This commit is contained in:
qq564020069
2026-07-04 10:34:51 +08:00
parent f209e67e06
commit d9c2811b60
12 changed files with 762 additions and 87 deletions
+52
View File
@@ -0,0 +1,52 @@
{
"active_features": [
"RET",
"RET20",
"MA_DIFF",
"HL_RANGE",
"DEV60",
"PRESSURE",
"VOL_RATIO",
"VOL_Z",
"REL_RET20",
"VWAP_DEV",
"EMA_RATIO_12_26",
"TREND_STRENGTH_50",
"PRICE_POS_50",
"PPO",
"ULT_OSC",
"YANG_ZHANG_VOL",
"RS_VOL",
"AMIHUD_ILLIQ",
"KYLE_LAMBDA",
"CMF_20",
"AROON_OSC_25",
"DMI_DIFF_14",
"DONCHIAN_POS_20",
"ICHIMOKU_KIJUN_DEV",
"SAR_DIST",
"ROLL_KURT_20",
"CS_RANK_RET5",
"CS_ZSCORE_RET20"
],
"meta": {
"source": "prune_features.py",
"corr_threshold": 0.85,
"top_k": 28,
"n_original": 65,
"n_retained": 28,
"symbols": [
"EURUSD",
"USDJPY",
"XAUUSD",
"AAVUSD",
"COCOA.c",
"US30.cash",
"US100.cash",
"US500.cash",
"US2000.cash",
"JP225.cash"
],
"bars": 3498
}
}
+274
View File
@@ -0,0 +1,274 @@
"""
backtest_elite.py — 对 elite pool 中的最优因子批量回测,发现问题
用法:
python backtest_elite.py --offline
"""
import sys, json, math
from pathlib import Path
import numpy as np
import torch
sys.path.insert(0, str(Path(__file__).parent))
from config import Config
from data_pipeline.data_manager import MT5DataManager
from data_pipeline.fetcher import MT5DataFetcher
from model_core.vocab import FORMULA_VOCAB, VOCAB_VERSION
from model_core.vm import StackVM
from model_core.features import MT5FeatureEngineer
from strategy_manager.signal import compute_target_positions_stateless
_H1_PER_YEAR = 6240
def decode(toks):
names = FORMULA_VOCAB.token_names
return " -> ".join(names[t] if 0 <= t < len(names) else f"?{t}" for t in toks)
def calc_sharpe(pnl):
m, s = pnl.mean(), pnl.std()
return float(m / (s + 1e-10) * math.sqrt(_H1_PER_YEAR))
def calc_sortino(pnl):
m = pnl.mean()
down = pnl[pnl < 0]
ds = down.std() if len(down) > 0 else 1e-10
ds = max(ds, abs(m), 1e-10)
return float(np.clip(m / ds * math.sqrt(_H1_PER_YEAR), -20, 20))
def calc_mdd(cum_pnl):
peak = np.maximum.accumulate(cum_pnl)
return float((peak - cum_pnl).max())
def calc_ic(factor, target_ret):
"""时序 IC:factor[t] vs target_ret[t+1],逐品种再取均值。"""
N, T = factor.shape
ic_list = []
for n in range(N):
x = factor[n, :-1]
y = target_ret[n, 1:]
xm = x - x.mean()
ym = y - y.mean()
sx = np.sqrt((xm**2).mean())
sy = np.sqrt((ym**2).mean())
if sx < 1e-6 or sy < 1e-6:
continue
ic = (xm * ym).mean() / (sx * sy + 1e-8)
ic_list.append(float(ic))
return float(np.mean(ic_list)) if ic_list else 0.0
def backtest_one(formula, feat, raw_dict, target_ret, cost_rate=0.0001):
"""对一个公式跑完整回测,返回统计结果。"""
vm = StackVM()
factor = vm.execute(formula, feat) # [N, T]
if factor is None:
return None
N, T = factor.shape
factor_np = factor.detach().numpy()
target_np = target_ret.detach().numpy()
# 持仓
pos = compute_target_positions_stateless(factor) # [N, T]
pos_np = pos.detach().numpy()
# 换手
prev = np.zeros_like(pos_np)
prev[:, 1:] = pos_np[:, :-1]
turnover = np.abs(pos_np - prev)
pnl = pos_np * target_np - turnover * cost_rate # [N, T]
# 分品种统计
per_sym = {}
sym_names = ["EURUSD", "USDJPY"][:N]
for i, sym in enumerate(sym_names):
p = pnl[i]
cum = np.cumsum(p)
per_sym[sym] = {
"pnl": p,
"cum": cum,
"total_ret": float(cum[-1]),
"sharpe": calc_sharpe(p),
"sortino": calc_sortino(p),
"mdd": calc_mdd(cum),
"n_trades": int((np.abs(np.diff(pos_np[i])) > 0.5).sum()),
"avg_hold": float(T / max(1, int((np.abs(np.diff(pos_np[i])) > 0.5).sum()))),
}
# 组合
port_pnl = pnl.mean(axis=0)
port_cum = np.cumsum(port_pnl)
# IC
ic = calc_ic(factor_np, target_np)
# 分段分析:前50% vs 后50%(检查过拟合)
split = T // 2
p1 = port_pnl[:split]
p2 = port_pnl[split:]
return {
"formula": formula,
"readable": decode(formula),
"per_sym": per_sym,
"port_total_ret": float(port_cum[-1]),
"port_sharpe": calc_sharpe(port_pnl),
"port_sortino": calc_sortino(port_pnl),
"port_mdd": calc_mdd(port_cum),
"ic": ic,
"n_pos_syms": sum(1 for d in per_sym.values() if d["total_ret"] > 0),
"n_syms": N,
# 稳定性:前后半段
"half1_sharpe": calc_sharpe(p1),
"half2_sharpe": calc_sharpe(p2),
"half1_sortino": calc_sortino(p1),
"half2_sortino": calc_sortino(p2),
# 换手
"avg_turnover": float(turnover.mean()),
"avg_hold_h": float(per_sym[sym_names[0]]["avg_hold"]) if sym_names else 0,
}
def main():
offline = "--offline" in sys.argv
# ── 候选因子(从 elite pool 手工提取,按分数降序)
CANDIDATES = [
# score=6.636 — 当前最优
[40, 51, 81, 83, 86, 125, 88, 125],
# score=6.224 — 变体(TANH_SQUASH→TS_SUM_5)
[40, 51, 81, 83, 86, 125, 88, 112],
# score=5.960 — TRIX × HURST 类型
[50, 105, 94, 103, 59, 66, 119, 88],
# score=5.827 — TRIX × HURST(4 个等分变体取代表)
[50, 105, 89, 103, 59, 88, 112, 66],
# score=5.811
[50, 105, 82, 103, 59, 88, 112, 66],
# 冒烟测试最优(供参比)
[3, 23, 110, 42, 53, 104, 72, 109],
]
print(f"\n{'='*70}")
print(f" Elite Pool 批量回测 | vocab={VOCAB_VERSION} | offline={offline}")
print(f"{'='*70}\n")
# ── 加载数据
print("加载数据...")
with MT5DataFetcher(offline=offline) as fetcher:
mgr = MT5DataManager(fetcher)
mgr.load()
raw_dict = mgr.raw_dict
syms = mgr.symbols
T = raw_dict["open"].shape[1]
print(f" 品种: {syms} T={T} bars")
# 只取 forex 组(EURUSD/USDJPY)
forex_syms = ["EURUSD", "USDJPY"]
forex_idx = [syms.index(s) for s in forex_syms if s in syms]
forex_raw = {k: v[forex_idx] for k, v in raw_dict.items()}
forex_feat = MT5FeatureEngineer.compute_features(forex_raw)
target_ret = mgr.target_ret[forex_idx]
print(f" Forex 品种: {[syms[i] for i in forex_idx]} feat={forex_feat.shape}\n")
# ── 批量回测
results = []
for i, formula in enumerate(CANDIDATES):
res = backtest_one(formula, forex_feat, forex_raw, target_ret)
if res is None:
print(f" [{i+1}] ❌ 公式执行失败: {formula}")
continue
results.append(res)
# ── 打印结果
print(f"\n{'='*70}")
print(f" 回测结果汇总({T} bars H1,forex 组)")
print(f"{'='*70}")
hdr = f" {'#':>3} {'Score*':>7} {'TotRet':>7} {'Sharpe':>7} {'Sortino':>8} {'MDD':>6} {'IC':>7} {'H1':>5} {'H2':>5} {'AvgHold':>8} {'公式类型':}"
print(hdr)
print(f" {'─'*95}")
for i, r in enumerate(results):
# 判断前后半段一致性
h1, h2 = r["half1_sharpe"], r["half2_sharpe"]
consistency = "✓" if h1 > 0 and h2 > 0 else ("⚠" if h1 * h2 > 0 else "✗")
print(f" [{i+1:2d}] {r['port_sortino']:>7.3f} "
f"{r['port_total_ret']:>7.3f} "
f"{r['port_sharpe']:>7.3f} "
f"{r['port_sortino']:>8.3f} "
f"{r['port_mdd']:>6.3f} "
f"{r['ic']:>7.4f} "
f"{h1:>5.2f} {h2:>5.2f} "
f"{r['avg_hold_h']:>8.1f}h "
f"{consistency} {r['readable'][:55]}")
# ── 品种级详情
print(f"\n{'─'*70}")
print(f" 品种级详情(前 3 个公式)")
print(f"{'─'*70}")
for i, r in enumerate(results[:3]):
print(f"\n [{i+1}] {r['readable']}")
for sym, d in r["per_sym"].items():
sig = "✓" if d["total_ret"] > 0 else "✗"
print(f" {sym}: TotRet={d['total_ret']:+.3f} Sharpe={d['sharpe']:+.3f} "
f"Sortino={d['sortino']:+.3f} MDD={d['mdd']:.3f} "
f"Trades={d['n_trades']} AvgHold={d['avg_hold']:.0f}h {sig}")
# ── 关键诊断
print(f"\n{'='*70}")
print(f" 关键诊断")
print(f"{'='*70}")
for i, r in enumerate(results):
issues = []
h1, h2 = r["half1_sharpe"], r["half2_sharpe"]
if h1 > 0 and h2 < 0:
issues.append(f"过拟合嫌疑: 前半Sharpe={h1:.2f} 后半={h2:.2f}")
if r["port_mdd"] > 0.5:
issues.append(f"最大回撤过大: {r['port_mdd']:.3f}")
if r["avg_hold_h"] < 2:
issues.append(f"持仓太短(>{r['avg_hold_h']:.1f}h),点差侵蚀大")
if r["avg_hold_h"] > 500:
issues.append(f"持仓极长({r['avg_hold_h']:.0f}h),实际交易数极少")
if r["n_pos_syms"] < r["n_syms"]:
issues.append(f"仅{r['n_pos_syms']}/{r['n_syms']}品种盈利,跨品种一致性差")
if abs(r["ic"]) < 0.005:
issues.append(f"IC≈0({r['ic']:.4f}),预测力存疑")
if issues:
print(f" [{i+1}] {decode(r['formula'])[:50]}")
for iss in issues:
print(f" ⚠ {iss}")
else:
print(f" [{i+1}] ✓ 无明显问题: {decode(r['formula'])[:55]}")
print()
# 保存
report_path = "backtest_output/elite_backtest.json"
Path("backtest_output").mkdir(exist_ok=True)
summary = []
for r in results:
summary.append({
"formula": r["formula"],
"readable": r["readable"],
"port_total_ret": round(r["port_total_ret"], 4),
"port_sharpe": round(r["port_sharpe"], 4),
"port_sortino": round(r["port_sortino"], 4),
"port_mdd": round(r["port_mdd"], 4),
"ic": round(r["ic"], 5),
"half1_sharpe": round(r["half1_sharpe"], 4),
"half2_sharpe": round(r["half2_sharpe"], 4),
"avg_hold_h": round(r["avg_hold_h"], 1),
})
with open(report_path, "w") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
print(f"报告已保存 → {report_path}\n")
if __name__ == "__main__":
main()
+8 -3
View File
@@ -221,7 +221,9 @@ class LoopedTransformer(nn.Module):
class AlphaGPT(nn.Module):
def __init__(self):
super().__init__()
self.d_model = 64
# d_model 64→96:vocab 剪枝后为 ~94,96 维 embedding 足以支撑,
# 兼顾容量与 CPU 自回归采样的速度(128 维每步耗时过高)。
self.d_model = 96
self.features_list = list(FORMULA_VOCAB.feature_names)
self.ops_list = list(FORMULA_VOCAB.operator_names)
@@ -239,11 +241,14 @@ class AlphaGPT(nn.Module):
self.pos_emb = nn.Parameter(torch.zeros(1, _POS_EMB_MAX, self.d_model))
# Enhanced Transformer with Looped Transformer
# num_layers 2→3、dim_feedforward 128→192:配合 d_model=96 适度扩容。
# 4 层 looped(×3 loops) 在 CPU 自回归采样下每步耗时过高,取 3 层平衡。
# nhead=4 → head_dim=24。
self.blocks = LoopedTransformer(
d_model=self.d_model,
nhead=4,
num_layers=2,
dim_feedforward=128,
num_layers=3,
dim_feedforward=192,
num_loops=3,
dropout=0.1
)
+28 -18
View File
@@ -4,6 +4,8 @@ model_core/config.py — 模型层配置
仅保留模型训练所需的参数。
品种、数据、风控等全局配置统一由根目录 config.py 的 Config 类管理。
"""
import math
import torch
from .vocab import FORMULA_VOCAB
@@ -26,15 +28,17 @@ class ModelConfig:
# 若后续改为批量并行公式评估(一次喂大批张量进 GPU),再切回 cuda。
DEVICE = torch.device("cpu")
# ── 训练参数(阶段 A:找简单稳定公式)────────────────────────────────
# 阶段 A(当前):MAX_FORMULA_LEN=8,TRAIN_STEPS=300
# 目标:找简单、稳定、低换手的基础公式,防过拟合
# 阶段 B(完成阶段A后切换):
# MAX_FORMULA_LEN=14,TRAIN_STEPS=500,ELITE_REPLAY_FRAC=0.35
# 目标:围绕好公式附近做组合增强
BATCH_SIZE = 128
TRAIN_STEPS = 3000 # 每品种训练步数(多因子模式下每品种独立跑)
MAX_FORMULA_LEN = 8 # 阶段B改为 14
# ── 训练参数(大搜索空间适配版,2026-07-04 重构)─────────────────────
# 背景:特征库扩展到 65、算子库扩展到 66(vocab=131),8-token 搜索空间
# 从旧版 ~7亿 暴增到 ~8.67×10^16(1.2 亿倍)。旧的采样预算(128×3000)
# 覆盖率趋近于零,导致熵坍塌 Early Stop、公式退化。
# 对策(训练时间不敏感场景):
# 1. 特征剪枝(active_features.json)把 vocab 降到 ~90,空间缩小约 20 倍
# 2. 放大采样预算:BATCH_SIZE 128→256,TRAIN_STEPS 3000→8000
# 3. 更大精英池(60)保留更多历史最优
BATCH_SIZE = 192 # 每步采样公式数(原 128,1.5x 提升覆盖率)
TRAIN_STEPS = 5000 # 每组训练步数(原 3000)
MAX_FORMULA_LEN = 8 # 公式长度上限(暂保持 8,防过拟合)
# ── 特征维度(由 vocab.py 自动派生,无需手动修改)──────────────────
INPUT_DIM: int = FORMULA_VOCAB.feature_count # == 10
@@ -45,20 +49,26 @@ class ModelConfig:
IC_GATE_MULT: float = 1.15
IC_NEG_MULT: float = 0.85
# ── 熵保护 ─────────────────────────────────────────────────────────
ENTROPY_COEFF_MAX: float = 0.50
# ── 熵保护(大空间加强版)──────────────────────────────────────────
# ENTROPY_COEFF_MAX 0.5→1.0:加倍探索压力,对抗大 vocab 的过早收敛。
# ENTROPY_COLLAPSE_THRESH 改为相对阈值 0.15×ln(vocab):大 vocab 最大熵更高
# (ln(131)≈4.87 vs ln(54)≈3.99),绝对阈值 0.5 不再合理。
# ENTROPY_COLLAPSE_STEPS 15→40:给模型更长的自我恢复窗口,不急于重启。
ENTROPY_COEFF_MAX: float = 1.0
ENTROPY_COEFF_POWER: float = 1.3
ENTROPY_COLLAPSE_THRESH: float = 0.5
ENTROPY_COLLAPSE_STEPS: int = 15
ENTROPY_COLLAPSE_THRESH: float = 0.15 * math.log(FORMULA_VOCAB.size)
ENTROPY_COLLAPSE_STEPS: int = 40
# ── Elite Replay ──────────────────────────────────────────────────
ELITE_REPLAY_FRAC: float = 0.25 # 阶段A;阶段B改为 0.35
ELITE_POOL_SIZE: int = 30
ELITE_REPLAY_FRAC: float = 0.25
ELITE_POOL_SIZE: int = 60 # 30→60:大空间需要更大的精英记忆
ELITE_REWARD_SCALE: float = 1.2
# ── 坍塌重启 ───────────────────────────────────────────────────────
MAX_RESTARTS: int = 8
RESTART_NOISE: float = 0.05
# ── 坍塌重启(大空间加强版)─────────────────────────────────────────
# MAX_RESTARTS 8→25、RESTART_NOISE 0.05→0.1:时间不敏感,多给机会+更强扰动。
# 配合 engine.py:超过 MAX_RESTARTS 后不再 Early Stop,改为强扰动继续训练。
MAX_RESTARTS: int = 25
RESTART_NOISE: float = 0.1
# ── 因子去相关参数 ────────────────────────────────────────────────
FACTOR_TOP_K: int = 10
+13 -3
View File
@@ -624,9 +624,19 @@ class AlphaEngine:
f"全参数扰动(σ={noise})")
self.opt = torch.optim.AdamW(self.model.parameters(), lr=1e-3)
else:
tqdm.write(f"[Early Stop] 已重启{max_r}次仍坍塌,"
f"H={ent_val:.3f},终止。")
break
# 训练时间不敏感:超过重启上限后不再 Early Stop 终止,
# 改为「全参数强扰动 + 重置流计数」继续探索,直到跑满 TRAIN_STEPS。
# 从 best_snapshot 恢复(若有)以保住已发现的最优结构,再加大扰动。
low_entropy_streak = 0
hard_noise = ModelConfig.RESTART_NOISE * 2.0
if self._best_snapshot is not None:
self.model.load_state_dict(self._best_snapshot)
with torch.no_grad():
for p in self.model.parameters():
p.add_(torch.randn_like(p) * hard_noise)
self.opt = torch.optim.AdamW(self.model.parameters(), lr=1e-3)
tqdm.write(f"[Hard Restart] 已重启{max_r}次仍坍塌,"
f"H={ent_val:.3f},强扰动(σ={hard_noise})继续,不终止。")
# ── End of training ──────────────────────────────────────────
if self.best_formula is not None:
+23
View File
@@ -1422,7 +1422,30 @@ _FEATURE_DEFS = [
("CS_ZSCORE_RET20", "cross_sectional", _fe._c_cs_zscore_ret20),
]
# ── 激活特征白名单(特征剪枝机制,2026-07-04)──────────────────────────
# 若项目根目录存在 active_features.json(由 prune_features.py 生成),
# 则只注册白名单内的特征,缩小 vocab 与搜索空间;否则注册全部 65 个特征。
# 白名单按 _FEATURE_DEFS 原始顺序过滤,保持特征维顺序稳定。
def _load_active_feature_allowlist() -> set[str] | None:
import json as _json
import pathlib as _pathlib
_path = _pathlib.Path(__file__).resolve().parent.parent / "active_features.json"
if not _path.exists():
return None
try:
data = _json.loads(_path.read_text(encoding="utf-8"))
names = data.get("active_features") if isinstance(data, dict) else data
allow = {str(n) for n in names}
return allow or None
except Exception:
return None
_ACTIVE_FEATURES = _load_active_feature_allowlist()
for _name, _category, _compute in _FEATURE_DEFS:
if _ACTIVE_FEATURES is not None and _name not in _ACTIVE_FEATURES:
continue
FEATURE_REGISTRY.register_feature(
FeatureSpec(name=_name, category=_category, compute=_compute)
)
+139
View File
@@ -0,0 +1,139 @@
"""
prune_features.py — 特征剪枝:从 65 个特征中筛出有区分度、低冗余的子集
背景:
特征库扩展到 65 个后,vocab=131,8-token 搜索空间暴增到 ~8.67×10^16。
其中大量特征高度相关(多周期均线/动量/通道位置类),是噪声维度。
做法(用 model_core.evaluator):
1. 离线加载全部品种数据,直接从 _FEATURE_DEFS 计算全部 65 个特征
2. score_all:对每个特征算 IC / RankIC / 互信息,跨特征秩归一聚合 importance
3. prune:保守双条件相关性剪枝(corr>阈值 且 分差>margin 才剪)
4. 额外按 importance 取 Top-K,去掉近零 IC 的弱特征
5. 写出 active_features.json(features.py 启动时读取,只注册白名单特征)
用法:
python prune_features.py # 默认 corr_threshold=0.85, top_k=28
python prune_features.py --top-k 25 # 自定义保留数量
python prune_features.py --corr 0.9 # 自定义相关性阈值
"""
import argparse
import json
import sys
from pathlib import Path
import torch
sys.path.insert(0, str(Path(__file__).parent))
from config import Config
from data_pipeline.data_manager import MT5DataManager
from data_pipeline.fetcher import MT5DataFetcher
from model_core.features import _FEATURE_DEFS
from model_core.evaluator import score_all, prune
OUTPUT = Path(__file__).parent / "active_features.json"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--top-k", type=int, default=28,
help="剪枝后额外按 importance 截断保留的最大特征数")
ap.add_argument("--corr", type=float, default=0.85,
help="相关性剪枝阈值(越低剪得越狠)")
ap.add_argument("--margin", type=float, default=0.01,
help="剪枝分差阈值(势均力敌时保留)")
args = ap.parse_args()
print(f"{'='*62}")
print(f" 特征剪枝 corr_threshold={args.corr} top_k={args.top_k}")
print(f"{'='*62}")
# ── 1. 离线加载数据 ───────────────────────────────────────────────
print("加载数据(离线缓存)...")
with MT5DataFetcher(offline=True) as fetcher:
mgr = MT5DataManager(fetcher)
mgr.load()
raw_dict = mgr.raw_dict
target = mgr.target_ret # [N, T]
syms = mgr.symbols
N, T = target.shape
print(f" 品种={syms} N={N} T={T}")
# ── 2. 直接从 _FEATURE_DEFS 计算全部 65 个特征(绕过白名单)───────
print(f"\n计算全部 {len(_FEATURE_DEFS)} 个特征...")
candidates: dict[str, torch.Tensor] = {}
categories: dict[str, str] = {}
for name, category, compute in _FEATURE_DEFS:
try:
series = compute(raw_dict) # [N, T]
candidates[name] = torch.nan_to_num(series, nan=0.0,
posinf=0.0, neginf=0.0)
categories[name] = category
except Exception as e:
print(f" [跳过] {name}: 计算失败 {e}")
# ── 3. 打分 ───────────────────────────────────────────────────────
print(f"\n对 {len(candidates)} 个特征打分(IC / RankIC / MI)...")
scores = score_all(candidates, target, categories=categories)
scores_sorted = sorted(scores, key=lambda s: (
-s.importance_score if s.importance_score == s.importance_score else 1,
))
print(f"\n {'特征':22s}{'类别':14s}{'IC':>8}{'RankIC':>8}{'MI':>8}{'importance':>12}")
print(f" {'-'*70}")
for s in scores_sorted:
imp = s.importance_score
imp_str = f"{imp:.4f}" if imp == imp and imp > -1e30 else " 退化"
print(f" {s.candidate:22s}{s.category:14s}"
f"{s.ic:+8.4f}{s.rank_ic:+8.4f}{s.mi:8.4f}{imp_str:>12}")
# ── 4. 相关性剪枝 ─────────────────────────────────────────────────
print(f"\n相关性剪枝(corr>{args.corr} 且分差>{args.margin})...")
rows = prune(scores, candidates, corr_threshold=args.corr, margin=args.margin)
retained = [r for r in rows if r.retention_status == "retained"]
pruned = [r for r in rows if r.retention_status == "pruned"]
print(f" 相关性剪枝后保留 {len(retained)} 个,剪掉 {len(pruned)} 个")
for r in pruned:
print(f" ✗ {r.candidate:22s} (favor of {r.pruned_in_favor_of})")
# ── 5. 按 importance 取 Top-K(去掉弱特征)────────────────────────
retained_sorted = sorted(
retained,
key=lambda r: (r.importance_score if r.importance_score == r.importance_score
and r.importance_score > -1e30 else -1e30),
reverse=True,
)
final = retained_sorted[:args.top_k]
final_names_set = {r.candidate for r in final}
# 保持 _FEATURE_DEFS 原始顺序输出
ordered_names = [name for name, _, _ in _FEATURE_DEFS if name in final_names_set]
print(f"\n{'='*62}")
print(f" 最终保留 {len(ordered_names)} 个特征(vocab 从 65 特征降至 {len(ordered_names)})")
print(f"{'='*62}")
for n in ordered_names:
print(f" ✓ {n}")
# ── 6. 写出 active_features.json ─────────────────────────────────
payload = {
"active_features": ordered_names,
"meta": {
"source": "prune_features.py",
"corr_threshold": args.corr,
"top_k": args.top_k,
"n_original": len(_FEATURE_DEFS),
"n_retained": len(ordered_names),
"symbols": syms,
"bars": T,
},
}
OUTPUT.write_text(json.dumps(payload, indent=2, ensure_ascii=False),
encoding="utf-8")
print(f"\n已写出 → {OUTPUT}")
print("重新 import model_core.features 时将只注册白名单特征。\n")
if __name__ == "__main__":
main()
+215
View File
@@ -0,0 +1,215 @@
# 已验证有效因子记录
> 生成时间:2026-07-04
> 训练日期:2026-07-03 ~ 2026-07-04(隔夜训练)
> 训练模式:分组训练(forex / metals_comm / index 三组),CPU
> 训练步数:目标 3000 步,实际因熵坍塌 Early Stop(forex=1207, metals_comm=845, index=913)
> 回测模式:离线缓存(D:\K线数据),T=3498 bars H1(10品种时间轴对齐)
> 数据周期:约 1.5 年历史数据
> vocab_version:v6ff4c52e30c1
---
## 一、有效性判定标准
本次记录以如下标准判定"有效":
- **✅ 确定有效**:Sharpe > 1 且 PnL > 0,回测结果稳健
- **⚠️ 部分有效**:同组内部分品种有效,存在泛化问题
- **❌ 无效**:整体亏损或零交易
---
## 二、本次回测汇总(2026-07-04)
| 品种 | 组 | PnL | Sharpe | Sortino | MaxDD | Calmar | 交易数 | 胜率 | 平均持仓 | 判定 |
|------|-----|-----|--------|---------|-------|--------|--------|------|----------|------|
| EURUSD | forex | -0.019 | -0.29 | -0.37 | 0.066 | -0.51 | 288 | 50.0% | 12.1h | ❌ |
| USDJPY | forex | +0.103 | +1.32 | +1.78 | 0.097 | +1.90 | 286 | 51.7% | 12.2h | ✅ |
| XAUUSD | metals_comm | -0.376 | -1.64 | -2.11 | 0.671 | -1.00 | 5 | 40.0% | 699h | ❌ |
| AAVUSD | metals_comm | +1.318 | +1.57 | +2.16 | 1.031 | +2.28 | 5 | 100% | 699h | ⚠️ |
| COCOA.c | metals_comm | +0.800 | +1.71 | +2.51 | 0.618 | +2.31 | 3 | 100% | 1166h | ⚠️ |
| US30.cash | index | 0.000 | 0.00 | 0.00 | 0.000 | 0.00 | 0 | — | — | ❌ |
| US100.cash | index | +0.300 | +1.47 | +1.75 | 0.296 | +1.81 | 3 | 100% | 1166h | ⚠️ |
| US500.cash | index | +0.201 | +1.26 | +1.48 | 0.236 | +1.52 | 3 | 100% | 1166h | ⚠️ |
| US2000.cash | index | -0.253 | -1.15 | -1.41 | 0.568 | -0.79 | 3 | 66.7% | 1166h | ❌ |
| JP225.cash | index | +0.563 | +2.40 | +3.01 | 0.226 | +4.45 | 3 | 66.7% | 1166h | ⚠️ |
| **Portfolio** | — | **+0.264** | **+2.84** | **+4.22** | **0.088** | **+5.37** | — | — | — | ✅ |
正收益品种:6/10 | Sharpe > 1 品种:6/10
---
## 三、各组公式详情
### 3.1 Forex 组(⚠️ 部分有效)
**公式**
```
RS_VOL -> DONCHIAN_POS_20 -> TS_STD_10 -> TS_RANK_5 -> TS_CORR_10 -> TANH_SQUASH -> MOMENTUM_10 -> TANH_SQUASH
```
Token 序列:`[40, 51, 81, 83, 86, 125, 88, 125]`
**训练结果**:Best Score = 6.636,Early Stop(step 1207/3000,熵坍塌触发上限)
**回测表现**:
- USDJPY:✅ Sharpe +1.32,Sortino +1.78,288 笔,胜率 51.7%,平均持仓 12h
- EURUSD:❌ Sharpe -0.29,小幅亏损
**公式逻辑**:
1. `RS_VOL`:相对波动率(品种波动率 / 截面均值),识别当前品种是否处于高波动状态
2. `DONCHIAN_POS_20`:价格在 20 期唐奇安通道内的相对位置,值域 [0,1],近期高点附近=1,低点附近=0
3. `TS_STD_10`:10 期时序标准差,衡量近期信号的变化幅度
4. `TS_RANK_5`:5 期时序排名,捕捉短期动量方向
5. `TS_CORR_10`:10 期相关系数,与截面均值的相关性
6. `TANH_SQUASH`:将值压缩到 (-1, 1),防止极端值
7. `MOMENTUM_10`:10 期动量(对数收益率差)
8. `TANH_SQUASH`:再次压缩,最终输出平滑信号
**本质**:波动率感知 + 通道位置 + 短期动量的组合信号,双重 tanh 压缩平滑。
**与历史 forex 公式对比**:
| 维度 | 历史公式(2026-07-03) | 本次公式(2026-07-04) |
|------|----------------------|----------------------|
| 公式 | `MA_DIFF -> TS_MIN_10 -> NEG -> ...` | `RS_VOL -> DONCHIAN_POS_20 -> ...` |
| EURUSD Sharpe | +1.82 | **-0.29(退步)** |
| USDJPY Sharpe | +1.63 | +1.32(相近) |
| 交易频率 | 83/77 笔 | 288/286 笔(高频化) |
| 持仓时长 | 57-62h | **12h(大幅缩短)** |
⚠️ **结论**:本次公式 USDJPY 仍有效,但 EURUSD 失效,高频化(12h 持仓)可能导致被点差侵蚀。历史 forex 公式(四重平滑)总体优于本次,建议继续沿用历史公式。
---
### 3.2 Metals_comm 组(⚠️ 分化严重)
**公式**
```
STOCH_K_14 -> KELTNER_POS_20 -> SUB -> WILLR_14 -> KELTNER_POS_20 -> DMI_DIFF_14 -> LT -> GATE
```
Token 序列:`[45, 52, 66, 26, 52, 49, 127, 72]`
**训练结果**:Best Score = 8.183,Early Stop(step 845/3000)
**回测表现**:
- AAVUSD:⚠️ Sharpe +1.57,5 笔交易,100% 胜率,持仓 699h(~29天)
- COCOA.c:⚠️ Sharpe +1.71,3 笔交易,100% 胜率,持仓 1166h(~49天)
- XAUUSD:❌ Sharpe -1.64,大幅亏损
**公式逻辑(RPN 执行)**:
```
[1] PUSH STOCH_K_14 → 随机指标K线(超买超卖)
[2] KELTNER_POS_20(STOCH_K) → 凯尔特纳通道位置
[3] PUSH WILLR_14 → 威廉指标(值域 [-1,0])
[4] KELTNER_POS_20(WILLR_14) → 威廉指标的通道位置
[5] PUSH DMI_DIFF_14 → 方向运动指标差值(+DI - -DI)
[6] LT(WILLR_keltner, DMI) → 二元比较(WILLR通道位置 < DMI差值?)
[7] SUB(STOCH_keltner, WILLR_keltner) → 差值
GATE(cond=SUB, x=LT, y=?) → 条件选择
```
> 注:实际 RPN 栈执行顺序较复杂,GATE 弹出最近 3 个值。
**本质**:技术指标组合(随机指标 + 威廉指标 + 方向运动指标)的阈值突破条件门控策略。
**分化原因**:
- XAUUSD 亏损而 AAVUSD/COCOA.c 盈利,说明公式对黄金的价格结构不适配
- 交易次数极少(3~5 笔),100% 胜率在小样本下统计意义不大
- 持仓 700~1200 小时实为超长期趋势跟踪,不是真正的"信号策略"
- **⚠️ 小样本警告**:3~5 笔交易不能作为有效性的可靠证据
---
### 3.3 Index 组(⚠️ 部分有效,泛化不足)
**公式**
```
VOL_REGIME -> TS_MAX_10 -> DELTA -> TS_MIN_10 -> PRESSURE -> MOMENTUM_5 -> WILLR_14 -> GATE
```
Token 序列:`[8, 89, 99, 90, 12, 87, 26, 72]`
**训练结果**:Best Score = 8.094,Early Stop(step 913/3000)
**回测表现**:
- JP225.cash:⚠️ Sharpe +2.40,3 笔,66.7% 胜率,持仓 1166h
- US100.cash:⚠️ Sharpe +1.47,3 笔,100% 胜率,持仓 1166h
- US500.cash:⚠️ Sharpe +1.26,3 笔,100% 胜率,持仓 1166h
- US2000.cash:❌ Sharpe -1.15,亏损
- US30.cash:❌ **零交易**(见下方根因分析)
**US30.cash 零交易根因**:
GATE 的 condition = `TS_MIN_10(DELTA(TS_MAX_10(VOL_REGIME)))` 要 > 0,要求:
> 过去 10 根 bar 内,VOL_REGIME 的 10 期滚动最大值**全部严格递增**
这是极苛刻的条件。大部分时间 DELTA=0(滚动最大在台阶期不变),导致 condition ≤ 0,GATE 全程选择 y 分支 = `WILLR_14`。
`WILLR_14` 值域 [-1, 0](永远非正),截面 z-score 归一化后 US30 的 WILLR_14 变化幅度不足,归一化后长期落在 neutral band (-0.3, 0) 内,始终不触发 ±0.3 的入场阈值。US100/US500 的波动率结构恰好在几个时点突破了该区间,产生了 3 笔交易。
**本质**:US30 数据无问题,是公式在该品种特征分布上泛化失败。
**⚠️ 小样本警告**:JP225/US100/US500 各仅 3 笔交易(其中 2 笔重叠),100% 胜率不可靠。
---
## 四、综合结论
### 4.1 跨时间段验证汇总
| 公式组 | 2026-07-03 回测 | 2026-07-04 回测 | 综合判定 |
|--------|----------------|----------------|---------|
| forex(旧公式) | EURUSD +1.82, USDJPY +1.63 | — | ✅ **确定有效** |
| forex(本次公式) | — | USDJPY +1.32, EURUSD -0.29 | ⚠️ **部分有效** |
| metals_comm(旧公式) | COCOA.c +2.54 | — | ⚠️ 不稳定 |
| metals_comm(本次公式) | — | AAVUSD +1.57, COCOA.c +1.71, XAUUSD -1.64 | ⚠️ 分化严重 |
| index(旧公式) | 全亏 | — | ❌ 无效 |
| index(本次公式) | — | JP225 +2.40, US100 +1.47, US500 +1.26, US30 零交易 | ⚠️ 部分有效 |
### 4.2 唯一确定有效的公式
**2026-07-03 Forex 公式**(历史最优,两次回测验证):
```
MA_DIFF -> TS_MIN_10 -> NEG -> TS_MAX_10 -> TS_MEAN_10 -> TS_MEAN_20 -> TS_MEAN_10 -> TS_MEAN_20
```
Token:`[3, 45, 24, 44, 33, 34, 33, 34]`(vocab_version: 2.0)
- EURUSD:Sharpe +1.82,Sortino +2.41,MaxDD 0.071,83 笔,胜率 57.8%
- USDJPY:Sharpe +1.63,Sortino +2.08,MaxDD 0.097,77 笔,胜率 57.1%
- 两次回测均正收益,跨品种一致,逻辑可解释
### 4.3 本次训练的主要问题
1. **Early Stop 过早**:三组均在 28%~40% 进度时因熵坍塌退出,未达到 3000 步目标
2. **index/metals_comm 组交易数极少**(3~5 笔),持仓超长(700~1200h),公式退化为稀疏趋势信号,统计可靠性极低
3. **US30.cash 零交易**:公式泛化失败,条件链在该品种上永不激活
4. **XAUUSD 亏损**:metals_comm 组共用公式,在黄金上反向运作
---
## 五、下一步建议
| 优先级 | 行动 | 说明 |
|--------|------|------|
| P0 | 沿用 forex 旧公式(2026-07-03)用于 EURUSD/USDJPY | 唯一经过两次独立验证的有效因子 |
| P1 | 解决 Early Stop 问题后重训 index 组 | 调大 `ENTROPY_COLLAPSE_STEPS`(15→30)或 `MAX_RESTARTS`(8→15)|
| P1 | index 组改为品种独立训练 | `--single US30.cash` 等,避免共用公式掩盖品种特异性 |
| P2 | metals_comm 公式需更多数据验证 | 3~5 笔样本量不足,需至少 30 笔以上才可信 |
| P3 | 清理 vocab_version 不匹配问题 | 旧公式 vocab_version=2.0,新公式=v6ff4c52e30c1,需统一 |
---
## 六、文件索引
| 文件 | 说明 |
|------|------|
| `strategies/best_group_forex.json` | 本次 forex 组公式(score=6.636) |
| `strategies/best_group_metals_comm.json` | 本次 metals_comm 组公式(score=8.183) |
| `strategies/best_group_index.json` | 本次 index 组公式(score=8.094) |
| `strategies/best_EURUSD.json` 等各品种 | 本次各品种策略文件 |
| `backtest_output/multi_factor_report.json` | 本次完整回测 JSON 报告 |
| `backtest_output/portfolio_equity.png` | 本次组合资金曲线图 |
| `strategies/FOREX_FACTOR_REPORT.md` | 历史 forex 组分析报告(2026-07-03) |
| `strategies/EFFECTIVE_FACTORS_ANALYSIS.md` | 历史综合因子分析(2026-07-03) |
| `training_history_forex.json` | 本次 forex 训练历史(1207 步) |
| `training_history_index.json` | 本次 index 训练历史(913 步) |
| `training_history_metals_comm.json` | 本次 metals_comm 训练历史(845 步) |
-17
View File
@@ -1,17 +0,0 @@
{
"vocab_version": "v6ff4c52e30c1",
"symbol": "EURUSD",
"group": "forex",
"formula": [
3,
23,
110,
42,
53,
104,
72,
109
],
"best_score": 5.9763946533203125,
"source": "group_forex"
}
-17
View File
@@ -1,17 +0,0 @@
{
"vocab_version": "v6ff4c52e30c1",
"symbol": "USDJPY",
"group": "forex",
"formula": [
3,
23,
110,
42,
53,
104,
72,
109
],
"best_score": 5.9763946533203125,
"source": "group_forex"
}
+10 -10
View File
@@ -1,15 +1,15 @@
{
"vocab_version": "v6ff4c52e30c1",
"vocab_version": "v61d94be3d0e5",
"symbol": "forex",
"formula": [
40,
51,
81,
83,
86,
125,
88,
125
16,
77,
77,
23,
75,
77,
71,
78
],
"best_score": 6.635913372039795
"best_score": 5.444646835327148
}
-19
View File
@@ -1,19 +0,0 @@
{
"vocab_version": "v6ff4c52e30c1",
"group": "forex",
"symbols": [
"EURUSD",
"USDJPY"
],
"formula": [
3,
23,
110,
42,
53,
104,
72,
109
],
"best_score": 5.9763946533203125
}