44from unittest .mock import patch
55
66from 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+ )
813from quant_platform_kit .strategy_contracts import BudgetIntent , PositionTarget , StrategyContext , StrategyDecision
914
1015from 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+
3492def 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
85143def 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
122184def 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:
162228def 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