Skip to content

Commit 7c25daf

Browse files
Pigbibicodex
andcommitted
fix: bind crypto risk candidates to QPK 2f75
Co-Authored-By: Codex <noreply@openai.com>
1 parent da5e12c commit 7c25daf

8 files changed

Lines changed: 333 additions & 48 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ jobs:
6666
set -euo pipefail
6767
python -m pip install --upgrade pip
6868
python -m pip install -e . numpy pandas pytest pytest-cov ruff==0.15.22 build
69-
python -m pip install --no-deps -e external/QuantPlatformKit
7069
7170
- name: Verify dependencies
7271
run: |

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ description = "Shared crypto strategy catalog and implementations"
99
readme = "README.md"
1010
requires-python = ">=3.11"
1111
dependencies = [
12-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@5d4bbd0e7ef9a1434010e8b6a69905d39ee55f1b",
12+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@2f75b59289ef24ab47a3ed8d522c9ef8d6aea6b2",
1313
]
1414

1515
[tool.setuptools]

‎qsl.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ upgrade_ring = "ring_b"
44
[compat]
55
bundle = "2026.07.4"
66
requires = [
7-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@5d4bbd0e7ef9a1434010e8b6a69905d39ee55f1b",
7+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@2f75b59289ef24ab47a3ed8d522c9ef8d6aea6b2",
88
]

‎src/crypto_strategies/entrypoints/_common.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from dataclasses import asdict
77
from typing import Any
88

9+
from quant_platform_kit.risk.contracts import CandidateRiskIdentity
910
from quant_platform_kit.risk.gate import assess_with_evidence as _qpk_assess_with_evidence
1011
from quant_platform_kit.risk.gate import enrich_decision_risk_diagnostics
1112
from quant_platform_kit.risk.portfolio_diagnostics import extract_portfolio_risk_diagnostics
@@ -73,12 +74,16 @@ def apply_risk_gate(
7374
mandate_provenance = None if ctx is None else ctx.artifacts.get("mandate_provenance")
7475
if not isinstance(mandate_provenance, Mapping):
7576
mandate_provenance = {}
77+
candidate_identity = None if ctx is None else ctx.artifacts.get("candidate_risk_identity")
78+
if not isinstance(candidate_identity, CandidateRiskIdentity):
79+
candidate_identity = None
7680
result = _qpk_assess_with_evidence(
7781
decision,
7882
snapshot,
7983
scope="MEMBER",
8084
mandate_provenance=mandate_provenance,
8185
market_data=market_data or {},
86+
candidate_identity=candidate_identity,
8287
)
8388
risk_flags = tuple(
8489
dict.fromkeys(tuple(decision.risk_flags or ()) + tuple(result.decision.risk_flags or ()))

‎tests/test_entrypoint_risk_gate.py‎

Lines changed: 184 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,48 @@
44
from unittest.mock import patch
55

66
from quant_platform_kit.common.models import PortfolioSnapshot, Position
7-
from quant_platform_kit.risk.contracts import RiskGateAssessment, RiskGateResult
7+
from quant_platform_kit.risk import gate as qpk_risk_gate
8+
from quant_platform_kit.risk.contracts import (
9+
CandidateRiskIdentity,
10+
RiskGateAssessment,
11+
RiskGateResult,
12+
)
813
from quant_platform_kit.strategy_contracts import BudgetIntent, PositionTarget, StrategyContext, StrategyDecision
914

1015
from crypto_strategies.entrypoints._common import apply_risk_gate
1116

1217

13-
def _zero_cap_mandate(now: datetime) -> dict[str, object]:
18+
def _candidate_identity(
19+
*, strategy_profile: str = "crypto_live_pool_rotation"
20+
) -> CandidateRiskIdentity:
21+
return CandidateRiskIdentity(
22+
strategy_profile=strategy_profile,
23+
account_mode="single_strategy_account_v1",
24+
strategy_revision="1" * 40,
25+
runner_revision="2" * 40,
26+
config_sha256="3" * 64,
27+
input_manifest_sha256="4" * 64,
28+
authority_receipt_sha256="246c39b8023b25f913bf1e67dc175005955a7102f3727dfc1bd8e981cf8128ee",
29+
)
30+
31+
32+
def _zero_cap_mandate(
33+
now: datetime,
34+
*,
35+
candidate_identity: CandidateRiskIdentity,
36+
) -> dict[str, object]:
1437
return {
1538
"mandate_id": "binance_crypto_research_only_v1",
1639
"mandate_version": "2026-08-04.1",
17-
"authority_receipt_sha256": "246c39b8023b25f913bf1e67dc175005955a7102f3727dfc1bd8e981cf8128ee",
40+
"authority_receipt_sha256": candidate_identity.authority_receipt_sha256,
1841
"authority_scope": "RESEARCH_ONLY",
19-
"strategy_profile": "crypto_live_pool_rotation",
20-
"account_mode": "single_strategy_account_v1",
42+
"strategy_profile": candidate_identity.strategy_profile,
43+
"account_mode": candidate_identity.account_mode,
44+
"strategy_revision": candidate_identity.strategy_revision,
45+
"runner_revision": candidate_identity.runner_revision,
46+
"config_sha256": candidate_identity.config_sha256,
47+
"input_manifest_sha256": candidate_identity.input_manifest_sha256,
48+
"candidate_identity_sha256": candidate_identity.candidate_sha256,
2149
"effective_at": (now - timedelta(minutes=1)).isoformat().replace("+00:00", "Z"),
2250
"expires_at": (now + timedelta(minutes=1)).isoformat().replace("+00:00", "Z"),
2351
"max_snapshot_age_seconds": 300,
@@ -31,6 +59,36 @@ def _zero_cap_mandate(now: datetime) -> dict[str, object]:
3159
}
3260

3361

62+
def _candidate_bound_artifacts(
63+
now: datetime,
64+
*,
65+
candidate_identity: CandidateRiskIdentity | None = None,
66+
) -> dict[str, object]:
67+
candidate = candidate_identity or _candidate_identity()
68+
return {
69+
"mandate_provenance": _zero_cap_mandate(
70+
now,
71+
candidate_identity=candidate,
72+
),
73+
"candidate_risk_identity": candidate,
74+
}
75+
76+
77+
def _apply_risk_gate_once(
78+
decision: StrategyDecision,
79+
**kwargs: object,
80+
) -> StrategyDecision:
81+
engine = qpk_risk_gate.build_risk_engine()
82+
with patch.object(engine, "assess", wraps=engine.assess) as assess, patch.object(
83+
qpk_risk_gate,
84+
"build_risk_engine",
85+
return_value=engine,
86+
):
87+
result = apply_risk_gate(decision, **kwargs)
88+
assess.assert_called_once()
89+
return result
90+
91+
3492
def test_apply_risk_gate_enriches_stop_loss_diagnostics_from_portfolio() -> None:
3593
snapshot = PortfolioSnapshot(
3694
as_of=datetime(2026, 7, 9, tzinfo=timezone.utc),
@@ -42,7 +100,7 @@ def test_apply_risk_gate_enriches_stop_loss_diagnostics_from_portfolio() -> None
42100
)
43101
ctx = StrategyContext(as_of=snapshot.as_of, portfolio=snapshot, market_data={}, state={}, runtime_config={})
44102
decision = StrategyDecision(positions=(PositionTarget(symbol="BTCUSDT", target_weight=0.5),))
45-
result = apply_risk_gate(decision, ctx=ctx)
103+
result = _apply_risk_gate_once(decision, ctx=ctx)
46104
assert result.positions == ()
47105
assert "rejected:risk_gate_assessment" in result.risk_flags
48106
assert result.diagnostics["member_risk_assessment"]["outcome"] == "REJECT"
@@ -62,14 +120,14 @@ def test_apply_risk_gate_uses_member_evidence_and_zero_cap_clears_authority() ->
62120
as_of=now,
63121
portfolio=snapshot,
64122
market_data={"private_api_token": "must-not-propagate"},
65-
artifacts={"mandate_provenance": _zero_cap_mandate(now)},
123+
artifacts=_candidate_bound_artifacts(now),
66124
)
67125
decision = StrategyDecision(
68126
positions=(PositionTarget(symbol="BTCUSDT", target_weight=0.1),),
69127
budgets=(BudgetIntent(name="btc", amount=1.0),),
70128
)
71129

72-
result = apply_risk_gate(decision, ctx=ctx)
130+
result = _apply_risk_gate_once(decision, ctx=ctx)
73131

74132
assessment = result.diagnostics["member_risk_assessment"]
75133
assert result.positions == ()
@@ -84,7 +142,8 @@ def test_apply_risk_gate_uses_member_evidence_and_zero_cap_clears_authority() ->
84142

85143
def test_apply_risk_gate_preserves_stricter_strategy_concentration_cap() -> None:
86144
now = datetime.now(timezone.utc)
87-
mandate = _zero_cap_mandate(now)
145+
candidate_identity = _candidate_identity()
146+
mandate = _zero_cap_mandate(now, candidate_identity=candidate_identity)
88147
mandate.update(
89148
{
90149
"mandate_id": "synthetic_algorithm_equivalence_only",
@@ -104,10 +163,13 @@ def test_apply_risk_gate_preserves_stricter_strategy_concentration_cap() -> None
104163
ctx = StrategyContext(
105164
as_of=now,
106165
portfolio=snapshot,
107-
artifacts={"mandate_provenance": mandate},
166+
artifacts={
167+
"mandate_provenance": mandate,
168+
"candidate_risk_identity": candidate_identity,
169+
},
108170
)
109171

110-
result = apply_risk_gate(
172+
result = _apply_risk_gate_once(
111173
StrategyDecision(positions=(PositionTarget(symbol="BTCUSDT", target_weight=0.6),)),
112174
ctx=ctx,
113175
max_single_weight=0.5,
@@ -122,7 +184,8 @@ def test_apply_risk_gate_preserves_stricter_strategy_concentration_cap() -> None
122184
def test_apply_risk_gate_preserves_hard_position_count_limit() -> None:
123185
now = datetime.now(timezone.utc)
124186
symbols = [f"ASSET{index}USDT" for index in range(21)]
125-
mandate = _zero_cap_mandate(now)
187+
candidate_identity = _candidate_identity()
188+
mandate = _zero_cap_mandate(now, candidate_identity=candidate_identity)
126189
mandate.update(
127190
{
128191
"mandate_id": "synthetic_algorithm_equivalence_only",
@@ -142,7 +205,10 @@ def test_apply_risk_gate_preserves_hard_position_count_limit() -> None:
142205
ctx = StrategyContext(
143206
as_of=now,
144207
portfolio=snapshot,
145-
artifacts={"mandate_provenance": mandate},
208+
artifacts={
209+
"mandate_provenance": mandate,
210+
"candidate_risk_identity": candidate_identity,
211+
},
146212
)
147213
decision = StrategyDecision(
148214
positions=tuple(
@@ -151,7 +217,7 @@ def test_apply_risk_gate_preserves_hard_position_count_limit() -> None:
151217
budgets=(BudgetIntent(name="portfolio", amount=1.0),),
152218
)
153219

154-
result = apply_risk_gate(decision, ctx=ctx)
220+
result = _apply_risk_gate_once(decision, ctx=ctx)
155221

156222
assert result.diagnostics["member_risk_assessment"]["outcome"] == "APPROVE"
157223
assert result.positions == ()
@@ -162,7 +228,8 @@ def test_apply_risk_gate_preserves_hard_position_count_limit() -> None:
162228
def test_apply_risk_gate_preserves_hard_total_exposure_limit() -> None:
163229
now = datetime.now(timezone.utc)
164230
symbols = [f"ASSET{index}USDT" for index in range(5)]
165-
mandate = _zero_cap_mandate(now)
231+
candidate_identity = _candidate_identity()
232+
mandate = _zero_cap_mandate(now, candidate_identity=candidate_identity)
166233
mandate.update(
167234
{
168235
"mandate_id": "synthetic_algorithm_equivalence_only",
@@ -182,7 +249,10 @@ def test_apply_risk_gate_preserves_hard_total_exposure_limit() -> None:
182249
ctx = StrategyContext(
183250
as_of=now,
184251
portfolio=snapshot,
185-
artifacts={"mandate_provenance": mandate},
252+
artifacts={
253+
"mandate_provenance": mandate,
254+
"candidate_risk_identity": candidate_identity,
255+
},
186256
)
187257
decision = StrategyDecision(
188258
positions=tuple(
@@ -202,8 +272,10 @@ def test_apply_risk_gate_preserves_hard_total_exposure_limit() -> None:
202272
mandate_version="test-v1",
203273
mandate_authority_receipt_sha256="a" * 64,
204274
mandate_scope="RESEARCH_ONLY",
275+
candidate_identity_sha256=candidate_identity.candidate_sha256,
205276
decision_digest_sha256="b" * 64,
206277
portfolio_snapshot_digest_sha256="c" * 64,
278+
normalization_origin_digest_sha256=None,
207279
effective_exposure_cap=2.0,
208280
observed_effective_exposure=0.0,
209281
proposed_effective_exposure=1.25,
@@ -216,8 +288,9 @@ def test_apply_risk_gate_preserves_hard_total_exposure_limit() -> None:
216288
decision=decision,
217289
assessment=permissive_assessment,
218290
),
219-
):
291+
) as assess:
220292
result = apply_risk_gate(decision, ctx=ctx)
293+
assess.assert_called_once()
221294

222295
assert result.diagnostics["member_risk_assessment"]["outcome"] == "APPROVE"
223296
assert result.positions == ()
@@ -235,9 +308,102 @@ def test_apply_risk_gate_preserves_hard_total_exposure_limit() -> None:
235308
decision=invalid_decision,
236309
assessment=permissive_assessment,
237310
),
238-
):
311+
) as assess:
239312
invalid_result = apply_risk_gate(invalid_decision, ctx=ctx)
313+
assess.assert_called_once()
240314

241315
assert invalid_result.positions == ()
242316
assert invalid_result.budgets == ()
243317
assert "rejected:overexposed" in invalid_result.risk_flags
318+
319+
320+
def test_apply_risk_gate_does_not_coerce_mapping_candidate_identity() -> None:
321+
now = datetime.now(timezone.utc)
322+
candidate_identity = _candidate_identity()
323+
artifacts = _candidate_bound_artifacts(now, candidate_identity=candidate_identity)
324+
artifacts["candidate_risk_identity"] = {
325+
"strategy_profile": candidate_identity.strategy_profile,
326+
"candidate_sha256": candidate_identity.candidate_sha256,
327+
}
328+
ctx = StrategyContext(
329+
as_of=now,
330+
portfolio=PortfolioSnapshot(
331+
as_of=now,
332+
total_equity=1000.0,
333+
metadata={"observed_effective_exposure": 0.0},
334+
),
335+
artifacts=artifacts,
336+
)
337+
338+
result = _apply_risk_gate_once(
339+
StrategyDecision(
340+
positions=(PositionTarget(symbol="BTCUSDT", target_weight=0.1),)
341+
),
342+
ctx=ctx,
343+
)
344+
345+
assessment = result.diagnostics["member_risk_assessment"]
346+
assert result.positions == ()
347+
assert result.budgets == ()
348+
assert assessment["outcome"] == "REJECT"
349+
assert "missing_candidate_identity" in assessment["reason_codes"]
350+
351+
352+
def test_apply_risk_gate_wrong_typed_candidate_identity_fails_closed() -> None:
353+
now = datetime.now(timezone.utc)
354+
expected_identity = _candidate_identity()
355+
wrong_identity = _candidate_identity(strategy_profile="crypto_equity_combo")
356+
artifacts = _candidate_bound_artifacts(now, candidate_identity=expected_identity)
357+
artifacts["candidate_risk_identity"] = wrong_identity
358+
ctx = StrategyContext(
359+
as_of=now,
360+
portfolio=PortfolioSnapshot(
361+
as_of=now,
362+
total_equity=1000.0,
363+
metadata={"observed_effective_exposure": 0.0},
364+
),
365+
artifacts=artifacts,
366+
)
367+
368+
result = _apply_risk_gate_once(
369+
StrategyDecision(
370+
positions=(PositionTarget(symbol="BTCUSDT", target_weight=0.1),)
371+
),
372+
ctx=ctx,
373+
)
374+
375+
assessment = result.diagnostics["member_risk_assessment"]
376+
assert result.positions == ()
377+
assert result.budgets == ()
378+
assert assessment["outcome"] == "REJECT"
379+
assert "candidate_strategy_profile_mismatch" in assessment["reason_codes"]
380+
381+
382+
def test_apply_risk_gate_incomplete_mandate_stays_fail_closed() -> None:
383+
now = datetime.now(timezone.utc)
384+
candidate_identity = _candidate_identity()
385+
ctx = StrategyContext(
386+
as_of=now,
387+
portfolio=PortfolioSnapshot(
388+
as_of=now,
389+
total_equity=1000.0,
390+
metadata={"observed_effective_exposure": 0.0},
391+
),
392+
artifacts={
393+
"mandate_provenance": {"mandate_id": "incomplete"},
394+
"candidate_risk_identity": candidate_identity,
395+
},
396+
)
397+
398+
result = _apply_risk_gate_once(
399+
StrategyDecision(
400+
positions=(PositionTarget(symbol="BTCUSDT", target_weight=0.1),)
401+
),
402+
ctx=ctx,
403+
)
404+
405+
assessment = result.diagnostics["member_risk_assessment"]
406+
assert result.positions == ()
407+
assert result.budgets == ()
408+
assert assessment["outcome"] == "REJECT"
409+
assert "invalid_mandate" in assessment["reason_codes"]

0 commit comments

Comments
 (0)