Skip to content

Commit 119fdc4

Browse files
Pigbibicodex
andcommitted
fix: enforce value targets in static risk gate
Co-Authored-By: Codex <noreply@openai.com>
1 parent 206f418 commit 119fdc4

2 files changed

Lines changed: 71 additions & 3 deletions

File tree

src/quant_platform_kit/risk/gate.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,29 @@ def _snapshot_metrics(
380380
}, observed, total_equity, set()
381381

382382

383+
def _static_gate_total_equity(portfolio_snapshot: Any) -> float | None:
384+
"""Return the equity needed to normalize value targets in the live gate.
385+
386+
The non-evidence ``apply_risk_gate`` path accepts both weight and value
387+
targets. A value target has no meaningful concentration without the same
388+
account equity used by the strategy invocation, so an absent or malformed
389+
snapshot must never turn it into a zero-weight target.
390+
"""
391+
try:
392+
if isinstance(portfolio_snapshot, Mapping):
393+
raw_total_equity = portfolio_snapshot.get("total_equity")
394+
elif isinstance(portfolio_snapshot, PortfolioSnapshot):
395+
raw_total_equity = portfolio_snapshot.total_equity
396+
else:
397+
return None
398+
except Exception:
399+
return None
400+
total_equity = _finite_number(raw_total_equity)
401+
if total_equity is None or total_equity <= 0.0:
402+
return None
403+
return total_equity
404+
405+
383406
def _exact_numeric_mapping(value: Any, expected: Mapping[str, float]) -> bool:
384407
normalized = _canonical_numeric_mapping(value, maximum=1.0)
385408
if normalized is None or set(normalized) != set(expected):
@@ -1291,6 +1314,7 @@ def _apply_risk_gate_static(
12911314
max_single_weight: Any,
12921315
max_positions: Any,
12931316
max_total_exposure: Any,
1317+
portfolio_snapshot: Any,
12941318
engine_action: Any,
12951319
engine_failed: bool,
12961320
) -> StrategyDecision:
@@ -1464,13 +1488,12 @@ def _apply_risk_gate_static(
14641488
)
14651489

14661490
positions = raw_positions if type(raw_positions) is tuple else ()
1491+
value_target_total_equity = _static_gate_total_equity(portfolio_snapshot)
14671492
weights: list[tuple[PositionTarget, float]] = []
14681493
if static_rejection is None:
14691494
for position in positions:
14701495
raw_weight = position.target_weight
1471-
if raw_weight is None:
1472-
weight = 0.0
1473-
else:
1496+
if raw_weight is not None:
14741497
normalized_weight = _finite_number(raw_weight)
14751498
if normalized_weight is None:
14761499
if type(raw_weight) is str:
@@ -1485,6 +1508,22 @@ def _apply_risk_gate_static(
14851508
)
14861509
break
14871510
weight = abs(normalized_weight)
1511+
else:
1512+
target_value = _finite_number(position.target_value)
1513+
if target_value is None or value_target_total_equity is None:
1514+
static_rejection = (
1515+
"rejected:invalid_decision_exposure",
1516+
"金额目标缺少有效账户净值",
1517+
)
1518+
break
1519+
normalized_weight = target_value / value_target_total_equity
1520+
if not math.isfinite(normalized_weight) or normalized_weight < 0.0:
1521+
static_rejection = (
1522+
"rejected:invalid_decision_exposure",
1523+
f"{position.symbol} 金额目标无效",
1524+
)
1525+
break
1526+
weight = normalized_weight
14881527
if weight > 0.0:
14891528
weights.append((position, weight))
14901529

@@ -1660,6 +1699,7 @@ def apply_risk_gate(
16601699
max_single_weight=max_single_weight,
16611700
max_positions=max_positions,
16621701
max_total_exposure=max_total_exposure,
1702+
portfolio_snapshot=portfolio_snapshot,
16631703
engine_action=engine_action,
16641704
engine_failed=engine_failed,
16651705
)

tests/test_risk_gate.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,34 @@ def test_no_mandate_allows_exactly_ten_percent(self) -> None:
130130
self.assertEqual(len(result.positions), 1)
131131
self.assertIn("risk_gate:passed", result.risk_flags)
132132

133+
def test_no_mandate_normalizes_value_targets_before_concentration_check(self) -> None:
134+
approved = apply_risk_gate(
135+
_decision(positions=(PositionTarget(symbol="SPY", target_value=10_000.0),)),
136+
product_leverage_factors={"SPY": 1},
137+
portfolio_snapshot=_portfolio_snapshot(),
138+
)
139+
rejected = apply_risk_gate(
140+
_decision(positions=(PositionTarget(symbol="SPY", target_value=10_001.0),)),
141+
product_leverage_factors={"SPY": 1},
142+
portfolio_snapshot=_portfolio_snapshot(),
143+
)
144+
145+
self.assertIn("risk_gate:passed", approved.risk_flags)
146+
self.assertEqual(rejected.positions, ())
147+
self.assertEqual(rejected.risk_flags, ("rejected:concentration",))
148+
149+
def test_no_mandate_rejects_value_target_without_valid_equity(self) -> None:
150+
for snapshot in (None, {}, {"total_equity": 0.0}, {"total_equity": float("inf")}):
151+
with self.subTest(snapshot=snapshot):
152+
result = apply_risk_gate(
153+
_decision(positions=(PositionTarget(symbol="SPY", target_value=10_000.0),)),
154+
product_leverage_factors={"SPY": 1},
155+
portfolio_snapshot=snapshot,
156+
)
157+
158+
self.assertEqual(result.positions, ())
159+
self.assertEqual(result.risk_flags, ("rejected:invalid_decision_exposure",))
160+
133161
def test_no_mandate_rejects_missing_or_leveraged_classification(self) -> None:
134162
decision = _decision(
135163
positions=(PositionTarget(symbol="SPY", target_weight=0.10),),

0 commit comments

Comments
 (0)