start experiment 20 (exp/20-improve-the-risk-limit-reference-signal)

This commit is contained in:
zhaoli
2026-08-17 02:55:26 +00:00
parent 7fad62a4ef
commit 337f6e17d8
4 changed files with 317 additions and 5 deletions
+4 -2
View File
@@ -1,5 +1,5 @@
# TradeAC custom-qlib-code snapshot (auto-generated) # TradeAC custom-qlib-code snapshot (auto-generated)
# parent repo HEAD : f9ef005e9aa05e546c11eef760046648cf5a6334 # parent repo HEAD : 7fad62a4eff8dad5713bb470ea2b7f3faa1bd520
# tac-qlib/tac_qlib/contrib # tac-qlib/tac_qlib/contrib
# tac-qlib/tac_qlib/data # tac-qlib/tac_qlib/data
# per-file hashes (git hash-object): # per-file hashes (git hash-object):
@@ -13,11 +13,13 @@
ab958203f33a99d12c7d923b6efb435189231666 tac-qlib/tac_qlib/contrib/model/__pycache__/__init__.cpython-312.pyc ab958203f33a99d12c7d923b6efb435189231666 tac-qlib/tac_qlib/contrib/model/__pycache__/__init__.cpython-312.pyc
9dc36de7e343073b7d511349ee5aede086c38f94 tac-qlib/tac_qlib/contrib/model/__pycache__/rank_ensemble.cpython-312.pyc 9dc36de7e343073b7d511349ee5aede086c38f94 tac-qlib/tac_qlib/contrib/model/__pycache__/rank_ensemble.cpython-312.pyc
9f9014ddd9bce37490061312d51e8e6fe540fec4 tac-qlib/tac_qlib/contrib/model/__pycache__/rank_gbdt.cpython-312.pyc 9f9014ddd9bce37490061312d51e8e6fe540fec4 tac-qlib/tac_qlib/contrib/model/__pycache__/rank_gbdt.cpython-312.pyc
d3f051f3a8650c42fedc7b367b966f7c74fb5789 tac-qlib/tac_qlib/contrib/model/rank_ensemble.py ce77dea53f6a87c5379782709293bf8ff55b2c75 tac-qlib/tac_qlib/contrib/model/rank_ensemble.py
ccfe7d554989aa7f3e5a2128ae663e51b2207149 tac-qlib/tac_qlib/contrib/model/rank_gbdt.py ccfe7d554989aa7f3e5a2128ae663e51b2207149 tac-qlib/tac_qlib/contrib/model/rank_gbdt.py
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
5e1ac61ebba6bc5bfb90c6d74ca29c28ff07aa13 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
@@ -56,6 +56,7 @@ import os
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import List, Optional from typing import List, Optional
import numpy as np
import pandas as pd import pandas as pd
from qlib.data.dataset import DatasetH from qlib.data.dataset import DatasetH
@@ -79,11 +80,15 @@ class RankICEnsembleLGBModel(RankICLGBModel):
forwarded. forwarded.
""" """
def __init__(self, seeds: str = "42", parallel: int = 0, **kwargs): def __init__(self, seeds: str = "42", parallel: int = 0, weight_mode: str = "equal", **kwargs):
self.seeds = [int(s.strip()) for s in str(seeds).split(",") if s.strip()] self.seeds = [int(s.strip()) for s in str(seeds).split(",") if s.strip()]
if not self.seeds: if not self.seeds:
raise ValueError("seeds must contain at least one integer") raise ValueError("seeds must contain at least one integer")
self.parallel = int(parallel) self.parallel = int(parallel)
if weight_mode not in ("equal", "rolling_ic"):
raise ValueError(f"weight_mode must be 'equal' or 'rolling_ic', got {weight_mode!r}")
self.weight_mode = weight_mode
self.rolling_ic_window = int(kwargs.pop("rolling_ic_window", 21))
# drop seed/parallel handling from the base kwargs, keep everything else # drop seed/parallel handling from the base kwargs, keep everything else
self._model_kwargs = dict(kwargs) self._model_kwargs = dict(kwargs)
super().__init__(**self._model_kwargs) super().__init__(**self._model_kwargs)
@@ -179,11 +184,44 @@ class RankICEnsembleLGBModel(RankICLGBModel):
# -------------------------------------------------------------- predict # -------------------------------------------------------------- predict
def predict(self, dataset: DatasetH, segment="test") -> pd.Series: def predict(self, dataset: DatasetH, segment="test") -> pd.Series:
"""Average the per-seed predictions over the given segment.""" """Combine per-seed predictions.
``weight_mode='equal'`` (default): simple average, as before.
``weight_mode='rolling_ic'``: weight each seed by its trailing
per-day RankIC over the last ``rolling_ic_window`` days of the segment,
normalised to sum to 1 — adaptive ensemble blending that up-weights the
seed that is currently working (cheap alpha gain; same trained models).
"""
if not self._models: if not self._models:
raise ValueError("model is not fitted yet!") raise ValueError("model is not fitted yet!")
preds = [m.predict(dataset, segment=segment) for m in self._models] preds = [m.predict(dataset, segment=segment) for m in self._models]
if len(preds) == 1: if len(preds) == 1:
return preds[0] return preds[0]
frame = pd.concat(preds, axis=1) frame = pd.concat(preds, axis=1)
return frame.mean(axis=1) frame.columns = [f"seed{m.params.get('seed', i)}" for i, m in enumerate(self._models)]
if self.weight_mode == "equal":
return frame.mean(axis=1)
# rolling-IC blend: weight by per-day Spearman IC of each seed vs the
# cross-sectional mean prediction (proxy for the true label) on the last
# `rolling_ic_window` days of this segment. No lookahead: only past days
# of the segment are used; the final (trading) day is excluded from the
# window so the weights are causal.
mean_pred = frame.mean(axis=1)
dates = sorted(frame.index.get_level_values(0).unique())
win = [d for d in dates if d < dates[-1]][-self.rolling_ic_window :]
ics = {}
for col in frame.columns:
if not win:
ics[col] = 1.0
continue
sub = pd.DataFrame({"p": frame[col], "m": mean_pred})
vals = []
for d in win:
s = sub[sub.index.get_level_values(0) == d]
if len(s) >= 3 and s["p"].nunique() > 1 and s["m"].nunique() > 1:
vals.append(s["p"].rank().corr(s["m"].rank()))
ics[col] = float(np.mean(vals)) if vals else 1.0
wsum = sum(ics.values()) or len(ics)
weights = {c: v / wsum for c, v in ics.items()}
return sum(frame[c] * weights[c] for c in frame.columns)
@@ -0,0 +1,164 @@
"""TopkDropout with HMM high-volatility + drawdown-pause risk gates.
Adds three risk controls on top of ``TopkDropoutStrategy``:
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 "价格过度反应".
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).
Held names are never force-sold by these gates; only new entries are gated.
Exits remain the pure TopkDropout rule.
Wired into a workflow yaml like:
strategy:
class: HmmRiskTopk
module_path: tac_qlib.contrib.strategy.hmm_risk
kwargs:
signal: "<PRED>"
topk: 10
n_drop: 2
only_tradable: true
risk_degree: 0.95
hmm_pause_pct: 0.70
drawdown_pause_pct: 8.0
liquidity_floor_adv: 5000000
"""
from __future__ import annotations
import copy
from typing import Dict
import numpy as np
import pandas as pd
from qlib.backtest.decision import TradeDecisionWO
from qlib.backtest.position import Position
from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy
__all__ = ["HmmRiskTopk"]
class HmmRiskTopk(TopkDropoutStrategy):
"""TopkDropoutStrategy with HMM high-vol pause + drawdown pause + liquidity floor."""
def __init__(
self,
*,
hmm_pause_pct: float = 0.70,
drawdown_pause_pct: float = 8.0,
liquidity_floor_adv: float = 0.0,
**kwargs,
):
super().__init__(**kwargs)
self.hmm_pause_pct = float(hmm_pause_pct)
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."""
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
)
if feat is None or len(feat) == 0:
return False
p = feat["$sp_hmm_p_regime1"].dropna()
if len(p) == 0:
return False
return float(p.mean()) >= self.hmm_pause_pct
except Exception:
return False
def _update_pause(self, equity: float, trade_start) -> None:
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)
# ------------------------------------------------------------- decision
def generate_trade_decision(self, execute_result=None):
trade_step = self.trade_calendar.get_trade_step()
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1)
pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
if pred_score is None:
return TradeDecisionWO([], self)
current_temp = copy.deepcopy(self.trade_position)
assert isinstance(current_temp, Position)
# 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))
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)
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
)
held = set(current_temp.get_stock_list())
held_amt = {c: abs(current_temp.get_stock_amount(c)) for c in held}
# 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)
@@ -0,0 +1,108 @@
"""TopkDropout with a 1-day momentum entry-confirmation gate.
Wraps qlib's ``TopkDropoutStrategy`` and adds one rule on top of the pure
signal ranking: a name may only be **bought** when its trailing 1-day return
is positive (short-term momentum confirmation, Lag-1 autocorr ~ +0.45 in the
time-series study). Held names are never force-sold by this gate — exits stay
the TopkDropout rule (fall out of top-k / n_drop). This attacks the churn/cost
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
weight dict produced by the base strategy — any BUY weight for a name whose
1-day return <= 0 (or missing quote) is zeroed (kept at 0 weight => no entry).
Exits (weights already held) are preserved.
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:
strategy:
class: MomentumGateTopk
module_path: tac_qlib.contrib.strategy.momentum_gate
kwargs:
signal: "<PRED>"
topk: 10
n_drop: 2
only_tradable: true
risk_degree: 0.95
min_momentum: 0.0
"""
from __future__ import annotations
import copy
import pandas as pd
from qlib.backtest import Order
from qlib.backtest.decision import OrderDir, TradeDecisionWO
from qlib.backtest.position import Position
from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy
__all__ = ["MomentumGateTopk"]
class MomentumGateTopk(TopkDropoutStrategy):
"""TopkDropoutStrategy gated on 1-day momentum for new entries."""
def __init__(self, *, min_momentum: float = 0.0, **kwargs):
super().__init__(**kwargs)
self.min_momentum = float(min_momentum)
def _momentum_ok(self, code, trade_start, trade_end) -> bool:
"""True when the trailing 1-day return is above the momentum floor."""
try:
cur = self.trade_exchange.get_deal_price(
stock_id=code, start_time=trade_start, end_time=trade_end, direction=OrderDir.BUY
)
except Exception:
return False
if cur is None or cur != cur or cur <= 0:
return False
# previous bar: shift the window back one step
prev_start = trade_start - pd.Timedelta(days=5)
prev_end = trade_start - pd.Timedelta(seconds=1)
prev = self.trade_exchange.get_deal_price(
stock_id=code, start_time=prev_start, end_time=prev_end, direction=OrderDir.SELL
)
if prev is None or prev != prev or prev <= 0:
return False
return (cur / prev - 1.0) >= self.min_momentum
def generate_trade_decision(self, execute_result=None):
trade_step = self.trade_calendar.get_trade_step()
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1)
pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
if pred_score is None:
return TradeDecisionWO([], self)
current_temp = copy.deepcopy(self.trade_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())
gated = {}
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(
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)