Skip to content

Commit 08eed91

Browse files
authored
Merge pull request #522 from QuantStrategyLab/codex/qsl-g2-evidence-risk-gate
Harden evidence-grade risk assessment gate
2 parents cc82758 + 6794608 commit 08eed91

3 files changed

Lines changed: 120 additions & 5 deletions

File tree

src/quant_platform_kit/risk/engine.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,13 @@ def assess(
120120
*,
121121
market_data: Mapping[str, Any] | None = None,
122122
) -> RiskAction:
123-
"""Assess a decision, rejecting missing or invalid account state."""
123+
"""Run the legacy runtime sanity check for account state.
124+
125+
This method is not evidence-grade and its ``approve`` result must not
126+
be used as promotion authority. Promotion/evidence callers must use
127+
:func:`quant_platform_kit.risk.gate.assess_with_evidence`, which adds
128+
the mandate, candidate, snapshot, and exposure contract checks.
129+
"""
124130
if portfolio_snapshot is None:
125131
return RiskAction(
126132
action="reject",

src/quant_platform_kit/risk/gate.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,7 @@ def _mandate_fields(
568568
"loss_budget": 0.0,
569569
"product_leverage_factors": {},
570570
"allowed_nonzero_assets": None,
571-
}, set()
571+
}, {"missing_mandate"}
572572
if not isinstance(mandate_provenance, Mapping):
573573
return {}, {"invalid_mandate"}
574574
if mandate_provenance.get("mandate_id") == _RETIRED_GLOBAL_ETF_RESEARCH_MANDATE:
@@ -1316,7 +1316,13 @@ def assess_with_evidence(
13161316
capital_base: CapitalBaseSnapshot | Mapping[str, Any] | None = None,
13171317
capital_base_binding: CapitalBaseBinding | Mapping[str, Any] | None = None,
13181318
) -> RiskGateResult:
1319-
"""Assess exactly once and fail closed with a redacted canonical receipt."""
1319+
"""Run the sole promotion/evidence-grade risk assessment API.
1320+
1321+
``mandate_provenance`` and a matching ``candidate_identity`` are required
1322+
for an evidence-grade approval. Missing or invalid authority is rejected;
1323+
the legacy :meth:`RiskEngine.assess` approval is never sufficient on its
1324+
own. The returned receipt is redacted and canonical.
1325+
"""
13201326
try:
13211327
risk_action = build_risk_engine().assess(
13221328
decision,

tests/test_risk_gate.py

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,95 @@ def test_unmandated_fallback_rejects_stale_snapshot(self) -> None:
10771077
self.assertIn("stale_portfolio_snapshot", result.assessment.reason_codes)
10781078
self.assertEqual(result.decision.positions, ())
10791079

1080+
def test_evidence_assessment_requires_mandate(self) -> None:
1081+
with patch("quant_platform_kit.risk.gate._utc_now", return_value=self._NOW):
1082+
result = assess_with_evidence(
1083+
_decision(positions=(PositionTarget(symbol="BTCUSDT", target_weight=0.10),)),
1084+
self._snapshot(),
1085+
scope="MEMBER",
1086+
mandate_provenance=None,
1087+
market_data={},
1088+
candidate_identity=None,
1089+
)
1090+
1091+
self.assertEqual(result.assessment.outcome, "REJECT")
1092+
self.assertIn("missing_mandate", result.assessment.reason_codes)
1093+
self.assertFalse(result.assessment.execution_authorized)
1094+
self.assertEqual(result.decision.positions, ())
1095+
1096+
def test_evidence_assessment_rejects_unbounded_target_and_unknown_symbol(self) -> None:
1097+
cases = (
1098+
(
1099+
"10x_target",
1100+
_decision(positions=(PositionTarget(symbol="BTCUSDT", target_weight=10.0),)),
1101+
"effective_exposure_cap",
1102+
),
1103+
(
1104+
"unknown_symbol",
1105+
_decision(positions=(PositionTarget(symbol="UNKNOWN", target_weight=0.10),)),
1106+
"asset_not_authorized",
1107+
),
1108+
)
1109+
for name, decision, reason_code in cases:
1110+
with (
1111+
self.subTest(name=name),
1112+
patch("quant_platform_kit.risk.gate._utc_now", return_value=self._NOW),
1113+
):
1114+
result = assess_with_evidence(
1115+
decision,
1116+
self._snapshot(),
1117+
scope="MEMBER",
1118+
mandate_provenance=self._mandate(),
1119+
market_data={},
1120+
candidate_identity=self._candidate(),
1121+
)
1122+
1123+
self.assertEqual(result.assessment.outcome, "REJECT")
1124+
self.assertIn(reason_code, result.assessment.reason_codes)
1125+
self.assertFalse(result.assessment.execution_authorized)
1126+
self.assertEqual(result.decision.positions, ())
1127+
1128+
def test_evidence_assessment_rejects_expired_mandate(self) -> None:
1129+
with patch("quant_platform_kit.risk.gate._utc_now", return_value=self._NOW):
1130+
result = assess_with_evidence(
1131+
_decision(positions=(PositionTarget(symbol="BTCUSDT", target_weight=0.10),)),
1132+
self._snapshot(),
1133+
scope="MEMBER",
1134+
mandate_provenance={
1135+
**self._mandate(),
1136+
"expires_at": "2026-08-04T04:27:59Z",
1137+
},
1138+
market_data={},
1139+
candidate_identity=self._candidate(),
1140+
)
1141+
1142+
self.assertEqual(result.assessment.outcome, "REJECT")
1143+
self.assertIn("expired_mandate", result.assessment.reason_codes)
1144+
self.assertFalse(result.assessment.execution_authorized)
1145+
1146+
def test_evidence_assessment_rejects_stale_capital_snapshot(self) -> None:
1147+
with patch("quant_platform_kit.risk.gate._utc_now", return_value=self._NOW):
1148+
result = assess_with_evidence(
1149+
_decision(positions=(PositionTarget(symbol="BTCUSDT", target_value=10_000.0),)),
1150+
self._snapshot(),
1151+
scope="MEMBER",
1152+
mandate_provenance=self._mandate(),
1153+
market_data={},
1154+
candidate_identity=self._candidate(),
1155+
capital_base=_capital_base(
1156+
as_of=self._NOW - timedelta(seconds=301),
1157+
strategy_scope="crypto_live_pool_rotation",
1158+
),
1159+
capital_base_binding=_capital_base_binding(
1160+
strategy_scope="crypto_live_pool_rotation",
1161+
),
1162+
)
1163+
1164+
self.assertEqual(result.assessment.outcome, "REJECT")
1165+
self.assertIn("stale_capital_base", result.assessment.reason_codes)
1166+
self.assertFalse(result.assessment.execution_authorized)
1167+
self.assertEqual(result.decision.positions, ())
1168+
10801169
def test_risk_plugin_exception_rejects_without_exposing_exception(self) -> None:
10811170
class CrashingPlugin:
10821171
plugin_name = "crashing_plugin"
@@ -1377,8 +1466,16 @@ def test_optional_string_nulls_and_nfc_text_remain_canonical(self) -> None:
13771466
),
13781467
),
13791468
)
1380-
first, first_engine = self._assess(decision)
1381-
second, second_engine = self._assess(decision)
1469+
first, first_engine = self._assess(
1470+
decision,
1471+
mandate=self._mandate(),
1472+
candidate=self._candidate(),
1473+
)
1474+
second, second_engine = self._assess(
1475+
decision,
1476+
mandate=self._mandate(),
1477+
candidate=self._candidate(),
1478+
)
13821479

13831480
self.assertEqual(first.assessment.outcome, "APPROVE")
13841481
self.assertEqual(first.assessment.assessment_sha256, second.assessment.assessment_sha256)
@@ -1568,14 +1665,20 @@ def test_timezone_and_whole_second_timestamp_compatibility(self) -> None:
15681665
first, _engine = self._assess(
15691666
StrategyDecision(),
15701667
snapshot={**self._SNAPSHOT, "as_of": utc.replace(microsecond=1)},
1668+
mandate=self._mandate(),
1669+
candidate=self._candidate(),
15711670
)
15721671
second, _engine = self._assess(
15731672
StrategyDecision(),
15741673
snapshot={**self._SNAPSHOT, "as_of": offset.replace(microsecond=999_999)},
1674+
mandate=self._mandate(),
1675+
candidate=self._candidate(),
15751676
)
15761677
rejected, _engine = self._assess(
15771678
StrategyDecision(),
15781679
snapshot={**self._SNAPSHOT, "as_of": "2026-08-04T12:27:55+08:00"},
1680+
mandate=self._mandate(),
1681+
candidate=self._candidate(),
15791682
)
15801683

15811684
self.assertEqual(first.assessment.outcome, "APPROVE")

0 commit comments

Comments
 (0)