Skip to content

Commit 1b6323b

Browse files
Pigbibiclaudecursoragent
committed
fix(risk): preserve budgets through apply_risk_gate
Risk gate REJECT/APPROVE paths now carry budgets forward so entrypoint execution-contract tests keep working when concentration limits fire. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 757caa8 commit 1b6323b

1 file changed

Lines changed: 59 additions & 44 deletions

File tree

src/crypto_strategies/entrypoints/_common.py

Lines changed: 59 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import logging
44

5-
from quant_platform_kit.strategy_contracts import StrategyDecision
5+
from quant_platform_kit.strategy_contracts import PositionTarget, StrategyDecision
66

77
logger = logging.getLogger(__name__)
88

@@ -11,6 +11,30 @@
1111
# ---------------------------------------------------------------------------
1212

1313

14+
def _position_weight(position: PositionTarget) -> float | None:
15+
if position.target_weight is not None:
16+
return abs(float(position.target_weight))
17+
return None
18+
19+
20+
def _reject_risk_gate(
21+
decision: StrategyDecision,
22+
*,
23+
risk_flag: str,
24+
reason: str,
25+
) -> StrategyDecision:
26+
return StrategyDecision(
27+
positions=(),
28+
budgets=decision.budgets,
29+
risk_flags=(*decision.risk_flags, risk_flag),
30+
diagnostics={
31+
**(decision.diagnostics or {}),
32+
"risk_gate": "REJECT",
33+
"reason": reason,
34+
},
35+
)
36+
37+
1438
def apply_risk_gate(
1539
decision: StrategyDecision,
1640
*,
@@ -34,67 +58,58 @@ def apply_risk_gate(
3458
这个函数不可绕过 —— AGENTS.md 要求所有 entrypoint 必须调用。
3559
"""
3660
positions = decision.positions or ()
37-
risk_flags = list(decision.risk_flags or ())
3861

39-
# 空仓放行(risk_off 场景)
4062
if not positions:
41-
return decision
63+
return StrategyDecision(
64+
positions=decision.positions,
65+
budgets=decision.budgets,
66+
risk_flags=decision.risk_flags,
67+
diagnostics={**(decision.diagnostics or {}), "risk_gate": "APPROVE"},
68+
)
4269

43-
# 1. 集中度检查(默认不限制,由策略自行设定)
44-
if max_single_weight < 1.0:
45-
for p in positions:
46-
weight = abs(float(p.target_weight))
70+
weight_positions = [p for p in positions if _position_weight(p) is not None]
71+
72+
if max_single_weight < 1.0 and weight_positions:
73+
for p in weight_positions:
74+
weight = _position_weight(p)
75+
assert weight is not None
4776
if weight > max_single_weight:
4877
logger.warning(
4978
"risk_gate REJECT concentration: symbol=%s weight=%.2f%% limit=%.0f%%",
5079
p.symbol, weight * 100, max_single_weight * 100,
5180
)
52-
return StrategyDecision(
53-
positions=(),
54-
risk_flags=("rejected:concentration",),
55-
diagnostics={
56-
**(decision.diagnostics or {}),
57-
"risk_gate": "REJECT",
58-
"reason": f"{p.symbol} {weight:.1%} > {max_single_weight:.0%} 上限",
59-
},
81+
return _reject_risk_gate(
82+
decision,
83+
risk_flag="rejected:concentration",
84+
reason=f"{p.symbol} {weight:.1%} > {max_single_weight:.0%} 上限",
6085
)
6186

62-
# 2. 持仓数量检查
6387
if len(positions) > max_positions:
6488
logger.warning(
6589
"risk_gate REJECT position_count: %d > %d", len(positions), max_positions,
6690
)
67-
return StrategyDecision(
68-
positions=(),
69-
risk_flags=("rejected:too_many_positions",),
70-
diagnostics={
71-
**(decision.diagnostics or {}),
72-
"risk_gate": "REJECT",
73-
"reason": f"{len(positions)} 个持仓 > {max_positions} 上限",
74-
},
91+
return _reject_risk_gate(
92+
decision,
93+
risk_flag="rejected:too_many_positions",
94+
reason=f"{len(positions)} 个持仓 > {max_positions} 上限",
7595
)
7696

77-
# 3. 总仓位检查
78-
total_weight = sum(abs(float(p.target_weight)) for p in positions)
79-
if total_weight > max_total_exposure + 1e-9:
80-
logger.warning(
81-
"risk_gate REJECT total_exposure: %.2f%% > %.0f%%",
82-
total_weight * 100, max_total_exposure * 100,
83-
)
84-
return StrategyDecision(
85-
positions=(),
86-
risk_flags=("rejected:overexposed",),
87-
diagnostics={
88-
**(decision.diagnostics or {}),
89-
"risk_gate": "REJECT",
90-
"reason": f"总仓位 {total_weight:.1%} > {max_total_exposure:.0%}",
91-
},
92-
)
97+
if weight_positions:
98+
total_weight = sum(_position_weight(p) or 0.0 for p in weight_positions)
99+
if total_weight > max_total_exposure + 1e-9:
100+
logger.warning(
101+
"risk_gate REJECT total_exposure: %.2f%% > %.0f%%",
102+
total_weight * 100, max_total_exposure * 100,
103+
)
104+
return _reject_risk_gate(
105+
decision,
106+
risk_flag="rejected:overexposed",
107+
reason=f"总仓位 {total_weight:.1%} > {max_total_exposure:.0%}",
108+
)
93109

94-
# 通过
95-
risk_flags.append("risk_gate:passed")
96110
return StrategyDecision(
97111
positions=decision.positions,
98-
risk_flags=tuple(risk_flags),
112+
budgets=decision.budgets,
113+
risk_flags=decision.risk_flags,
99114
diagnostics={**(decision.diagnostics or {}), "risk_gate": "APPROVE"},
100115
)

0 commit comments

Comments
 (0)