Skip to content

Commit 270bfe8

Browse files
Pigbibiclaudecursoragent
authored
feat(strategy_runtime): enrich portfolio metadata with unrealized PnL (#313)
* feat(strategy_runtime): compute unrealized PnL in portfolio metadata Enrich runtime metadata with QPK extract_portfolio_risk_diagnostics so decision_mapper can forward stop-loss / circuit-breaker inputs. Depends on QuantPlatformKit#197 (QPK pin bumped). Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ibkr): QPK pin fallback for portfolio risk diagnostics Keep canonical QPK pin; use local fallback module until QPK#197 merges. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3fe6c1e commit 270bfe8

3 files changed

Lines changed: 85 additions & 0 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Portfolio risk diagnostics with QPK import fallback until pin bumps."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
try:
8+
from quant_platform_kit.risk.portfolio_diagnostics import extract_portfolio_risk_diagnostics
9+
except ImportError: # pragma: no cover - exercised only before QPK pin bump
10+
11+
def _position_unrealized_pnl(position: Any) -> float | None:
12+
quantity = float(getattr(position, "quantity", 0.0) or 0.0)
13+
if quantity == 0.0:
14+
return 0.0
15+
market_value = float(getattr(position, "market_value", 0.0) or 0.0)
16+
average_cost = getattr(position, "average_cost", None)
17+
if average_cost is None:
18+
return None
19+
cost_basis = abs(quantity) * float(average_cost)
20+
return market_value - cost_basis
21+
22+
def extract_portfolio_risk_diagnostics(snapshot: Any) -> dict[str, float | int]:
23+
diagnostics: dict[str, float | int] = {}
24+
total_equity = float(getattr(snapshot, "total_equity", 0.0) or 0.0)
25+
metadata = dict(getattr(snapshot, "metadata", None) or {})
26+
if metadata.get("unrealized_pnl_pct") is not None:
27+
diagnostics["unrealized_pnl_pct"] = float(metadata["unrealized_pnl_pct"])
28+
elif total_equity > 0.0:
29+
positions = getattr(snapshot, "positions", ()) or ()
30+
unrealized = 0.0
31+
has_cost_basis = False
32+
for position in positions:
33+
position_pnl = _position_unrealized_pnl(position)
34+
if position_pnl is None:
35+
continue
36+
has_cost_basis = True
37+
unrealized += position_pnl
38+
if has_cost_basis or not positions:
39+
diagnostics["unrealized_pnl_pct"] = float(unrealized / total_equity)
40+
if metadata.get("consecutive_losses") is not None:
41+
diagnostics["consecutive_losses"] = int(metadata["consecutive_losses"])
42+
return diagnostics
43+
44+
__all__ = ["extract_portfolio_risk_diagnostics"]

strategy_runtime.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,9 @@ def _enrich_portfolio_metadata(
444444
for position in getattr(portfolio_snapshot, "positions", ()) or ()
445445
if str(getattr(position, "symbol", "") or "").strip()
446446
}
447+
from application.portfolio_risk_diagnostics import extract_portfolio_risk_diagnostics
448+
449+
enriched.update(extract_portfolio_risk_diagnostics(portfolio_snapshot))
447450
return enriched
448451

449452
def _build_market_history_inputs(

tests/test_strategy_runtime.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,3 +1183,41 @@ def fake_close_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):
11831183
assert close_loader_symbols == [("TQQQ", "10 D", "1 day"), ("BOXX", "10 D", "1 day")]
11841184
assert result.metadata["price_fallback_source"] == "historical_close"
11851185
assert result.metadata["price_fallbacks"] == {"TQQQ": 70.0, "BOXX": 105.0}
1186+
1187+
1188+
def test_enrich_portfolio_metadata_includes_unrealized_pnl():
1189+
from datetime import datetime, timezone
1190+
1191+
from quant_platform_kit.common.models import Position
1192+
1193+
class FakeEntrypoint:
1194+
manifest = StrategyManifest(
1195+
profile="tech_communication_pullback_enhancement",
1196+
domain="us_equity",
1197+
display_name="Tech Pullback",
1198+
description="test",
1199+
required_inputs=frozenset(),
1200+
)
1201+
1202+
def evaluate(self, ctx):
1203+
return StrategyDecision(positions=())
1204+
1205+
runtime = strategy_runtime_module.LoadedStrategyRuntime(
1206+
entrypoint=FakeEntrypoint(),
1207+
runtime_adapter=StrategyRuntimeAdapter(),
1208+
runtime_settings=_build_runtime_settings(),
1209+
)
1210+
snapshot = PortfolioSnapshot(
1211+
as_of=datetime.now(timezone.utc),
1212+
total_equity=10_000.0,
1213+
positions=(
1214+
Position(symbol="SPY", quantity=10.0, market_value=4_500.0, average_cost=500.0),
1215+
),
1216+
metadata={"consecutive_losses": 2},
1217+
)
1218+
1219+
enriched = runtime._enrich_portfolio_metadata({}, snapshot)
1220+
1221+
assert enriched["unrealized_pnl_pct"] == -0.05
1222+
assert enriched["consecutive_losses"] == 2
1223+
assert enriched["portfolio_total_equity"] == 10_000.0

0 commit comments

Comments
 (0)