Skip to content

Commit 9e630e5

Browse files
Pigbibicodex
andauthored
feat: attach verified IBKR capital base v2 (#396)
Co-authored-by: Codex <noreply@openai.com>
1 parent 14fcf68 commit 9e630e5

5 files changed

Lines changed: 142 additions & 12 deletions

File tree

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ dependencies = [
2121
"google-cloud-secret-manager",
2222
"google-cloud-storage",
2323
"yfinance",
24-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@2f84b5f1a22b134ea677a94850318ce8251ed40e",
24+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@3c6709019f8ac7db8792ec534f70eaf1d30c5322",
2525
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@1690b8a84aa819ee6d5063a9fff34bd1c51a5e04",
2626
"hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@8230eb2e3c7681c6c1348c8449773f73d58bfa15",
2727
]
@@ -64,5 +64,5 @@ include = [
6464

6565
[tool.uv]
6666
override-dependencies = [
67-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@2f84b5f1a22b134ea677a94850318ce8251ed40e",
67+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@3c6709019f8ac7db8792ec534f70eaf1d30c5322",
6868
]

‎qsl.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ upgrade_ring = "ring_d"
55
allow_legacy = false
66

77
[qsl.requires]
8-
quant_platform_kit = "2f84b5f1a22b134ea677a94850318ce8251ed40e"
8+
quant_platform_kit = "3c6709019f8ac7db8792ec534f70eaf1d30c5322"
99
us_equity_strategies = "1690b8a84aa819ee6d5063a9fff34bd1c51a5e04"
1010
hk_equity_strategies = "8230eb2e3c7681c6c1348c8449773f73d58bfa15"
1111

‎strategy_runtime.py‎

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
FeatureSnapshotRuntimeSettings,
1313
evaluate_feature_snapshot_strategy,
1414
)
15+
from quant_platform_kit.common.capital_base import (
16+
CapitalBaseBinding,
17+
CapitalScope,
18+
CapitalValuationBasis,
19+
build_capital_base_snapshot,
20+
)
1521
from quant_platform_kit.common.strategy_plugins import attach_strategy_plugin_metadata
1622
from quant_platform_kit.ibkr import (
1723
build_ibkr_strategy_context,
@@ -211,6 +217,74 @@ def _with_consecutive_loss_metadata(self, portfolio_snapshot: Any | None) -> Any
211217
metadata["consecutive_losses"] = int(streak)
212218
return replace(portfolio_snapshot, metadata=metadata)
213219

220+
def _build_capital_base_capabilities(
221+
self,
222+
portfolio_snapshot: Any | None,
223+
) -> tuple[dict[str, Any], str]:
224+
"""Attach strict v2 evidence only to an explicitly scoped IBKR snapshot."""
225+
226+
runtime_target = self.runtime_settings.runtime_target
227+
if portfolio_snapshot is None:
228+
return {}, "unavailable:portfolio_snapshot"
229+
if runtime_target is None:
230+
return {}, "unavailable:runtime_target"
231+
account_scope = str(runtime_target.account_scope or "").strip()
232+
runtime_scope = str(
233+
runtime_target.service_name or runtime_target.deployment_selector or ""
234+
).strip()
235+
if not account_scope or not runtime_scope:
236+
return {}, "unavailable:runtime_scope"
237+
metadata = getattr(portfolio_snapshot, "metadata", {})
238+
if not isinstance(metadata, Mapping):
239+
return {}, "unavailable:portfolio_metadata"
240+
if metadata.get("total_equity_source") != "broker_net_liquidation":
241+
return {}, "unavailable:unverified_net_liquidation"
242+
source_digest = str(metadata.get("source_digest_sha256") or "").strip()
243+
if not source_digest:
244+
return {}, "unavailable:source_digest"
245+
try:
246+
capital_base = build_capital_base_snapshot(
247+
portfolio_snapshot,
248+
account_scope=account_scope,
249+
runtime_scope=runtime_scope,
250+
strategy_scope=self.profile,
251+
reported_currency="USD",
252+
target_currency="USD",
253+
fx_rate_to_target=1.0,
254+
source_digest_sha256=source_digest,
255+
capital_scope=CapitalScope.ACCOUNT,
256+
valuation_basis=CapitalValuationBasis.BROKER_ACCOUNT_NET_LIQUIDATION,
257+
)
258+
binding = CapitalBaseBinding(
259+
account_scope=account_scope,
260+
runtime_scope=runtime_scope,
261+
strategy_scope=self.profile,
262+
target_currency="USD",
263+
capital_scope=CapitalScope.ACCOUNT,
264+
valuation_basis=CapitalValuationBasis.BROKER_ACCOUNT_NET_LIQUIDATION,
265+
)
266+
except (TypeError, ValueError):
267+
return {}, "unavailable:invalid_capital_evidence"
268+
return {
269+
"capital_base": capital_base,
270+
"capital_base_binding": binding,
271+
}, "verified:broker_account_net_liquidation"
272+
273+
def _build_context_capabilities(
274+
self,
275+
*,
276+
ib: Any | None,
277+
portfolio_snapshot: Any | None,
278+
) -> dict[str, Any]:
279+
capabilities: dict[str, Any] = {}
280+
if ib is not None:
281+
capabilities["broker_client"] = ib
282+
capital_base_capabilities, _status = self._build_capital_base_capabilities(
283+
portfolio_snapshot
284+
)
285+
capabilities.update(capital_base_capabilities)
286+
return capabilities
287+
214288
def _prepare_portfolio_snapshot(
215289
self,
216290
portfolio_snapshot: Any | None,
@@ -624,9 +698,10 @@ def _build_strategy_context(
624698
market_inputs=market_inputs,
625699
portfolio_snapshot=portfolio_snapshot,
626700
)
627-
capabilities = {}
628-
if ib is not None:
629-
capabilities["broker_client"] = ib
701+
capabilities = self._build_context_capabilities(
702+
ib=ib,
703+
portfolio_snapshot=portfolio_snapshot,
704+
)
630705
return build_strategy_context_from_available_inputs(
631706
entrypoint=self.entrypoint,
632707
runtime_adapter=context_adapter,
@@ -756,6 +831,7 @@ def _evaluate_direct_market_data_strategy(
756831
)
757832
metadata = {
758833
"strategy_profile": self.profile,
834+
"capital_base_status": self._build_capital_base_capabilities(portfolio_snapshot)[1],
759835
"managed_symbols": managed_symbols,
760836
"status_icon": self.status_icon,
761837
"dry_run_only": self.runtime_settings.dry_run_only,
@@ -838,6 +914,7 @@ def _evaluate_market_data_strategy(
838914
)
839915
metadata = {
840916
"strategy_profile": self.profile,
917+
"capital_base_status": self._build_capital_base_capabilities(portfolio_snapshot)[1],
841918
"managed_symbols": managed_symbols,
842919
"status_icon": self.status_icon,
843920
"dry_run_only": self.runtime_settings.dry_run_only,
@@ -899,6 +976,13 @@ def _evaluate_value_target_strategy(
899976
current_holdings=current_holdings,
900977
ib=ib,
901978
)
979+
ctx = replace(
980+
ctx,
981+
capabilities=self._build_context_capabilities(
982+
ib=ib,
983+
portfolio_snapshot=portfolio_snapshot,
984+
),
985+
)
902986
decision = self.entrypoint.evaluate(ctx)
903987
safe_haven_symbol = next(
904988
(position.symbol for position in decision.positions if position.role == "safe_haven"),
@@ -916,6 +1000,7 @@ def _evaluate_value_target_strategy(
9161000
metadata = self._enrich_portfolio_metadata(
9171001
{
9181002
"strategy_profile": self.profile,
1003+
"capital_base_status": self._build_capital_base_capabilities(portfolio_snapshot)[1],
9191004
"managed_symbols": managed_symbols,
9201005
"status_icon": self.status_icon,
9211006
"dry_run_only": self.runtime_settings.dry_run_only,
@@ -1044,9 +1129,10 @@ def build_context(request: FeatureSnapshotContextRequest):
10441129
available_inputs = dict(request.available_inputs)
10451130
if portfolio_snapshot is not None:
10461131
available_inputs[_PORTFOLIO_SNAPSHOT_INPUT] = portfolio_snapshot
1047-
capabilities = {}
1048-
if ib is not None:
1049-
capabilities["broker_client"] = ib
1132+
capabilities = self._build_context_capabilities(
1133+
ib=ib,
1134+
portfolio_snapshot=portfolio_snapshot,
1135+
)
10501136
return build_strategy_context_from_available_inputs(
10511137
entrypoint=request.entrypoint,
10521138
runtime_adapter=runtime_adapter,

‎tests/test_strategy_runtime.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sys
2+
from dataclasses import replace
23
from pathlib import Path
34
from types import SimpleNamespace
45

@@ -12,6 +13,7 @@
1213

1314
import strategy_runtime as strategy_runtime_module
1415
from quant_platform_kit.common.models import PortfolioSnapshot
16+
from quant_platform_kit.common.runtime_target import build_runtime_target
1517
from quant_platform_kit.strategy_contracts import (
1618
PositionTarget,
1719
StrategyDecision,
@@ -98,6 +100,48 @@ def _build_runtime_settings(
98100
)
99101

100102

103+
def test_loaded_runtime_attaches_v2_capital_base_only_for_verified_ibkr_net_liquidation():
104+
entrypoint = SimpleNamespace(manifest=SimpleNamespace(profile="soxl_soxx_trend_income"))
105+
runtime = strategy_runtime_module.LoadedStrategyRuntime(
106+
entrypoint=entrypoint,
107+
runtime_settings=replace(
108+
_build_runtime_settings(profile="soxl_soxx_trend_income"),
109+
runtime_target=build_runtime_target(
110+
platform_id="ibkr",
111+
strategy_profile="soxl_soxx_trend_income",
112+
dry_run_only=True,
113+
account_scope="paper-account-scope",
114+
service_name="ibkr-paper-service",
115+
execution_environment="dry_run",
116+
),
117+
),
118+
runtime_adapter=StrategyRuntimeAdapter(),
119+
logger=lambda _message: None,
120+
)
121+
verified_snapshot = PortfolioSnapshot(
122+
as_of=strategy_runtime_module.pd.Timestamp("2026-08-27", tz="UTC").to_pydatetime(),
123+
total_equity=1_000.0,
124+
metadata={
125+
"total_equity_source": "broker_net_liquidation",
126+
"source_digest_sha256": "b" * 64,
127+
},
128+
)
129+
130+
capabilities, status = runtime._build_capital_base_capabilities(verified_snapshot)
131+
132+
assert status == "verified:broker_account_net_liquidation"
133+
assert capabilities["capital_base"].to_safe_dict()["valuation_basis"] == "broker_account_net_liquidation"
134+
assert capabilities["capital_base_binding"].capital_scope.value == "account"
135+
136+
unverified = replace(
137+
verified_snapshot,
138+
metadata={"total_equity_source": "unverified_net_liquidation"},
139+
)
140+
capabilities, status = runtime._build_capital_base_capabilities(unverified)
141+
assert capabilities == {}
142+
assert status == "unavailable:unverified_net_liquidation"
143+
144+
101145
def test_main_compute_signals_uses_strategy_runtime_decision(strategy_module, monkeypatch):
102146
observed = {}
103147

‎uv.lock‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)