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
+2 -2
View File
@@ -18,8 +18,8 @@
4afcf9058231111c412925f4c4b84e81d656db87 tac-qlib/tac_qlib/contrib/strategy/__init__.py 4afcf9058231111c412925f4c4b84e81d656db87 tac-qlib/tac_qlib/contrib/strategy/__init__.py
74e5ecbbbb20bb71fd5cd083383de4ce88476712 tac-qlib/tac_qlib/contrib/strategy/__pycache__/__init__.cpython-312.pyc 74e5ecbbbb20bb71fd5cd083383de4ce88476712 tac-qlib/tac_qlib/contrib/strategy/__pycache__/__init__.cpython-312.pyc
afaf562aeaa12cebc8529cd916153252e7e3c38a tac-qlib/tac_qlib/contrib/strategy/__pycache__/optimal_stop.cpython-312.pyc afaf562aeaa12cebc8529cd916153252e7e3c38a tac-qlib/tac_qlib/contrib/strategy/__pycache__/optimal_stop.cpython-312.pyc
f3a084be7aea509ec10381af59a0c996bf66c5b6 tac-qlib/tac_qlib/contrib/strategy/hmm_risk.py 96a0a25201f0a1bb2fc2190e26228c5c0e711a79 tac-qlib/tac_qlib/contrib/strategy/hmm_risk.py
5e1ac61ebba6bc5bfb90c6d74ca29c28ff07aa13 tac-qlib/tac_qlib/contrib/strategy/momentum_gate.py 816de5d58ae23d996635d42331cf9fc8963d5dbe tac-qlib/tac_qlib/contrib/strategy/momentum_gate.py
79aaad9e39fcc740a773f4f63c512ce1086cfde0 tac-qlib/tac_qlib/contrib/strategy/optimal_stop.py 79aaad9e39fcc740a773f4f63c512ce1086cfde0 tac-qlib/tac_qlib/contrib/strategy/optimal_stop.py
92e6e90eb0cd0a25142034560f27adb6b705b1a8 tac-qlib/tac_qlib/data/__init__.py 92e6e90eb0cd0a25142034560f27adb6b705b1a8 tac-qlib/tac_qlib/data/__init__.py
0ed1ead6c1314a3f25784d453e54a15a8a04baaa tac-qlib/tac_qlib/data/__pycache__/__init__.cpython-312.pyc 0ed1ead6c1314a3f25784d453e54a15a8a04baaa tac-qlib/tac_qlib/data/__pycache__/__init__.cpython-312.pyc
@@ -1,24 +1,22 @@
"""TopkDropout with HMM high-volatility + drawdown-pause risk gates. """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 1. **HMM high-vol pause**: when the cross-sectional mean of ``sp_hmm_p_regime1``
probability (``sp_hmm_p_regime1`` feature, regime-1 = high-vol) is above (HMM high-vol regime probability) on the signal date is >= ``hmm_pause_pct``,
``hmm_pause_pct``, new buys are paused (existing positions held). This new buys are paused. The time-series study showed HMM high-vol probability
encodes the time-series study's finding that HMM high-vol probability pulses pulses BEFORE sharp moves (regime-change cut) — pausing new exposure at the
BEFORE sharp moves (regime-change cut) — pausing new exposure at the boundary reduces drawdown from price over-reaction.
boundary reduces drawdown from "价格过度反应".
2. **Drawdown pause**: when the account equity drawdown from its running peak 2. **Drawdown pause**: when the account equity drawdown from its running peak
exceeds ``drawdown_pause_pct``, new buys are paused (positions kept). This exceeds ``drawdown_pause_pct``, new buys are paused. This is the
is the ``drawdown_pause_pct`` risk-limit expressed in the backtest (the ``drawdown_pause_pct`` risk-limit expressed inside the backtest (the pure
executor-side gate is documented as not expressible in a one-shot qlib executor-side gate is documented as not expressible in a one-shot backtest).
backtest — here we implement it inside the strategy). 3. **Liquidity floor**: names whose 20-day average daily dollar volume is below
3. **Liquidity floor**: names whose average daily dollar volume is below ``liquidity_floor_adv`` are dropped from BUY candidates (the proven mitigant
``liquidity_floor_adv`` are dropped from the tradable set (the proven risk from exp-18: $5M floor cut drawdown 7.9%->5.4% at higher IR).
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. Implementation: pre-filter the signal score before the base TopkDropout
Exits remain the pure TopkDropout rule. decision — non-held names get score 0 when any gate fires.
Wired into a workflow yaml like: Wired into a workflow yaml like:
@@ -67,23 +65,15 @@ class HmmRiskTopk(TopkDropoutStrategy):
self.drawdown_pause_pct = float(drawdown_pause_pct) self.drawdown_pause_pct = float(drawdown_pause_pct)
self.liquidity_floor_adv = float(liquidity_floor_adv) self.liquidity_floor_adv = float(liquidity_floor_adv)
self._peak_equity = 0.0 self._peak_equity = 0.0
self._hmm_pause_active = False
self._drawdown_pause_active = False
# ------------------------------------------------------------- state # ------------------------------------------------------------- gates
def _high_vol_active(self, trade_start) -> bool: def _hmm_high_vol(self, pred_date) -> bool:
"""True when HMM high-vol regime probability >= pause threshold.""" """Cross-sectional mean HMM high-vol regime probability >= threshold."""
try: try:
from qlib.data import D from qlib.data import D
cal = D.calendar(start_time=str((trade_start - pd.Timedelta(days=10)).date()), feat = D.features(D.instruments("all"), ["$sp_hmm_p_regime1"],
end_time=str(trade_start.date())) start_time=pred_date, end_time=pred_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
)
if feat is None or len(feat) == 0: if feat is None or len(feat) == 0:
return False return False
p = feat["$sp_hmm_p_regime1"].dropna() p = feat["$sp_hmm_p_regime1"].dropna()
@@ -93,11 +83,22 @@ class HmmRiskTopk(TopkDropoutStrategy):
except Exception: except Exception:
return False 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) 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 if self._peak_equity <= 0:
self._drawdown_pause_active = self.drawdown_pause_pct > 0 and dd >= self.drawdown_pause_pct return False
self._hmm_pause_active = self._high_vol_active(trade_start) 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 # ------------------------------------------------------------- decision
def generate_trade_decision(self, execute_result=None): 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) pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
if pred_score is None: if pred_score is None:
return TradeDecisionWO([], self) return TradeDecisionWO([], self)
if isinstance(pred_score, pd.DataFrame):
pred_score = pred_score.iloc[:, 0]
current_temp = copy.deepcopy(self.trade_position) current_temp = copy.deepcopy(self.trade_position)
assert isinstance(current_temp, 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() equity = current_temp.get_cash()
for code in current_temp.get_stock_list(): for code in held:
amt = abs(current_temp.get_stock_amount(code))
mark = self.trade_exchange.get_deal_price( mark = self.trade_exchange.get_deal_price(
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=1 stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=1
) )
if mark is not None and np.isfinite(mark): if mark is not None and np.isfinite(mark):
equity += amt * mark equity += abs(current_temp.get_stock_amount(code)) * mark
self._update_pause(equity, trade_start_time)
target_weight_position = self.generate_target_weight_position( hmm_pause = self._hmm_high_vol(str(pd.Timestamp(pred_start_time).date()))
score=pred_score, current=current_temp, trade_start_time=trade_start_time, trade_end_time=trade_end_time dd_pause = self._drawdown_active(equity)
) buys_paused = hmm_pause or dd_pause
held = set(current_temp.get_stock_list()) pred_score = pred_score.copy()
held_amt = {c: abs(current_temp.get_stock_amount(c)) for c in held} 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 return super().generate_trade_decision(execute_result)
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)
@@ -1,21 +1,16 @@
"""TopkDropout with a 1-day momentum entry-confirmation gate. """TopkDropout with a 1-day momentum entry-confirmation gate.
Wraps qlib's ``TopkDropoutStrategy`` and adds one rule on top of the pure Gates NEW entries on short-term momentum: a name that is not currently held
signal ranking: a name may only be **bought** when its trailing 1-day return may only be bought when its trailing 1-day return is above ``min_momentum``
is positive (short-term momentum confirmation, Lag-1 autocorr ~ +0.45 in the (Lag-1 autocorr ~ +0.45 in the time-series study => short-term momentum
time-series study). Held names are never force-sold by this gate — exits stay continuation). Held names are never force-sold by this gate — exits stay the
the TopkDropout rule (fall out of top-k / n_drop). This attacks the churn/cost pure TopkDropout rule.
drag: the reference TopkDropout bought and sold ~590 times in 150 days ($63.5k
cost); momentum confirmation filters the entry side so a name that just fell
is not immediately re-bought on rank alone.
Implementation: overrides ``generate_trade_decision`` and wraps the target Implementation: override ``generate_trade_decision`` and zero out the signal
weight dict produced by the base strategy — any BUY weight for a name whose score of any non-held name that fails the momentum check BEFORE calling the
1-day return <= 0 (or missing quote) is zeroed (kept at 0 weight => no entry). base TopkDropout decision, so it can never be selected as a buy candidate.
Exits (weights already held) are preserved. This is a clean pre-filter: the rest of the strategy (top-k, n_drop, sizing,
costs) is untouched.
The 1-day return is read from the exchange's deal price over the previous
bar (no lookahead: decision on day t uses the close of t-1).
Wired into a workflow yaml like: Wired into a workflow yaml like:
@@ -37,8 +32,7 @@ import copy
import pandas as pd import pandas as pd
from qlib.backtest import Order from qlib.backtest.decision import TradeDecisionWO
from qlib.backtest.decision import OrderDir, TradeDecisionWO
from qlib.backtest.position import Position from qlib.backtest.position import Position
from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy
@@ -56,17 +50,16 @@ class MomentumGateTopk(TopkDropoutStrategy):
"""True when the trailing 1-day return is above the momentum floor.""" """True when the trailing 1-day return is above the momentum floor."""
try: try:
cur = self.trade_exchange.get_deal_price( cur = self.trade_exchange.get_deal_price(
stock_id=code, start_time=trade_start, end_time=trade_end, direction=OrderDir.BUY stock_id=code, start_time=trade_start, end_time=trade_end, direction=1
) )
except Exception: except Exception:
return False return False
if cur is None or cur != cur or cur <= 0: if cur is None or cur != cur or cur <= 0:
return False return False
# previous bar: shift the window back one step
prev_start = trade_start - pd.Timedelta(days=5) prev_start = trade_start - pd.Timedelta(days=5)
prev_end = trade_start - pd.Timedelta(seconds=1) prev_end = trade_start - pd.Timedelta(seconds=1)
prev = self.trade_exchange.get_deal_price( prev = self.trade_exchange.get_deal_price(
stock_id=code, start_time=prev_start, end_time=prev_end, direction=OrderDir.SELL stock_id=code, start_time=prev_start, end_time=prev_end, direction=0
) )
if prev is None or prev != prev or prev <= 0: if prev is None or prev != prev or prev <= 0:
return False return False
@@ -79,30 +72,20 @@ class MomentumGateTopk(TopkDropoutStrategy):
pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time) pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
if pred_score is None: if pred_score is None:
return TradeDecisionWO([], self) return TradeDecisionWO([], self)
if isinstance(pred_score, pd.DataFrame):
pred_score = pred_score.iloc[:, 0]
current_temp = copy.deepcopy(self.trade_position) current_temp = copy.deepcopy(self.trade_position)
assert isinstance(current_temp, Position) assert isinstance(current_temp, Position)
# base topk target weights (equal-weight top-k minus n_drop)
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
)
# entry gate: zero out any NEW (not currently held) buy weight when momentum fails
held = set(current_temp.get_stock_list()) held = set(current_temp.get_stock_list())
gated = {} held = {c for c in held if abs(current_temp.get_stock_amount(c)) > 1e-6}
for code, w in target_weight_position.items():
is_new = code not in held or abs(current_temp.get_stock_amount(code)) <= 1e-6
if is_new and not self._momentum_ok(code, trade_start_time, trade_end_time):
continue # skip entry (momentum not confirmed)
gated[code] = w
order_list = self.order_generator.generate_order_list_from_target_weight_position( # pre-filter: zero the score of non-held names that fail momentum
current=current_temp, pred_score = pred_score.copy()
trade_exchange=self.trade_exchange, for code in pred_score.index:
risk_degree=self.get_risk_degree(trade_step), if code in held:
target_weight_position=gated, continue # never gate exits / re-balancing of held names
pred_start_time=pred_start_time, if not self._momentum_ok(code, trade_start_time, trade_end_time):
pred_end_time=pred_end_time, pred_score[code] = -1e9 # cannot enter today
trade_start_time=trade_start_time,
trade_end_time=trade_end_time, return super().generate_trade_decision(execute_result)
)
return TradeDecisionWO(order_list, self)