summaryrefslogtreecommitdiff
path: root/services/portfolio-manager
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 17:55:44 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 17:55:44 +0900
commita65575124b18f2ec5d418623e22c5bdef6c3424e (patch)
tree2e9f9e2b66083c54adf017c3676d970a6b34d0d5 /services/portfolio-manager
parent70a33a5236fd9c3b51b8db0cbaf11376f9817ac5 (diff)
feat(portfolio): track realized PnL on sell orders
Diffstat (limited to 'services/portfolio-manager')
-rw-r--r--services/portfolio-manager/src/portfolio_manager/main.py2
-rw-r--r--services/portfolio-manager/src/portfolio_manager/portfolio.py13
-rw-r--r--services/portfolio-manager/tests/test_portfolio.py66
-rw-r--r--services/portfolio-manager/tests/test_snapshot.py4
4 files changed, 81 insertions, 4 deletions
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()