Skip to content

Commit 0334876

Browse files
Pigbibiclaudecursoragent
authored
feat(risk): wire entrypoints to QPK stop-loss diagnostics (task 8) (#77)
Delegate apply_risk_gate to QuantPlatformKit and enrich decisions from ctx.portfolio unrealized_pnl_pct / consecutive_losses before gating. Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 761a47e commit 0334876

3 files changed

Lines changed: 53 additions & 81 deletions

File tree

src/hk_equity_strategies/entrypoints/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def evaluate_hk_global_etf_tactical_rotation(ctx: StrategyContext) -> StrategyDe
4343
risk_flags=risk_flags,
4444
diagnostics=diagnostics,
4545
)
46-
decision = apply_risk_gate(decision)
46+
decision = apply_risk_gate(decision, ctx=ctx)
4747
record_strategy_decision(
4848
ctx,
4949
decision,
@@ -83,7 +83,7 @@ def evaluate_hk_low_vol_dividend_quality_snapshot(ctx: StrategyContext) -> Strat
8383
risk_flags=risk_flags,
8484
diagnostics=diagnostics,
8585
)
86-
decision = apply_risk_gate(decision)
86+
decision = apply_risk_gate(decision, ctx=ctx)
8787
record_strategy_decision(
8888
ctx,
8989
decision,
@@ -106,7 +106,7 @@ def evaluate_hk_low_vol_dividend_quality_snapshot(ctx: StrategyContext) -> Strat
106106

107107
def evaluate_hk_equity_combo(ctx: StrategyContext) -> StrategyDecision:
108108
from hk_equity_strategies.combo_entrypoints import evaluate_hk_equity_combo as _eval
109-
decision = apply_risk_gate(_eval(ctx))
109+
decision = apply_risk_gate(_eval(ctx), ctx=ctx)
110110
record_strategy_decision(
111111
ctx,
112112
decision,

src/hk_equity_strategies/entrypoints/_common.py

Lines changed: 26 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
from __future__ import annotations
22

33
import logging
4+
from collections.abc import Mapping
45
from typing import Any
56

7+
from quant_platform_kit.risk.gate import apply_risk_gate as _qpk_apply_risk_gate
8+
from quant_platform_kit.risk.gate import enrich_decision_risk_diagnostics
9+
from quant_platform_kit.risk.portfolio_diagnostics import extract_portfolio_risk_diagnostics
610
from quant_platform_kit.strategy_contracts import PositionTarget, StrategyContext, StrategyDecision
711
from quant_platform_kit.strategy_lifecycle.performance_monitor import PerformanceMonitor
812

@@ -40,89 +44,33 @@ def record_strategy_decision(
4044
def apply_risk_gate(
4145
decision: StrategyDecision,
4246
*,
47+
ctx: StrategyContext | None = None,
4348
max_single_weight: float = 1.0,
4449
max_positions: int = 20,
4550
max_total_exposure: float = 1.0,
51+
portfolio_snapshot: Any | None = None,
52+
market_data: Mapping[str, Any] | None = None,
4653
) -> StrategyDecision:
47-
"""对所有 StrategyDecision 施加硬风控门。
48-
49-
检查项:
50-
1. 单仓位集中度(> max_single_weight → REJECT,默认 100% 即不限制)
51-
2. 持仓数量(> max_positions → REJECT)
52-
3. 总仓位超限(> max_total_exposure → REJECT)
53-
54-
各策略类型可根据自身特点调整门限:
55-
- ETF 轮动:max_single_weight=1.0(ETF 本身就是分散的篮子)
56-
- 个股精选:max_single_weight=0.10
57-
- 加密货币:max_single_weight=0.20, max_positions=10
58-
59-
如果 REJECT,返回空仓决策并标注拒绝原因。
60-
这个函数不可绕过 —— AGENTS.md 要求所有 entrypoint 必须调用。
61-
"""
62-
positions = decision.positions or ()
63-
risk_flags = list(decision.risk_flags or ())
64-
65-
# 空仓放行(risk_off 场景)
66-
if not positions:
67-
return decision
68-
69-
# 1. 集中度检查(默认不限制,由策略自行设定)
70-
if max_single_weight < 1.0:
71-
for p in positions:
72-
weight = abs(float(p.target_weight))
73-
if weight > max_single_weight:
74-
logger.warning(
75-
"risk_gate REJECT concentration: symbol=%s weight=%.2f%% limit=%.0f%%",
76-
p.symbol, weight * 100, max_single_weight * 100,
77-
)
78-
return StrategyDecision(
79-
positions=(),
80-
risk_flags=("rejected:concentration",),
81-
diagnostics={
82-
**(decision.diagnostics or {}),
83-
"risk_gate": "REJECT",
84-
"reason": f"{p.symbol} {weight:.1%} > {max_single_weight:.0%} 上限",
85-
},
86-
)
87-
88-
# 2. 持仓数量检查
89-
if len(positions) > max_positions:
90-
logger.warning(
91-
"risk_gate REJECT position_count: %d > %d", len(positions), max_positions,
92-
)
93-
return StrategyDecision(
94-
positions=(),
95-
risk_flags=("rejected:too_many_positions",),
96-
diagnostics={
97-
**(decision.diagnostics or {}),
98-
"risk_gate": "REJECT",
99-
"reason": f"{len(positions)} 个持仓 > {max_positions} 上限",
100-
},
101-
)
102-
103-
# 3. 总仓位检查
104-
total_weight = sum(abs(float(p.target_weight)) for p in positions)
105-
if total_weight > max_total_exposure + 1e-9:
106-
logger.warning(
107-
"risk_gate REJECT total_exposure: %.2f%% > %.0f%%",
108-
total_weight * 100, max_total_exposure * 100,
109-
)
110-
return StrategyDecision(
111-
positions=(),
112-
risk_flags=("rejected:overexposed",),
113-
diagnostics={
114-
**(decision.diagnostics or {}),
115-
"risk_gate": "REJECT",
116-
"reason": f"总仓位 {total_weight:.1%} > {max_total_exposure:.0%}",
117-
},
54+
"""QPK unified risk gate: stop-loss, circuit breaker, concentration (task 8)."""
55+
snapshot = portfolio_snapshot if portfolio_snapshot is not None else (
56+
ctx.portfolio if ctx is not None else None
57+
)
58+
if snapshot is not None:
59+
portfolio_diag = extract_portfolio_risk_diagnostics(snapshot)
60+
decision = enrich_decision_risk_diagnostics(
61+
decision,
62+
unrealized_pnl_pct=portfolio_diag.get("unrealized_pnl_pct"),
63+
consecutive_losses=portfolio_diag.get("consecutive_losses"),
11864
)
119-
120-
# 通过
121-
risk_flags.append("risk_gate:passed")
122-
return StrategyDecision(
123-
positions=decision.positions,
124-
risk_flags=tuple(risk_flags),
125-
diagnostics={**(decision.diagnostics or {}), "risk_gate": "APPROVE"},
65+
if market_data is None and ctx is not None:
66+
market_data = dict(ctx.market_data or {})
67+
return _qpk_apply_risk_gate(
68+
decision,
69+
max_single_weight=max_single_weight,
70+
max_positions=max_positions,
71+
max_total_exposure=max_total_exposure,
72+
portfolio_snapshot=snapshot,
73+
market_data=market_data,
12674
)
12775

12876

tests/test_entrypoint_risk_gate.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timezone
4+
5+
from quant_platform_kit.common.models import PortfolioSnapshot, Position
6+
from quant_platform_kit.strategy_contracts import PositionTarget, StrategyContext, StrategyDecision
7+
8+
from hk_equity_strategies.entrypoints._common import apply_risk_gate
9+
10+
11+
def test_apply_risk_gate_enriches_stop_loss_diagnostics_from_portfolio() -> None:
12+
snapshot = PortfolioSnapshot(
13+
as_of=datetime(2026, 7, 9, tzinfo=timezone.utc),
14+
total_equity=1000.0,
15+
positions=(
16+
Position(symbol="2800", quantity=100.0, market_value=700.0, average_cost=10.0),
17+
),
18+
metadata={"consecutive_losses": 2},
19+
)
20+
ctx = StrategyContext(as_of=snapshot.as_of, portfolio=snapshot, market_data={}, state={}, runtime_config={})
21+
decision = StrategyDecision(positions=(PositionTarget(symbol="2800", target_weight=0.5),))
22+
result = apply_risk_gate(decision, ctx=ctx)
23+
assert result.positions == ()
24+
assert "rejected:stop_loss" in result.risk_flags

0 commit comments

Comments
 (0)