Skip to content

Commit 747b492

Browse files
Pigbibiclaudecursoragent
committed
feat(risk): stamp consecutive_losses before strategy evaluate
Resolve trailing loss streak from live equity history into portfolio metadata so entrypoint risk gates can trip the circuit breaker. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 524613f commit 747b492

3 files changed

Lines changed: 143 additions & 8 deletions

File tree

application/rebalance_service.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,13 @@ def _record_platform_execution_telemetry(
5858
"orders_filled": list(summary.get("orders_filled") or ()),
5959
"orders_skipped": list(summary.get("orders_skipped") or ()),
6060
"trade_date": summary.get("trade_date") or metadata.get("trade_date"),
61-
"total_equity": summary.get("equity") or metadata.get("equity"),
61+
"total_equity": (
62+
summary.get("equity")
63+
or summary.get("total_equity")
64+
or metadata.get("equity")
65+
or metadata.get("portfolio_total_equity")
66+
or metadata.get("total_equity")
67+
),
6268
},
6369
domain=str(metadata.get("domain") or ""),
6470
)

strategy_runtime.py

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,46 @@ def _attach_strategy_plugin_metadata(
185185
return portfolio_snapshot
186186
return attach_strategy_plugin_metadata(portfolio_snapshot, tuple(strategy_plugin_signals or ()))
187187

188+
def _with_consecutive_loss_metadata(self, portfolio_snapshot: Any | None) -> Any | None:
189+
"""Stamp trailing consecutive_losses onto portfolio metadata before evaluate."""
190+
if portfolio_snapshot is None:
191+
return None
192+
metadata = dict(getattr(portfolio_snapshot, "metadata", None) or {})
193+
if metadata.get("consecutive_losses") is not None:
194+
return portfolio_snapshot
195+
try:
196+
from quant_platform_kit.strategy_lifecycle.live_equity import resolve_consecutive_losses
197+
from quant_platform_kit.strategy_lifecycle.performance_monitor import infer_strategy_domain
198+
199+
streak = resolve_consecutive_losses(
200+
domain=infer_strategy_domain(self.profile),
201+
strategy_profile=self.profile,
202+
)
203+
except Exception as exc:
204+
self.logger(
205+
"strategy_consecutive_losses_resolve_failed | "
206+
f"profile={self.profile} error_type={type(exc).__name__} error={exc}"
207+
)
208+
return portfolio_snapshot
209+
if streak is None:
210+
return portfolio_snapshot
211+
metadata["consecutive_losses"] = int(streak)
212+
return replace(portfolio_snapshot, metadata=metadata)
213+
214+
def _prepare_portfolio_snapshot(
215+
self,
216+
portfolio_snapshot: Any | None,
217+
strategy_symbols=(),
218+
strategy_plugin_signals=(),
219+
*,
220+
project: bool = True,
221+
) -> Any | None:
222+
snapshot = portfolio_snapshot
223+
if project:
224+
snapshot = self._project_portfolio_snapshot(snapshot, strategy_symbols)
225+
snapshot = self._attach_strategy_plugin_metadata(snapshot, strategy_plugin_signals)
226+
return self._with_consecutive_loss_metadata(snapshot)
227+
188228
@staticmethod
189229
def _normalize_symbols(symbols) -> tuple[str, ...]:
190230
normalized = []
@@ -680,11 +720,11 @@ def _evaluate_direct_market_data_strategy(
680720
ib,
681721
required=False,
682722
)
683-
portfolio_snapshot = self._project_portfolio_snapshot(
723+
portfolio_snapshot = self._prepare_portfolio_snapshot(
684724
portfolio_snapshot,
685725
self._configured_strategy_symbols(include_ranking_pool=True),
726+
strategy_plugin_signals,
686727
)
687-
portfolio_snapshot = self._attach_strategy_plugin_metadata(portfolio_snapshot, strategy_plugin_signals)
688728
option_chains = self._fetch_option_chains_for_runtime(ib, runtime_config, portfolio_snapshot)
689729
if option_chains:
690730
runtime_config["option_chains"] = option_chains
@@ -760,11 +800,11 @@ def _evaluate_market_data_strategy(
760800
ib,
761801
required=requires_portfolio,
762802
)
763-
portfolio_snapshot = self._project_portfolio_snapshot(
803+
portfolio_snapshot = self._prepare_portfolio_snapshot(
764804
portfolio_snapshot,
765805
self._configured_strategy_symbols(include_ranking_pool=True),
806+
strategy_plugin_signals,
766807
)
767-
portfolio_snapshot = self._attach_strategy_plugin_metadata(portfolio_snapshot, strategy_plugin_signals)
768808
option_chains = self._fetch_option_chains_for_runtime(ib, runtime_config, portfolio_snapshot)
769809
if option_chains:
770810
runtime_config["option_chains"] = option_chains
@@ -835,8 +875,11 @@ def _evaluate_value_target_strategy(
835875
apply_runtime_policy_to_runtime_config(runtime_config, self.runtime_adapter)
836876
managed_symbols = self._configured_strategy_symbols()
837877
portfolio_snapshot = self._fetch_portfolio_snapshot_for_context(ib, required=True)
838-
portfolio_snapshot = self._project_portfolio_snapshot(portfolio_snapshot, managed_symbols)
839-
portfolio_snapshot = self._attach_strategy_plugin_metadata(portfolio_snapshot, strategy_plugin_signals)
878+
portfolio_snapshot = self._prepare_portfolio_snapshot(
879+
portfolio_snapshot,
880+
managed_symbols,
881+
strategy_plugin_signals,
882+
)
840883
option_chains = self._fetch_option_chains_for_runtime(ib, runtime_config, portfolio_snapshot)
841884
if option_chains:
842885
runtime_config["option_chains"] = option_chains
@@ -965,7 +1008,11 @@ def build_available_inputs(feature_snapshot) -> Mapping[str, Any]:
9651008
ib,
9661009
required=requires_portfolio,
9671010
)
968-
portfolio_snapshot = self._attach_strategy_plugin_metadata(portfolio_snapshot, strategy_plugin_signals)
1011+
portfolio_snapshot = self._prepare_portfolio_snapshot(
1012+
portfolio_snapshot,
1013+
strategy_plugin_signals=strategy_plugin_signals,
1014+
project=False,
1015+
)
9691016
if portfolio_snapshot is not None:
9701017
portfolio_snapshot_holder["portfolio_snapshot"] = portfolio_snapshot
9711018
option_chains = self._fetch_option_chains_for_runtime(ib, runtime_config, portfolio_snapshot)

tests/test_strategy_runtime.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,3 +1221,85 @@ def evaluate(self, ctx):
12211221
assert enriched["unrealized_pnl_pct"] == -0.05
12221222
assert enriched["consecutive_losses"] == 2
12231223
assert enriched["portfolio_total_equity"] == 10_000.0
1224+
1225+
1226+
def test_with_consecutive_loss_metadata_stamps_from_resolver(monkeypatch):
1227+
from datetime import datetime, timezone
1228+
1229+
class FakeEntrypoint:
1230+
manifest = StrategyManifest(
1231+
profile="global_etf_rotation",
1232+
domain="us_equity",
1233+
display_name="Global ETF",
1234+
description="test",
1235+
required_inputs=frozenset(),
1236+
)
1237+
1238+
def evaluate(self, ctx):
1239+
return StrategyDecision(positions=())
1240+
1241+
runtime = strategy_runtime_module.LoadedStrategyRuntime(
1242+
entrypoint=FakeEntrypoint(),
1243+
runtime_adapter=StrategyRuntimeAdapter(),
1244+
runtime_settings=_build_runtime_settings("global_etf_rotation"),
1245+
logger=lambda _msg: None,
1246+
)
1247+
monkeypatch.setattr(
1248+
"quant_platform_kit.strategy_lifecycle.live_equity.resolve_consecutive_losses",
1249+
lambda **_kwargs: 4,
1250+
)
1251+
snapshot = PortfolioSnapshot(
1252+
as_of=datetime.now(timezone.utc),
1253+
total_equity=10_000.0,
1254+
positions=(),
1255+
metadata={},
1256+
)
1257+
1258+
stamped = runtime._with_consecutive_loss_metadata(snapshot)
1259+
1260+
assert stamped is not snapshot
1261+
assert stamped.metadata["consecutive_losses"] == 4
1262+
1263+
1264+
def test_with_consecutive_loss_metadata_preserves_explicit_value(monkeypatch):
1265+
from datetime import datetime, timezone
1266+
1267+
class FakeEntrypoint:
1268+
manifest = StrategyManifest(
1269+
profile="global_etf_rotation",
1270+
domain="us_equity",
1271+
display_name="Global ETF",
1272+
description="test",
1273+
required_inputs=frozenset(),
1274+
)
1275+
1276+
def evaluate(self, ctx):
1277+
return StrategyDecision(positions=())
1278+
1279+
runtime = strategy_runtime_module.LoadedStrategyRuntime(
1280+
entrypoint=FakeEntrypoint(),
1281+
runtime_adapter=StrategyRuntimeAdapter(),
1282+
runtime_settings=_build_runtime_settings("global_etf_rotation"),
1283+
logger=lambda _msg: None,
1284+
)
1285+
called = {"count": 0}
1286+
1287+
def _boom(**_kwargs):
1288+
called["count"] += 1
1289+
raise AssertionError("should not resolve when already set")
1290+
1291+
monkeypatch.setattr(
1292+
"quant_platform_kit.strategy_lifecycle.live_equity.resolve_consecutive_losses",
1293+
_boom,
1294+
)
1295+
snapshot = PortfolioSnapshot(
1296+
as_of=datetime.now(timezone.utc),
1297+
total_equity=10_000.0,
1298+
positions=(),
1299+
metadata={"consecutive_losses": 1},
1300+
)
1301+
1302+
stamped = runtime._with_consecutive_loss_metadata(snapshot)
1303+
1304+
assert stamped is snapshot
1305+
assert called["count"] == 0

0 commit comments

Comments
 (0)