diff --git a/book/workflows/sq_gate/sq_gate_2021.yaml b/book/workflows/sq_gate/sq_gate_2021.yaml index b7c8c40..03507d1 100644 --- a/book/workflows/sq_gate/sq_gate_2021.yaml +++ b/book/workflows/sq_gate/sq_gate_2021.yaml @@ -2,7 +2,7 @@ {%- set LAKE = TAC_LAKE_DIR %} {%- set UNIVERSE = "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM" %} {%- set SP_FIELDS = "sp_ret,sp_ou_zscore,sp_ou_half_life,sp_ou_revert,sp_hmm_p_regime1,sp_hmm_state,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead" %} -{%- set GATE_PATH = "/app/experiments/book/data/signal_quality_gate/sq_gate_2021.pkl" %} +{# Gate computed on-the-fly from signal + lake close prices #} qlib_init: provider_uri: "{{ LAKE }}" @@ -90,7 +90,12 @@ task: module_path: tac_qlib.contrib.strategy.signal_quality_gate kwargs: signal: "" - signal_quality_gate_path: "{{ GATE_PATH }}" + lake_root: "{{ LAKE }}" + gate_topk: 10 + gate_lookback: 5 + gate_threshold: 0.5 + gate_start: "2015-01-03" + gate_end: "2021-12-31" topk: 10 n_drop: 1 only_tradable: true diff --git a/book/workflows/sq_gate/sq_gate_2023.yaml b/book/workflows/sq_gate/sq_gate_2023.yaml index ea132b8..6104b31 100644 --- a/book/workflows/sq_gate/sq_gate_2023.yaml +++ b/book/workflows/sq_gate/sq_gate_2023.yaml @@ -2,7 +2,7 @@ {%- set LAKE = TAC_LAKE_DIR %} {%- set UNIVERSE = "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM" %} {%- set SP_FIELDS = "sp_ret,sp_ou_zscore,sp_ou_half_life,sp_ou_revert,sp_hmm_p_regime1,sp_hmm_state,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead" %} -{%- set GATE_PATH = "/app/experiments/book/data/signal_quality_gate/sq_gate_2023.pkl" %} +{# Gate computed on-the-fly from signal + lake close prices #} qlib_init: provider_uri: "{{ LAKE }}" @@ -90,7 +90,12 @@ task: module_path: tac_qlib.contrib.strategy.signal_quality_gate kwargs: signal: "" - signal_quality_gate_path: "{{ GATE_PATH }}" + lake_root: "{{ LAKE }}" + gate_topk: 10 + gate_lookback: 5 + gate_threshold: 0.5 + gate_start: "2015-01-03" + gate_end: "2023-12-29" topk: 10 n_drop: 1 only_tradable: true diff --git a/book/workflows/sq_gate/sq_gate_2024.yaml b/book/workflows/sq_gate/sq_gate_2024.yaml index fcb6f0c..85c82e3 100644 --- a/book/workflows/sq_gate/sq_gate_2024.yaml +++ b/book/workflows/sq_gate/sq_gate_2024.yaml @@ -2,7 +2,7 @@ {%- set LAKE = TAC_LAKE_DIR %} {%- set UNIVERSE = "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM" %} {%- set SP_FIELDS = "sp_ret,sp_ou_zscore,sp_ou_half_life,sp_ou_revert,sp_hmm_p_regime1,sp_hmm_state,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead" %} -{%- set GATE_PATH = "/app/experiments/book/data/signal_quality_gate/sq_gate_2024.pkl" %} +{# Gate computed on-the-fly from signal + lake close prices #} qlib_init: provider_uri: "{{ LAKE }}" @@ -90,7 +90,12 @@ task: module_path: tac_qlib.contrib.strategy.signal_quality_gate kwargs: signal: "" - signal_quality_gate_path: "{{ GATE_PATH }}" + lake_root: "{{ LAKE }}" + gate_topk: 10 + gate_lookback: 5 + gate_threshold: 0.5 + gate_start: "2015-01-03" + gate_end: "2024-12-31" topk: 10 n_drop: 1 only_tradable: true diff --git a/book/workflows/sq_gate/sq_gate_2025.yaml b/book/workflows/sq_gate/sq_gate_2025.yaml index 80eccec..fbcc0e8 100644 --- a/book/workflows/sq_gate/sq_gate_2025.yaml +++ b/book/workflows/sq_gate/sq_gate_2025.yaml @@ -2,7 +2,7 @@ {%- set LAKE = TAC_LAKE_DIR %} {%- set UNIVERSE = "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM" %} {%- set SP_FIELDS = "sp_ret,sp_ou_zscore,sp_ou_half_life,sp_ou_revert,sp_hmm_p_regime1,sp_hmm_state,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead" %} -{%- set GATE_PATH = "/app/experiments/book/data/signal_quality_gate/sq_gate_2025.pkl" %} +{# Gate computed on-the-fly from signal + lake close prices #} qlib_init: provider_uri: "{{ LAKE }}" @@ -90,7 +90,12 @@ task: module_path: tac_qlib.contrib.strategy.signal_quality_gate kwargs: signal: "" - signal_quality_gate_path: "{{ GATE_PATH }}" + lake_root: "{{ LAKE }}" + gate_topk: 10 + gate_lookback: 5 + gate_threshold: 0.5 + gate_start: "2015-01-03" + gate_end: "2025-12-31" topk: 10 n_drop: 1 only_tradable: true diff --git a/book/workflows/sq_gate/sq_gate_2026.yaml b/book/workflows/sq_gate/sq_gate_2026.yaml index 518eaa2..12e09bb 100644 --- a/book/workflows/sq_gate/sq_gate_2026.yaml +++ b/book/workflows/sq_gate/sq_gate_2026.yaml @@ -2,7 +2,7 @@ {%- set LAKE = TAC_LAKE_DIR %} {%- set UNIVERSE = "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM" %} {%- set SP_FIELDS = "sp_ret,sp_ou_zscore,sp_ou_half_life,sp_ou_revert,sp_hmm_p_regime1,sp_hmm_state,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead" %} -{%- set GATE_PATH = "/app/experiments/book/data/signal_quality_gate/sq_gate_2026.pkl" %} +{# Gate computed on-the-fly from signal + lake close prices #} qlib_init: provider_uri: "{{ LAKE }}" @@ -90,7 +90,12 @@ task: module_path: tac_qlib.contrib.strategy.signal_quality_gate kwargs: signal: "" - signal_quality_gate_path: "{{ GATE_PATH }}" + lake_root: "{{ LAKE }}" + gate_topk: 10 + gate_lookback: 5 + gate_threshold: 0.5 + gate_start: "2015-01-03" + gate_end: "2026-08-19" topk: 10 n_drop: 1 only_tradable: true diff --git a/code/MANIFEST.txt b/code/MANIFEST.txt index e60be5f..21b3699 100644 --- a/code/MANIFEST.txt +++ b/code/MANIFEST.txt @@ -1,5 +1,5 @@ # TradeAC custom-qlib-code snapshot (auto-generated) -# parent repo HEAD : fd5382caa4c4e96417a16d5a22006d9a7d625d00 +# parent repo HEAD : e952feed0a66a20439f4f24ad5524233429cd0c3 # tac-qlib/tac_qlib/contrib # tac-qlib/tac_qlib/data # per-file hashes (git hash-object): @@ -20,12 +20,12 @@ cd3133cfbd2556b25c106e39ae97fb128df0e326 tac-qlib/tac_qlib/contrib/strategy/__pycache__/ic_gate.cpython-312.pyc 6dd1c568a2961842793674390d5abffd1a0e71b8 tac-qlib/tac_qlib/contrib/strategy/__pycache__/optimal_stop.cpython-312.pyc 03b5e4d80da00800f1b108bee0735d3d18d856d1 tac-qlib/tac_qlib/contrib/strategy/__pycache__/regime_gate.cpython-312.pyc - ebcbd48b5de9bf6da84af4d6916c97d03bb6acfa tac-qlib/tac_qlib/contrib/strategy/__pycache__/signal_quality_gate.cpython-312.pyc + a6a1c21ab71b62080830df45c4784b73c1531036 tac-qlib/tac_qlib/contrib/strategy/__pycache__/signal_quality_gate.cpython-312.pyc 755e3b139496a5e22b0328db45c8199c33029fbc tac-qlib/tac_qlib/contrib/strategy/__pycache__/weekly_rebalance.cpython-312.pyc 519a1f4c05dbe0ac018ab8b779eb33d53b4dd545 tac-qlib/tac_qlib/contrib/strategy/ic_gate.py 79aaad9e39fcc740a773f4f63c512ce1086cfde0 tac-qlib/tac_qlib/contrib/strategy/optimal_stop.py 7bcee5f0b09cfa721440f1354f16f2dd9a112b12 tac-qlib/tac_qlib/contrib/strategy/regime_gate.py - 0101bc87b67c6b10debe0eb9c7e5fa910e919b41 tac-qlib/tac_qlib/contrib/strategy/signal_quality_gate.py + 16ab80b731aab6d8bb818525615d77c2d1fcb0c8 tac-qlib/tac_qlib/contrib/strategy/signal_quality_gate.py fe60bacdfedd48617863be31f24b7c7daebfac5a tac-qlib/tac_qlib/contrib/strategy/weekly_rebalance.py 92e6e90eb0cd0a25142034560f27adb6b705b1a8 tac-qlib/tac_qlib/data/__init__.py 316bf4aa160cc8d15929ea648be03f4b4999667d tac-qlib/tac_qlib/data/__pycache__/__init__.cpython-312.pyc diff --git a/code/tac-qlib/tac_qlib/contrib/strategy/__pycache__/signal_quality_gate.cpython-312.pyc b/code/tac-qlib/tac_qlib/contrib/strategy/__pycache__/signal_quality_gate.cpython-312.pyc index ebcbd48..a6a1c21 100644 Binary files a/code/tac-qlib/tac_qlib/contrib/strategy/__pycache__/signal_quality_gate.cpython-312.pyc and b/code/tac-qlib/tac_qlib/contrib/strategy/__pycache__/signal_quality_gate.cpython-312.pyc differ diff --git a/code/tac-qlib/tac_qlib/contrib/strategy/signal_quality_gate.py b/code/tac-qlib/tac_qlib/contrib/strategy/signal_quality_gate.py index 0101bc8..16ab80b 100644 --- a/code/tac-qlib/tac_qlib/contrib/strategy/signal_quality_gate.py +++ b/code/tac-qlib/tac_qlib/contrib/strategy/signal_quality_gate.py @@ -28,26 +28,118 @@ __all__ = ["SignalQualityGateStrategy", "compute_signal_quality_gate"] class SignalQualityGateStrategy(TopkDropoutStrategy): """TopkDropout with signal-quality gate overlay. + When ``lake_root`` is provided the gate is computed on-the-fly from the + signal (````) and close prices — no precomputed gate file needed. + This ensures the gate matches the model that is actually generating the + predictions (critical when the model is retrained each year). + Parameters ---------- topk, n_drop, method_sell, method_buy, hold_thresh, only_tradable, forbid_all_trade_at_limit : same as ``TopkDropoutStrategy``. - signal_quality_gate : pd.Series — precomputed per-date gate (bool indexed - by datetime). True = trade allowed, False = no orders. Missing dates - default to open (trade allowed). + signal_quality_gate : pd.Series — precomputed per-date gate (bool). + signal_quality_gate_path : str — path to pickled gate Series. + lake_root : str — lake root for on-the-fly gate computation (preferred). + gate_topk, gate_lookback, gate_threshold : int/float — gate params. + gate_start, gate_end : str — date window for loading close prices. """ - def __init__(self, *, signal_quality_gate=None, signal_quality_gate_path=None, **kwargs): + def __init__(self, *, signal_quality_gate=None, signal_quality_gate_path=None, + lake_root=None, gate_topk=10, gate_lookback=5, gate_threshold=0.5, + gate_start="2015-01-03", gate_end="2026-08-19", **kwargs): super().__init__(**kwargs) + self._sq_gate_computed = False if signal_quality_gate is not None: self._sq_gate = signal_quality_gate + self._sq_gate_computed = True elif signal_quality_gate_path is not None: import pickle with open(signal_quality_gate_path, "rb") as f: self._sq_gate = pickle.load(f) + self._sq_gate_computed = True + elif lake_root is not None: + self._sq_gate = None + self._lake_root = lake_root + self._gate_topk = gate_topk + self._gate_lookback = gate_lookback + self._gate_threshold = gate_threshold + self._gate_start = gate_start + self._gate_end = gate_end else: self._sq_gate = None + def _compute_gate_on_fly(self): + """Compute gate from the signal (pred.pkl) and lake close prices.""" + import pickle + from pathlib import Path + + signal_path = self._signal + if not Path(signal_path).exists(): + return + + with open(signal_path, "rb") as f: + pred = pickle.load(f) + + # Handle MultiIndex DataFrame -> unstack to wide + if isinstance(pred, pd.DataFrame) and isinstance(pred.index, pd.MultiIndex): + pred = pred.iloc[:, 0] + pred.index = pd.MultiIndex.from_arrays([ + pd.to_datetime(pred.index.get_level_values(0)).normalize(), + pred.index.get_level_values(1) + ]) + pred = pred.unstack(level=1) + elif isinstance(pred, pd.DataFrame): + pred = pred.iloc[:, 0] if pred.shape[1] >= 1 else pred.squeeze() + pred.index = pd.to_datetime(pred.index).normalize() + + # Load close prices from lake + close_df = _load_close_prices(self._lake_root, "US", self._gate_start, self._gate_end) + if close_df.empty: + return + + ret_df = close_df.pct_change() + ret_df.index = pd.to_datetime(ret_df.index).normalize() + + pred_dates = sorted(pred.index.unique()) + if len(pred_dates) < 2: + self._sq_gate = pd.Series(True, index=pd.DatetimeIndex(pred_dates)) + self._sq_gate_computed = True + return + + hit_rates = {} + for i in range(1, len(pred_dates)): + day = pred_dates[i] + prev_day = pred_dates[i - 1] + try: + prev_scores = pred.loc[prev_day] + except KeyError: + continue + if isinstance(prev_scores, pd.DataFrame): + prev_scores = prev_scores.iloc[:, 0] + prev_scores = prev_scores.dropna().sort_values(ascending=False) + topk_syms = list(prev_scores.index[:self._gate_topk]) + + if day not in ret_df.index: + continue + today_ret = ret_df.loc[day] + topk_rets = today_ret.reindex(topk_syms).dropna() + if len(topk_rets) == 0: + continue + + hit_rates[day] = (topk_rets > 0).sum() / len(topk_rets) + + if not hit_rates: + self._sq_gate_computed = True + return + + hr_series = pd.Series(hit_rates).sort_index() + rolling_hr = hr_series.rolling(self._gate_lookback, min_periods=1).mean() + gate = rolling_hr >= self._gate_threshold + gate.iloc[:self._gate_lookback] = True + + self._sq_gate = gate + self._sq_gate_computed = True + def _gate_open(self, trade_start_time) -> bool: if self._sq_gate is None: return True @@ -58,6 +150,8 @@ class SignalQualityGateStrategy(TopkDropoutStrategy): return True # default open if no history yet def generate_trade_decision(self, execute_result=None): + if not self._sq_gate_computed: + self._compute_gate_on_fly() trade_step = self.trade_calendar.get_trade_step() trade_start_time, _ = self.trade_calendar.get_step_time(trade_step) if not self._gate_open(trade_start_time):