From a65575124b18f2ec5d418623e22c5bdef6c3424e Mon Sep 17 00:00:00 2001 From: TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:55:44 +0900 Subject: feat(portfolio): track realized PnL on sell orders --- .../src/portfolio_manager/main.py | 2 +- .../src/portfolio_manager/portfolio.py | 13 ++++- services/portfolio-manager/tests/test_portfolio.py | 66 ++++++++++++++++++++++ services/portfolio-manager/tests/test_snapshot.py | 4 +- 4 files changed, 81 insertions(+), 4 deletions(-) (limited to 'services/portfolio-manager') diff --git a/services/portfolio-manager/src/portfolio_manager/main.py b/services/portfolio-manager/src/portfolio_manager/main.py index c453745..a7f1a14 100644 --- a/services/portfolio-manager/src/portfolio_manager/main.py +++ b/services/portfolio-manager/src/portfolio_manager/main.py @@ -34,7 +34,7 @@ async def save_snapshot( unrealized = sum(p.unrealized_pnl for p in positions) await db.insert_portfolio_snapshot( total_value=total_value, - realized_pnl=Decimal("0"), # TODO: track realized PnL + realized_pnl=tracker.realized_pnl, unrealized_pnl=unrealized, ) await notifier.send_daily_summary(positions, total_value, unrealized) diff --git a/services/portfolio-manager/src/portfolio_manager/portfolio.py b/services/portfolio-manager/src/portfolio_manager/portfolio.py index 2c93643..4a7a11f 100644 --- a/services/portfolio-manager/src/portfolio_manager/portfolio.py +++ b/services/portfolio-manager/src/portfolio_manager/portfolio.py @@ -18,6 +18,11 @@ class PortfolioTracker: def __init__(self) -> None: self._positions: dict[str, _PositionState] = {} + self._realized_pnl: Decimal = Decimal("0") + + @property + def realized_pnl(self) -> Decimal: + return self._realized_pnl def _get_or_create(self, symbol: str) -> _PositionState: if symbol not in self._positions: @@ -35,8 +40,12 @@ class PortfolioTracker: if state.quantity > Decimal("0"): state.avg_entry = total_cost / state.quantity elif order.side == OrderSide.SELL: - state.quantity -= order.quantity - # Keep avg_entry unchanged unless fully sold + # Calculate realized PnL for this sell + sell_quantity = min(order.quantity, state.quantity) + if sell_quantity > 0 and state.avg_entry > 0: + self._realized_pnl += sell_quantity * (order.price - state.avg_entry) + + state.quantity -= sell_quantity if state.quantity <= Decimal("0"): state.quantity = Decimal("0") state.avg_entry = Decimal("0") diff --git a/services/portfolio-manager/tests/test_portfolio.py b/services/portfolio-manager/tests/test_portfolio.py index 92ff6ca..5a7ac64 100644 --- a/services/portfolio-manager/tests/test_portfolio.py +++ b/services/portfolio-manager/tests/test_portfolio.py @@ -56,3 +56,69 @@ def test_portfolio_no_position_returns_none() -> None: tracker = PortfolioTracker() position = tracker.get_position("ETH/USDT") assert position is None + + +def test_realized_pnl_on_sell() -> None: + """Selling should track realized PnL.""" + tracker = PortfolioTracker() + + # Buy at 50000 + tracker.apply_order(Order( + signal_id="s1", symbol="BTCUSDT", side=OrderSide.BUY, + type=OrderType.MARKET, price=Decimal("50000"), + quantity=Decimal("0.1"), status=OrderStatus.FILLED, + )) + assert tracker.realized_pnl == Decimal("0") + + # Sell at 55000 — profit of 500 + tracker.apply_order(Order( + signal_id="s2", symbol="BTCUSDT", side=OrderSide.SELL, + type=OrderType.MARKET, price=Decimal("55000"), + quantity=Decimal("0.1"), status=OrderStatus.FILLED, + )) + assert tracker.realized_pnl == Decimal("500") + + +def test_realized_pnl_on_loss() -> None: + """Selling at a loss should track negative realized PnL.""" + tracker = PortfolioTracker() + + tracker.apply_order(Order( + signal_id="s1", symbol="BTCUSDT", side=OrderSide.BUY, + type=OrderType.MARKET, price=Decimal("50000"), + quantity=Decimal("0.1"), status=OrderStatus.FILLED, + )) + tracker.apply_order(Order( + signal_id="s2", symbol="BTCUSDT", side=OrderSide.SELL, + type=OrderType.MARKET, price=Decimal("45000"), + quantity=Decimal("0.1"), status=OrderStatus.FILLED, + )) + assert tracker.realized_pnl == Decimal("-500") + + +def test_realized_pnl_accumulates() -> None: + """Multiple sells accumulate realized PnL.""" + tracker = PortfolioTracker() + + # Buy 0.2 at 50000 + tracker.apply_order(Order( + signal_id="s1", symbol="BTCUSDT", side=OrderSide.BUY, + type=OrderType.MARKET, price=Decimal("50000"), + quantity=Decimal("0.2"), status=OrderStatus.FILLED, + )) + + # Sell 0.1 at 55000 -> +500 + tracker.apply_order(Order( + signal_id="s2", symbol="BTCUSDT", side=OrderSide.SELL, + type=OrderType.MARKET, price=Decimal("55000"), + quantity=Decimal("0.1"), status=OrderStatus.FILLED, + )) + + # Sell 0.1 at 60000 -> +1000 + tracker.apply_order(Order( + signal_id="s3", symbol="BTCUSDT", side=OrderSide.SELL, + type=OrderType.MARKET, price=Decimal("60000"), + quantity=Decimal("0.1"), status=OrderStatus.FILLED, + )) + + assert tracker.realized_pnl == Decimal("1500") diff --git a/services/portfolio-manager/tests/test_snapshot.py b/services/portfolio-manager/tests/test_snapshot.py index 89d23d7..a464599 100644 --- a/services/portfolio-manager/tests/test_snapshot.py +++ b/services/portfolio-manager/tests/test_snapshot.py @@ -21,6 +21,7 @@ class TestSaveSnapshot: tracker = MagicMock() tracker.get_all_positions.return_value = [pos] + tracker.realized_pnl = Decimal("500") db = AsyncMock() notifier = AsyncMock() @@ -33,7 +34,7 @@ class TestSaveSnapshot: db.insert_portfolio_snapshot.assert_awaited_once_with( total_value=expected_total, - realized_pnl=Decimal("0"), + realized_pnl=Decimal("500"), unrealized_pnl=expected_unrealized, ) notifier.send_daily_summary.assert_awaited_once_with( @@ -51,6 +52,7 @@ class TestSaveSnapshot: tracker = MagicMock() tracker.get_all_positions.return_value = [] + tracker.realized_pnl = Decimal("0") db = AsyncMock() notifier = AsyncMock() -- cgit v1.2.3