exp 20: sync fixed MomentumGateTopk + HmmRiskTopk + rolling-IC rank_ensemble into code snapshot

This commit is contained in:
zhaoli
2026-08-17 04:08:07 +00:00
parent 07e78c2bd9
commit 80c7230e17
3 changed files with 79 additions and 122 deletions
@@ -1,24 +1,22 @@
"""TopkDropout with HMM high-volatility + drawdown-pause risk gates.
Adds three risk controls on top of ``TopkDropoutStrategy``:
Gates NEW entries on two risk conditions (held names are never force-sold):
1. **HMM high-vol pause**: when the current day's HMM high-volatility regime
probability (``sp_hmm_p_regime1`` feature, regime-1 = high-vol) is above
``hmm_pause_pct``, new buys are paused (existing positions held). This
encodes the time-series study's finding that HMM high-vol probability pulses
BEFORE sharp moves (regime-change cut) — pausing new exposure at the
boundary reduces drawdown from "价格过度反应".
1. **HMM high-vol pause**: when the cross-sectional mean of ``sp_hmm_p_regime1``
(HMM high-vol regime probability) on the signal date is >= ``hmm_pause_pct``,
new buys are paused. The time-series study showed HMM high-vol probability
pulses BEFORE sharp moves (regime-change cut) — pausing new exposure at the
boundary reduces drawdown from price over-reaction.
2. **Drawdown pause**: when the account equity drawdown from its running peak
exceeds ``drawdown_pause_pct``, new buys are paused (positions kept). This
is the ``drawdown_pause_pct`` risk-limit expressed in the backtest (the
executor-side gate is documented as not expressible in a one-shot qlib
backtest — here we implement it inside the strategy).
3. **Liquidity floor**: names whose average daily dollar volume is below
``liquidity_floor_adv`` are dropped from the tradable set (the proven risk
mitigant from exp-18: $5M floor cut drawdown 7.9%->5.4% at higher IR).
exceeds ``drawdown_pause_pct``, new buys are paused. This is the
``drawdown_pause_pct`` risk-limit expressed inside the backtest (the pure
executor-side gate is documented as not expressible in a one-shot backtest).
3. **Liquidity floor**: names whose 20-day average daily dollar volume is below
``liquidity_floor_adv`` are dropped from BUY candidates (the proven mitigant
from exp-18: $5M floor cut drawdown 7.9%->5.4% at higher IR).
Held names are never force-sold by these gates; only new entries are gated.
Exits remain the pure TopkDropout rule.
Implementation: pre-filter the signal score before the base TopkDropout
decision — non-held names get score 0 when any gate fires.
Wired into a workflow yaml like:
@@ -67,23 +65,15 @@ class HmmRiskTopk(TopkDropoutStrategy):
self.drawdown_pause_pct = float(drawdown_pause_pct)
self.liquidity_floor_adv = float(liquidity_floor_adv)
self._peak_equity = 0.0
self._hmm_pause_active = False
self._drawdown_pause_active = False
# ------------------------------------------------------------- state
def _high_vol_active(self, trade_start) -> bool:
"""True when HMM high-vol regime probability >= pause threshold."""
# ------------------------------------------------------------- gates
def _hmm_high_vol(self, pred_date) -> bool:
"""Cross-sectional mean HMM high-vol regime probability >= threshold."""
try:
from qlib.data import D
cal = D.calendar(start_time=str((trade_start - pd.Timedelta(days=10)).date()),
end_time=str(trade_start.date()))
if len(cal) == 0:
return False
ref_date = str(pd.Timestamp(cal[-1]).date())
feat = D.features(
D.instruments("all"), ["$sp_hmm_p_regime1"], start_time=ref_date, end_time=ref_date
)
feat = D.features(D.instruments("all"), ["$sp_hmm_p_regime1"],
start_time=pred_date, end_time=pred_date)
if feat is None or len(feat) == 0:
return False
p = feat["$sp_hmm_p_regime1"].dropna()
@@ -93,11 +83,22 @@ class HmmRiskTopk(TopkDropoutStrategy):
except Exception:
return False
def _update_pause(self, equity: float, trade_start) -> None:
def _drawdown_active(self, equity: float) -> bool:
if self.drawdown_pause_pct <= 0:
return False
self._peak_equity = max(self._peak_equity, equity)
dd = (self._peak_equity - equity) / self._peak_equity * 100.0 if self._peak_equity > 0 else 0.0
self._drawdown_pause_active = self.drawdown_pause_pct > 0 and dd >= self.drawdown_pause_pct
self._hmm_pause_active = self._high_vol_active(trade_start)
if self._peak_equity <= 0:
return False
dd = (self._peak_equity - equity) / self._peak_equity * 100.0
return dd >= self.drawdown_pause_pct
def _illiquid(self, codes, asof) -> Dict[str, bool]:
if self.liquidity_floor_adv <= 0 or not codes:
return {}
from tac_qlib.risk_limits import dollar_adv
adv = dollar_adv(codes, market="US", asof=asof, lookback=20)
return {c: adv.get(str(c).upper(), 0.0) < self.liquidity_floor_adv for c in codes}
# ------------------------------------------------------------- decision
def generate_trade_decision(self, execute_result=None):
@@ -107,58 +108,31 @@ class HmmRiskTopk(TopkDropoutStrategy):
pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
if pred_score is None:
return TradeDecisionWO([], self)
if isinstance(pred_score, pd.DataFrame):
pred_score = pred_score.iloc[:, 0]
current_temp = copy.deepcopy(self.trade_position)
assert isinstance(current_temp, Position)
held = {c for c in current_temp.get_stock_list() if abs(current_temp.get_stock_amount(c)) > 1e-6}
# account equity for drawdown pause
equity = current_temp.get_cash()
for code in current_temp.get_stock_list():
amt = abs(current_temp.get_stock_amount(code))
for code in held:
mark = self.trade_exchange.get_deal_price(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=1
)
if mark is not None and np.isfinite(mark):
equity += amt * mark
self._update_pause(equity, trade_start_time)
equity += abs(current_temp.get_stock_amount(code)) * mark
target_weight_position = self.generate_target_weight_position(
score=pred_score, current=current_temp, trade_start_time=trade_start_time, trade_end_time=trade_end_time
)
hmm_pause = self._hmm_high_vol(str(pd.Timestamp(pred_start_time).date()))
dd_pause = self._drawdown_active(equity)
buys_paused = hmm_pause or dd_pause
held = set(current_temp.get_stock_list())
held_amt = {c: abs(current_temp.get_stock_amount(c)) for c in held}
pred_score = pred_score.copy()
if buys_paused or self.liquidity_floor_adv > 0:
new_codes = [c for c in pred_score.index if c not in held]
illiquid = self._illiquid(new_codes, str(pd.Timestamp(pred_start_time).date()))
for code in new_codes:
if buys_paused or illiquid.get(code, False):
pred_score[code] = -1e9 # cannot enter today
# liquidity floor: drop names below the ADV floor from BUY candidates
illiquid: Dict[str, bool] = {}
if self.liquidity_floor_adv > 0:
from tac_qlib.risk_limits import dollar_adv
codes = [c for c in target_weight_position if c not in held]
if codes:
adv = dollar_adv(codes, market="US", asof=str(pd.Timestamp(trade_start_time).date()), lookback=20)
for c in codes:
illiquid[c] = adv.get(str(c).upper(), 0.0) < self.liquidity_floor_adv
buys_paused = self._hmm_pause_active or self._drawdown_pause_active
gated = {}
for code, w in target_weight_position.items():
is_new = code not in held or held_amt.get(code, 0.0) <= 1e-6
if is_new:
if buys_paused:
continue # risk gate: no new entries
if illiquid.get(code, False):
continue # liquidity floor: drop illiquid buy candidate
gated[code] = w
order_list = self.order_generator.generate_order_list_from_target_weight_position(
current=current_temp,
trade_exchange=self.trade_exchange,
risk_degree=self.get_risk_degree(trade_step),
target_weight_position=gated,
pred_start_time=pred_start_time,
pred_end_time=pred_end_time,
trade_start_time=trade_start_time,
trade_end_time=trade_end_time,
)
return TradeDecisionWO(order_list, self)
return super().generate_trade_decision(execute_result)