This commit is contained in:
rosemarycox5334-debug
2026-07-05 12:50:50 +08:00
parent 624272b910
commit 3669cc37ae
28 changed files with 809 additions and 1643 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

+32
View File
@@ -0,0 +1,32 @@
"""benchmark_speed.py — 诊断训练速度瓶颈"""
import sys, time
from pathlib import Path
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.config import ModelConfig
from model_core.engine import AlphaEngine
def run_benchmark(name, max_steps=10):
offline = True
original_symbols = Config.SYMBOLS[:]
Config.SYMBOLS = Config.SYMBOL_GROUPS["index"]
try:
with MT5DataFetcher(offline=offline) as fetcher:
mgr = MT5DataManager(fetcher)
mgr.load()
engine = AlphaEngine(data_manager=mgr)
t0 = time.time()
engine.train(start_step=0, end_step=max_steps, verbose_header=False)
elapsed = time.time() - t0
print(f"\n[{name}] {max_steps} steps in {elapsed:.1f}s = {elapsed/max_steps:.2f}s/it")
finally:
Config.SYMBOLS = original_symbols
if __name__ == "__main__":
print(f"Benchmark: formula_len={ModelConfig.MAX_FORMULA_LEN}, single model, 10 steps")
run_benchmark("speed", max_steps=10)
+290
View File
@@ -0,0 +1,290 @@
"""
deep_backtest_metals.py — metals_comm FTMO 最佳因子深入回测分析
对 strategies/best_metals_comm.json 中的因子在 XAUUSD/AAVUSD/COCOA.c 上:
- 全量历史回测
- Walk-Forward 分折统计
- 年度/月度收益分解
- FTMO 合规性检查
- 品种相关性、换手率、持仓时间
- 输出可视化图表和 JSON 报告
"""
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
COST_RATE = 0.0001 # 默认点差成本
ACCOUNT = 100_000.0
def sharpe(pnl):
m = pnl.mean()
s = pnl.std()
return float(m / s * math.sqrt(H1_PER_YEAR)) if s > 1e-10 else 0.0
def sortino(pnl):
m = pnl.mean()
d = pnl[pnl < 0]
ds = d.std() if len(d) > 0 else 1e-10
ds = max(ds, abs(m) * 0.2, 1e-10)
return float(np.clip(m / ds * math.sqrt(H1_PER_YEAR), -20, 20))
def calmar(pnl):
c = np.cumsum(pnl)
peak = np.maximum.accumulate(c)
dd = peak - c
max_dd = dd.max()
ann = pnl.mean() * H1_PER_YEAR
return float(ann / max_dd) if max_dd > 1e-10 else 0.0
def max_dd(pnl):
c = np.cumsum(pnl)
peak = np.maximum.accumulate(c)
dd = peak - c
return float(dd.max())
def avg_hold(position):
"""计算平均持仓时间(bars)"""
pos = np.sign(position)
if pos.size == 0:
return 0.0
runs = []
cur_dir = 0
cur_len = 0
for p in pos:
if p == 0:
if cur_len > 0:
runs.append(cur_len)
cur_dir, cur_len = 0, 0
elif p == cur_dir:
cur_len += 1
else:
if cur_len > 0:
runs.append(cur_len)
cur_dir, cur_len = p, 1
if cur_len > 0:
runs.append(cur_len)
return float(sum(runs) / len(runs)) if runs else 0.0
def daily_pnl(pnl_hourly):
"""H1 PnL -> daily PnL"""
n = len(pnl_hourly) // 24
return pnl_hourly[:n * 24].reshape(n, 24).sum(axis=1)
def ftmo_stats(daily, max_daily_loss=0.05, max_overall_loss=0.10, profit_target=0.10):
"""FTMO 2-Step 统计"""
scaled = daily * ACCOUNT
balance = ACCOUNT
peak = ACCOUNT
cum_pnl = 0.0
max_daily = 0.0
trading_days = 0
violations = 0
target_day = None
for d in scaled:
if abs(d) > 1e-6:
trading_days += 1
balance += d
cum_pnl += d
peak = max(peak, balance - d)
if d < 0:
max_daily = max(max_daily, abs(d) / ACCOUNT)
if balance < peak - max_overall_loss * ACCOUNT:
violations += 1
if target_day is None and cum_pnl >= profit_target * ACCOUNT:
target_day = trading_days
return {
"max_daily_loss": max_daily,
"violations": violations,
"trading_days": trading_days,
"target_day": target_day,
}
def main():
offline = "--offline" in sys.argv
# ── 加载因子 ─────────────────────────────────────────────────
data = json.load(open("strategies/best_metals_comm.json"))
if data.get("vocab_version") != VOCAB_VERSION:
print("[ERROR] vocab 版本不符"); return
formula = data["formula"]
readable = " -> ".join(FORMULA_VOCAB.token_names[t] for t in formula)
print(f"\n因子: {readable}")
print(f"训练 score: {data['best_score']:.4f} (step {data.get('step', '?')})\n")
# ── 加载数据 ─────────────────────────────────────────────────
original_symbols = Config.SYMBOLS[:]
Config.SYMBOLS = data.get("symbol_group", "metals_comm").split(",") if "," in data.get("symbol_group", "") else Config.SYMBOL_GROUPS["metals_comm"]
symbols = Config.SYMBOLS
results = {}
try:
with MT5DataFetcher(offline=offline) as fetcher:
mgr = MT5DataManager(fetcher)
mgr.load()
raw_dict = mgr.raw_dict
target_ret = mgr.target_ret
T = raw_dict["open"].shape[1]
print(f"数据: {symbols} T={T} bars\n")
feat = MT5FeatureEngineer.compute_features(raw_dict)
vm = StackVM()
factor = vm.execute(formula, feat)
if factor is None:
print("[ERROR] 因子执行失败"); return
position = compute_target_positions_stateless(factor)
prev_pos = torch.roll(position, 1, dims=1)
prev_pos[:, 0] = 0.0
turnover = torch.abs(position - prev_pos)
pnl = position * target_ret - turnover * COST_RATE
finally:
Config.SYMBOLS = original_symbols
# ── 总体统计 ─────────────────────────────────────────────────
port_pnl = pnl.mean(dim=0).numpy()
print(f"{'='*70}")
print(f" 组合总体统计(等权 {len(symbols)} 品种)")
print(f"{'='*70}")
print(f" 累计收益: {port_pnl.sum():+.4f}")
print(f" 年化收益: {port_pnl.mean() * H1_PER_YEAR:+.4f}")
print(f" Sharpe: {sharpe(port_pnl):+.4f}")
print(f" Sortino: {sortino(port_pnl):+.4f}")
print(f" Calmar: {calmar(port_pnl):+.4f}")
print(f" MaxDD: {max_dd(port_pnl):.4f}")
print(f" 交易次数: {int(np.sum(np.abs(np.diff(np.sign(port_pnl), prepend=0)) > 0))}")
print(f" 平均持仓: {avg_hold(port_pnl):.1f}h")
print()
# ── 分品种统计 ───────────────────────────────────────────────
print(f"{'='*70}")
print(f" 分品种统计")
print(f"{'='*70}")
print(f" {'品种':12s}{'累计':>10s}{'年化':>10s}{'Sharpe':>8s}{'Sortino':>8s}{'MaxDD':>8s}{'持仓(h)':>8s}")
print(f" {'─'*70}")
per_sym = {}
for i, sym in enumerate(symbols):
p = pnl[i].numpy()
per_sym[sym] = {
"total": float(p.sum()),
"annual": float(p.mean() * H1_PER_YEAR),
"sharpe": sharpe(p),
"sortino": sortino(p),
"maxdd": max_dd(p),
"avg_hold_h": avg_hold(position[i].numpy()),
}
print(f" {sym:12s}{p.sum():>+10.4f}{p.mean()*H1_PER_YEAR:>+10.4f}{sharpe(p):>+8.2f}{sortino(p):>+8.2f}{max_dd(p):>8.4f}{avg_hold(position[i].numpy()):>8.1f}")
# ── Walk-Forward 4 折 ────────────────────────────────────────
print(f"\n{'='*70}")
print(f" Walk-Forward 4 折(按时间顺序)")
print(f"{'='*70}")
fold_size = T // 4
wf = []
for f in range(4):
s, e = f * fold_size, (f + 1) * fold_size if f < 3 else T
fp = port_pnl[s:e]
wf.append({
"fold": f + 1,
"start_bar": s,
"end_bar": e,
"total": float(fp.sum()),
"sharpe": sharpe(fp),
"sortino": sortino(fp),
"maxdd": max_dd(fp),
})
print(f" Fold {f+1} [{s:6d}:{e:6d}] Tot={fp.sum():+.4f} Sharpe={sharpe(fp):+.2f} Sortino={sortino(fp):+.2f} MDD={max_dd(fp):.4f}")
# ── 年度/月度分解 ────────────────────────────────────────────
print(f"\n{'='*70}")
print(f" 年度收益分解(近似,按 6240 bars/年)")
print(f"{'='*70}")
yearly = []
bars_year = H1_PER_YEAR
n_years = T // bars_year
for y in range(n_years):
yp = port_pnl[y * bars_year:(y + 1) * bars_year]
yearly.append({"year": y + 1, "total": float(yp.sum()), "sharpe": sharpe(yp)})
print(f" Year {y+1}: Tot={yp.sum():+.4f} Sharpe={sharpe(yp):+.2f}")
# ── FTMO 合规性 ──────────────────────────────────────────────
print(f"\n{'='*70}")
print(f" FTMO 2-Step 合规性(1.0x 仓位)")
print(f"{'='*70}")
dly = daily_pnl(port_pnl)
ft = ftmo_stats(dly)
print(f" 最大日亏: {ft['max_daily_loss']:.2%} (限 5%)")
print(f" 违规次数: {ft['violations']}")
print(f" 交易天数: {ft['trading_days']}")
print(f" 达标 10% 日: {ft['target_day']}")
# ── 不同仓位倍数 ─────────────────────────────────────────────
print(f"\n{'='*70}")
print(f" 仓位缩放对 FTMO 的影响")
print(f"{'='*70}")
print(f" {'倍数':>6s}{'累计':>10s}{'最大日亏':>10s}{'达标日':>8s}{'违规':>6s}")
print(f" {'─'*46}")
for scale in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0]:
sd = daily_pnl(port_pnl * scale)
ft2 = ftmo_stats(sd)
print(f" {scale:>5.1f}x {port_pnl.sum()*scale:>+9.4f} {ft2['max_daily_loss']:>9.2%} {str(ft2['target_day']):>8s} {ft2['violations']:>6d}")
# ── 保存报告 ─────────────────────────────────────────────────
def _to_native(obj):
if isinstance(obj, np.floating): return float(obj)
if isinstance(obj, np.integer): return int(obj)
if isinstance(obj, np.ndarray): return obj.tolist()
if isinstance(obj, dict): return {k: _to_native(v) for k, v in obj.items()}
if isinstance(obj, list): return [_to_native(v) for v in obj]
return obj
report = _to_native({
"formula": formula,
"readable": readable,
"symbols": symbols,
"T": int(T),
"portfolio": {
"total_return": float(port_pnl.sum()),
"annual_return": float(port_pnl.mean() * H1_PER_YEAR),
"sharpe": sharpe(port_pnl),
"sortino": sortino(port_pnl),
"calmar": calmar(port_pnl),
"maxdd": max_dd(port_pnl),
"avg_hold_h": avg_hold(port_pnl),
},
"per_symbol": per_sym,
"walk_forward": wf,
"yearly": yearly,
"ftmo_1x": ft,
})
out_dir = Path("backtest_output")
out_dir.mkdir(exist_ok=True)
out_path = out_dir / "metals_comm_deep_report.json"
with open(out_path, "w") as f:
json.dump(report, f, indent=2)
print(f"\n报告已保存: {out_path}\n")
if __name__ == "__main__":
main()
+27 -1
View File
@@ -38,7 +38,7 @@ class ModelConfig:
# 3. 更大精英池(60)保留更多历史最优
BATCH_SIZE = 192 # 每步采样公式数(原 128,1.5x 提升覆盖率)
TRAIN_STEPS = 5000 # 每组训练步数(原 3000)
MAX_FORMULA_LEN = 8 # 公式长度上限(暂保持 8,防过拟合)
MAX_FORMULA_LEN = 8 # 公式长度上限:保持 8(10 会导致 CPU 训练慢 3 倍)
# ── 特征维度(由 vocab.py 自动派生,无需手动修改)──────────────────
INPUT_DIM: int = FORMULA_VOCAB.feature_count # == 10
@@ -79,6 +79,32 @@ class ModelConfig:
MAX_RESTARTS: int = 25
RESTART_NOISE: float = 0.1
# ── 自适应噪声:Best 停滞时自动增大扰动 ─────────────────────────────
# stagnation_window: 判断停滞的步数窗口
# noise_min / noise_max: 噪声下界和上界
# noise_boost: 停滞时噪声提升倍率
ADAPTIVE_NOISE: bool = True
STAGNATION_WINDOW: int = 500
NOISE_MIN: float = 0.05
NOISE_MAX: float = 0.40
NOISE_BOOST_FACTOR: float = 1.0 # noise += 0.1 * (stagnation / window)
# ── 重启时部分重置参数:保留底层,扰动顶层 ───────────────────────────
PARTIAL_RESET: bool = True
PARTIAL_RESET_LAYERS: tuple = ("ln_f", "mtp_head", "head_critic")
# ── Elite Replay 衰减:旧 elite 采样权重随时间衰减 ──────────────────
ELITE_DECAY: bool = True
ELITE_DECAY_HALF_LIFE: int = 1000 # 每 1000 步旧 elite 权重减半
# ── 多起点并行(Island)──────────────────────────────────────────────
# 注意:Island 模式在 CPU 训练下会让总时间变成 N 倍(islands 串行),
# 对于 index 这类大数组(T=32076)会变得极慢。当前默认关闭,保留配置开关。
N_ISLANDS: int = 1
MIGRATION_INTERVAL: int = 500
MIGRATION_TOP_K: int = 5
# island 默认关闭,避免用户误开导致速度爆炸
# ── 因子去相关参数 ────────────────────────────────────────────────
FACTOR_TOP_K: int = 25
CORR_THRESHOLD: float = 0.85
+121 -36
View File
@@ -176,10 +176,14 @@ class AlphaEngine:
self.factor_pool: list[tuple[float, int, torch.Tensor]] = []
self._factor_pool_counter = 0
# Elite Replay pool: (val_score, counter, formula_tokens)
self._elite_pool: list[tuple[float, int, list[int]]] = []
# Elite Replay pool: (val_score, counter, formula_tokens, birth_step)
self._elite_pool: list[tuple[float, int, list[int], int]] = []
self._elite_counter = 0
# 自适应噪声:记录 best 刷新步数
self._best_update_step = 0
self._stagnation_steps = 0
# ── IC computation ────────────────────────────────────────────────────────
@staticmethod
@@ -240,28 +244,29 @@ class AlphaEngine:
@staticmethod
def _dedup_elite_pool(
pool: list[tuple[float, int, list[int]]]
) -> list[tuple[float, int, list[int]]]:
pool: list[tuple[float, int, list[int], int]]
) -> list[tuple[float, int, list[int], int]]:
"""对精英池去重:相同 tokens 只保留得分最高的一条,重建最小堆。"""
best: dict[str, tuple[float, int, list[int]]] = {}
for sc, cnt, toks in pool:
best: dict[str, tuple[float, int, list[int], int]] = {}
for sc, cnt, toks, birth in pool:
key = str(toks)
if key not in best or sc > best[key][0]:
best[key] = (sc, cnt, toks)
best[key] = (sc, cnt, toks, birth)
deduped = list(best.values())
heapq.heapify(deduped)
return deduped
def _update_elite_pool(self, val_score: float, formula: list[int]) -> None:
def _update_elite_pool(self, val_score: float, formula: list[int], step: int = 0) -> None:
"""维护精英公式池(最小堆,Top-ELITE_POOL_SIZE 个历史最优公式,自动去重)。
去重逻辑:若 formula 已在池中,只在新得分更高时原地更新,不插入重复副本。
这防止了单一公式垄断 elite pool,保持多样性。
新增:记录 birth_step 用于 elite decay。
"""
k = ModelConfig.ELITE_POOL_SIZE
# 检查是否已有相同公式
for idx, (sc, cnt, toks) in enumerate(self._elite_pool):
for idx, (sc, cnt, toks, birth) in enumerate(self._elite_pool):
if toks == formula:
if val_score <= sc:
return # 已有更高分的相同公式,不更新
@@ -271,7 +276,7 @@ class AlphaEngine:
heapq.heapify(self._elite_pool) # O(k),k≤20,可接受
break
entry = (val_score, self._elite_counter, list(formula))
entry = (val_score, self._elite_counter, list(formula), step)
self._elite_counter += 1
if len(self._elite_pool) < k:
heapq.heappush(self._elite_pool, entry)
@@ -312,10 +317,15 @@ class AlphaEngine:
# ── Main training loop ────────────────────────────────────────────────────
def train(self, start_step: int = 0):
def train(self, start_step: int = 0, end_step: int | None = None,
migration_hook=None, verbose_header: bool = True):
if self.data_manager is None:
raise RuntimeError("AlphaEngine requires a data_manager.")
if end_step is None:
end_step = ModelConfig.TRAIN_STEPS
if verbose_header:
print("🚀 Starting MT5 Alpha Mining" +
(" with LoRD Regularization..." if self.use_lord else "..."))
print(f" Entropy: thresh={ModelConfig.ENTROPY_COLLAPSE_THRESH} "
@@ -334,6 +344,7 @@ class AlphaEngine:
use_wf = len(folds) > 1 and not (
folds[0]["train_start"] == 0 and folds[0]["train_end"] == T
)
if verbose_header:
if use_wf:
print(f" Walk-Forward: {len(folds)} 折 T={T}")
for k, f in enumerate(folds):
@@ -350,13 +361,13 @@ class AlphaEngine:
n_elite = max(1, int(bs * ModelConfig.ELITE_REPLAY_FRAC))
n_new = bs - n_elite
remaining = ModelConfig.TRAIN_STEPS - start_step
remaining = end_step - start_step
if remaining <= 0:
print(f"[Train] start_step={start_step} >= TRAIN_STEPS={ModelConfig.TRAIN_STEPS}, nothing to do.")
print(f"[Train] start_step={start_step} >= end_step={end_step}, nothing to do.")
return
pbar = tqdm(range(start_step, ModelConfig.TRAIN_STEPS),
total=ModelConfig.TRAIN_STEPS,
pbar = tqdm(range(start_step, end_step),
total=end_step,
initial=start_step)
low_entropy_streak = 0
@@ -386,8 +397,18 @@ class AlphaEngine:
# ── Part B: Elite Replay ─────────────────────────────────
elite_formulas: list[list[int]] = []
if self._elite_pool and n_elite > 0:
ps = [e[0] for e in self._elite_pool]
pt = [e[2] for e in self._elite_pool]
ps = []
pt = []
weights = []
for sc, cnt, toks, birth in self._elite_pool:
age = max(0, step - birth)
decay = 1.0
if ModelConfig.ELITE_DECAY:
half = max(1, ModelConfig.ELITE_DECAY_HALF_LIFE)
decay = 0.5 ** (age / half)
ps.append(sc)
pt.append(toks)
weights.append(decay)
ps_min = min(ps)
ps_max = max(ps)
# 软温度采样:避免最高分公式垄断
@@ -398,12 +419,23 @@ class AlphaEngine:
else:
normalized = [1.0] * len(ps)
temp = 0.5
exp_s = [2.0 ** (v / temp) for v in normalized] # 2^(v/T)
exp_s = [weights[i] * (2.0 ** (normalized[i] / temp)) for i in range(len(ps))]
exp_sum = sum(exp_s)
probs = [e / exp_sum for e in exp_s]
idx_e = random.choices(range(len(self._elite_pool)),
weights=probs, k=n_elite)
elite_formulas = [pt[i] for i in idx_e]
# 详细日志:Elite Replay 衰减状态(每 100 步打印一次)
if step % 100 == 0:
avg_decay = sum(weights) / len(weights)
max_age = max(max(0, step - birth) for _, _, _, birth in self._elite_pool)
age_list = sorted([max(0, step - birth) for _, _, _, birth in self._elite_pool])
tqdm.write(
f"[EliteReplay @ step {step}] pool={len(self._elite_pool)} "
f"avg_decay={avg_decay:.3f} max_age={max_age} ages={age_list} "
f"sampled_scores=[{', '.join(f'{ps[i]:.3f}' for i in idx_e[:3])}...]"
)
else:
elite_formulas = seqs_new[:n_elite].tolist()
@@ -505,20 +537,29 @@ class AlphaEngine:
pos_check = compute_target_positions_stateless(res)
exposure = pos_check.abs().mean().item() # 连续仓位:均值
if exposure < 0.05:
pass # 极稀疏:参与梯度更新但不登顶
# 极稀疏:参与梯度更新但不登顶,但记录日志方便排查
tqdm.write(
f"[SparseSkip @ step {step}] Val={final_val:.3f} "
f"IC={ic_i:.4f} Exp={exposure:.1%} | too sparse to be king"
)
pass
else:
old_best = self.best_score
self.best_score = final_val
self.best_formula = fml
self._best_snapshot = copy.deepcopy(self.model.state_dict())
self._best_update_step = step
self._stagnation_steps = 0
self._update_factor_pool(final_val, res)
# 即时保存:任何时刻进程退出都有最新最优公式(防终端回收丢策略)
self._save_strategy_live()
tqdm.write(
f"[!] New King: Val={final_val:.3f} IC={ic_i:.4f} "
f"Exp={exposure:.1%} | "
f"[!] New King @ step {step}: Val={final_val:.3f} "
f"(was {old_best:.3f}, +{final_val-old_best:.3f}) "
f"IC={ic_i:.4f} Exp={exposure:.1%} | "
f"{fml}\n {self._decode_formula(fml)}"
)
self._update_elite_pool(final_val, fml)
self._update_elite_pool(final_val, fml, step)
# ── Part D: REINFORCE gradient update ────────────────────
@@ -568,17 +609,20 @@ class AlphaEngine:
bis_ = sum(bis) / len(bis) if bis else 0.0
bsor_= sum(bsor) / len(bsor) if bsor else 0.0
self._stagnation_steps = step - self._best_update_step
tqdm.write(
f"[{step+1}/{ModelConfig.TRAIN_STEPS}] "
f"[{step+1}/{end_step}] "
f"new={n_new} elite={n_elite} | "
f"ok={ok_cnt} none={none_cnt} const={const_cnt} | "
f"Rew={avg_rew:.3f} Val={avg_val:.3f} | "
f"IC={bim:.4f} | H={ent_val:.3f}(c={ent_coeff:.3f}) | "
f"Best={self.best_score:.3f} epool={len(self._elite_pool)}"
f"Best={self.best_score:.3f} stagnation={self._stagnation_steps} "
f"epool={len(self._elite_pool)} restarts={self._restart_count}"
)
pbar.set_postfix({
'Val': f"{avg_val:.3f}", 'Best': f"{self.best_score:.3f}",
'H': f"{ent_val:.2f}", 'IC': f"{bim:.4f}",
'stag': f"{self._stagnation_steps}",
})
if self.use_lord and step % 10 == 0:
@@ -609,54 +653,92 @@ class AlphaEngine:
with open(save_path, "w") as fp:
json.dump(strategy_data, fp, indent=2)
if (step + 1) % 20 == 0 or (step + 1) == ModelConfig.TRAIN_STEPS:
if (step + 1) % 20 == 0 or (step + 1) == end_step:
ckpt = self.save_checkpoint(step + 1)
tqdm.write(f"[Checkpoint] → {ckpt}")
tqdm.write(f"[Checkpoint] → {ckpt} (best={self.best_score:.3f})")
# ── Part F: Migration hook(多岛训练时交换精英)────────────
if migration_hook is not None and (step + 1) % ModelConfig.MIGRATION_INTERVAL == 0:
tqdm.write(f"[MigrationHook @ step {step+1}] calling registered hook")
migration_hook(self, step + 1)
# ── Part F: Entropy collapse detection & restart ─────────
# ── Part G: Entropy collapse detection & restart ─────────
if ent_val < ModelConfig.ENTROPY_COLLAPSE_THRESH:
low_entropy_streak += 1
else:
low_entropy_streak = 0
if low_entropy_streak >= ModelConfig.ENTROPY_COLLAPSE_STEPS:
# ── 自适应噪声:根据 stagnation 调整 ─────────────────────
self._stagnation_steps = step - self._best_update_step
stagnation_ratio = self._stagnation_steps / max(1, ModelConfig.STAGNATION_WINDOW)
base_noise = ModelConfig.RESTART_NOISE
if ModelConfig.ADAPTIVE_NOISE:
raw_noise = base_noise + ModelConfig.NOISE_BOOST_FACTOR * 0.1 * min(stagnation_ratio, 3.0)
noise = max(ModelConfig.NOISE_MIN, min(ModelConfig.NOISE_MAX, raw_noise))
else:
noise = base_noise
max_r = ModelConfig.MAX_RESTARTS
if self._restart_count < max_r:
self._restart_count += 1
low_entropy_streak = 0
noise = ModelConfig.RESTART_NOISE
if self._best_snapshot is not None:
self.model.load_state_dict(self._best_snapshot)
with torch.no_grad():
if ModelConfig.PARTIAL_RESET:
perturbed_layers = []
for nm, p in self.model.named_parameters():
if 'ffn' in nm:
if any(k in nm for k in ModelConfig.PARTIAL_RESET_LAYERS):
p.add_(torch.randn_like(p) * noise)
tqdm.write(f"[Restart {self._restart_count}/{max_r}] "
f"快照恢复+FFN扰动(σ={noise})")
perturbed_layers.append(nm)
else:
perturbed_layers = []
for nm, p in self.model.named_parameters():
if 'ffn' in nm or 'attention' in nm or nm.startswith('blocks'):
p.add_(torch.randn_like(p) * noise)
perturbed_layers.append(nm)
tqdm.write(
f"[Restart {self._restart_count}/{max_r} @ step {step}] "
f"mode={'partial' if ModelConfig.PARTIAL_RESET else 'FFN/Attn'} "
f"noise={noise:.4f}(base={base_noise:.3f}, ratio={stagnation_ratio:.2f}) "
f"stagnation={self._stagnation_steps} "
f"H={ent_val:.3f} "
f"perturbed_layers={len(perturbed_layers)}"
)
else:
with torch.no_grad():
for p in self.model.parameters():
p.add_(torch.randn_like(p) * noise)
tqdm.write(f"[Restart {self._restart_count}/{max_r}] "
f"全参数扰动(σ={noise})")
tqdm.write(
f"[Restart {self._restart_count}/{max_r} @ step {step}] "
f"mode=full_param "
f"noise={noise:.4f}(base={base_noise:.3f}, ratio={stagnation_ratio:.2f}) "
f"stagnation={self._stagnation_steps} "
f"H={ent_val:.3f} | no best snapshot"
)
self.opt = torch.optim.AdamW(self.model.parameters(), lr=1e-3)
else:
# 训练时间不敏感:超过重启上限后不再 Early Stop 终止,
# 改为「全参数强扰动 + 重置流计数」继续探索,直到跑满 TRAIN_STEPS。
# 从 best_snapshot 恢复(若有)以保住已发现的最优结构,再加大扰动。
low_entropy_streak = 0
hard_noise = ModelConfig.RESTART_NOISE * 2.0
hard_noise = min(ModelConfig.NOISE_MAX, 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})继续,不终止。")
tqdm.write(
f"[Hard Restart @ step {step}] max_reached={max_r} "
f"H={ent_val:.3f} hard_noise={hard_noise:.4f} "
f"continue training without early stop"
)
# ── End of training ──────────────────────────────────────────
# 仅当跑满最终步时才保存最终 strategy 和历史
if end_step == ModelConfig.TRAIN_STEPS:
if self.best_formula is not None:
from .vocab import VOCAB_VERSION
strategy_data = {
@@ -684,6 +766,9 @@ class AlphaEngine:
print(f" Best formula : {self.best_formula}")
print(f" Readable : {self._decode_formula(self.best_formula)}")
print(f" Elite pool : {len(self._elite_pool)}")
print(f" Elite decay : enabled={ModelConfig.ELITE_DECAY}, half_life={ModelConfig.ELITE_DECAY_HALF_LIFE}")
print(f" Adaptive noise : enabled={ModelConfig.ADAPTIVE_NOISE}, range=[{ModelConfig.NOISE_MIN}, {ModelConfig.NOISE_MAX}]")
print(f" Partial reset : enabled={ModelConfig.PARTIAL_RESET}, layers={ModelConfig.PARTIAL_RESET_LAYERS}")
print(f" Restarts : {self._restart_count}")
print(f" Strategy saved : {save_path}")
+151
View File
@@ -0,0 +1,151 @@
"""
model_core/island_engine.py — 多起点并行训练(Island Model)
同时维护 N 个独立的 AlphaEngine(island),每个 island 独立探索不同的
公式空间区域。每隔 migration_interval 步,把所有 island 的 elite 公式
汇总,取 Top-K 注入到其他 island 的 elite pool 中,实现"精英迁移"。
注意:这里的"并行"是算法层面的多群体演化,不是 Python multiprocessing。
CPU 训练下串行轮流训练每个 island 一个小阶段效率更高,且输出不混乱。
"""
import copy
import heapq
import json
from pathlib import Path
import torch
from .config import ModelConfig
from .engine import AlphaEngine
class IslandAlphaEngine:
"""管理多个 AlphaEngine 组成 island population。"""
def __init__(self, data_manager, n_islands: int | None = None,
migration_interval: int | None = None,
migration_top_k: int | None = None):
self.data_manager = data_manager
self.n_islands = n_islands or ModelConfig.N_ISLANDS
self.migration_interval = migration_interval or ModelConfig.MIGRATION_INTERVAL
self.migration_top_k = migration_top_k or ModelConfig.MIGRATION_TOP_K
self.islands: list[AlphaEngine] = []
for i in range(self.n_islands):
isl = AlphaEngine(data_manager=data_manager)
# 给每个 island 不同的随机初始化,增加多样性
torch.manual_seed(2026 + i * 17)
isl.model = isl.model.__class__().to(ModelConfig.DEVICE)
isl.opt = torch.optim.AdamW(isl.model.parameters(), lr=1e-3)
self.islands.append(isl)
self.global_best_score = -float('inf')
self.global_best_formula = None
self.global_best_island = -1
self._step = 0
def _migrate_elites(self, step: int):
"""在所有 islands 之间交换 Top-K elite 公式。"""
# 收集所有 island 的 elite
all_elites = []
for isl in self.islands:
all_elites.extend(isl._elite_pool)
if len(all_elites) < 2:
return
# 去重:相同公式保留最高分和最新 birth_step
best_by_formula = {}
for sc, cnt, toks, birth in all_elites:
key = str(toks)
if key not in best_by_formula or sc > best_by_formula[key][0]:
best_by_formula[key] = (sc, cnt, toks, birth)
# 按得分排序取 Top-K
sorted_elites = sorted(
best_by_formula.values(), key=lambda x: x[0], reverse=True
)
top_elites = sorted_elites[:self.migration_top_k]
# 注入到每个 island(替换低分 elite)
injected = 0
for isl in self.islands:
for sc, cnt, toks, birth in top_elites:
# 避免注入 island 已存在的公式(_update_elite_pool 会处理去重)
isl._update_elite_pool(sc, list(toks), step)
injected += 1
print(f"\n[Island Migration @ step {step}] "
f"collected {len(all_elites)} elites, "
f"deduped to {len(best_by_formula)}, "
f"injected {injected} top elites across {self.n_islands} islands\n")
def _update_global_best(self):
"""从所有 island 中更新全局最优。"""
for i, isl in enumerate(self.islands):
if isl.best_score > self.global_best_score:
self.global_best_score = isl.best_score
self.global_best_formula = isl.best_formula
self.global_best_island = i
def train(self):
"""主训练循环:每个 island 轮流训练一个阶段,然后迁移 elite。"""
total_steps = ModelConfig.TRAIN_STEPS
n_phases = total_steps // self.migration_interval
if n_phases == 0:
n_phases = 1
print(f"\n{'='*60}")
print(f" Island Alpha Training")
print(f" islands={self.n_islands} migration_every={self.migration_interval}")
print(f" total_steps={total_steps} phases={n_phases}")
print(f"{'='*60}\n")
for phase in range(n_phases):
start = phase * self.migration_interval
end = min((phase + 1) * self.migration_interval, total_steps)
for i, isl in enumerate(self.islands):
print(f"\n>>> Phase {phase+1}/{n_phases} — Island {i+1}/{self.n_islands} "
f"steps [{start}:{end}]")
# 每个 island 独立训练一个阶段
isl.train(start_step=start, end_step=end,
migration_hook=None, verbose_header=False)
self._update_global_best()
# 阶段结束:迁移 elite
self._migrate_elites(end)
# 同步全局最优到每个 island 的 best_snapshot
# 这样下次 restart 时可以从全局最优恢复,而非局部最优
for isl in self.islands:
if self.global_best_score > isl.best_score:
isl.best_score = self.global_best_score
isl.best_formula = copy.deepcopy(self.global_best_formula)
# 最终保存全局最优
self._update_global_best()
if self.global_best_formula is not None:
from .vocab import VOCAB_VERSION
strategy_data = {
"vocab_version": VOCAB_VERSION,
"formula": self.global_best_formula,
"best_score": self.global_best_score,
"island_engine": True,
"n_islands": self.n_islands,
}
save_path = Path("strategies") / "best_island_strategy.json"
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, "w") as fp:
json.dump(strategy_data, fp, indent=2)
print(f"\n✓ Island training completed!")
print(f" Global best score : {self.global_best_score:.4f}")
print(f" From island : {self.global_best_island + 1}")
print(f" Formula : {self.global_best_formula}")
sample_island = self.islands[self.global_best_island] if self.global_best_island >= 0 else self.islands[0]
readable = sample_island._decode_formula(self.global_best_formula)
print(f" Readable : {readable}")
print(f" Saved to : {save_path}")
def get_global_best(self):
return self.global_best_formula, self.global_best_score
+37
View File
@@ -0,0 +1,37 @@
"""save_metals_best.py — 保存 metals_comm 当前 checkpoint 中的最优因子"""
import sys, json, torch
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from model_core.vocab import FORMULA_VOCAB, VOCAB_VERSION
ckpts = sorted(Path('checkpoints').glob('ckpt_metals_comm_step_*.pt'))
if not ckpts:
print("无 metals_comm checkpoint"); sys.exit(1)
latest = ckpts[-1]
ckpt = torch.load(latest, map_location='cpu', weights_only=False)
formula = ckpt['best_formula']
score = float(ckpt['best_score'])
step = int(ckpt['step'])
out = {
"group": "metals_comm",
"symbol_group": "metals_comm",
"vocab_version": VOCAB_VERSION,
"formula": formula,
"best_score": score,
"step": step,
"saved_at": str(Path(__file__).parent),
"mode": "ftmo"
}
out_path = Path('strategies/best_metals_comm.json')
with open(out_path, 'w') as f:
json.dump(out, f, indent=2)
readable = " -> ".join(FORMULA_VOCAB.token_names[t] for t in formula)
print(f"已保存: {out_path}")
print(f" step={step} score={score:.4f}")
print(f" {readable}")
-207
View File
@@ -1,207 +0,0 @@
# 有效因子回测与原理分析报告
> 生成时间:2026-07-03
> 回测模式:离线(默认点差)
> 两次回测综合:T=4777 bars(5品种)+ T=8375 bars(6品种)
## 一、回测结果汇总
### 第一次回测(T=4777 bars,品种: EURUSD/USDJPY/COCOA.c/US30/US100)
| 品种 | 组 | PnL | Sharpe | Sortino | MaxDD | 交易数 | 胜率 | 持仓h |
|------|-----|-----|--------|---------|-------|--------|------|-------|
| EURUSD | forex | +0.161 | +1.82 | +2.41 | 0.071 | 83 | 57.8% | 57.5 |
| USDJPY | forex | +0.229 | +1.63 | +2.08 | 0.097 | 77 | 57.1% | 62.0 |
| COCOA.c | metals_comm | +1.629 | +2.54 | +3.76 | 0.364 | 684 | 51.9% | 7.0 |
| US30.cash | index | -0.257 | -1.35 | -1.68 | 0.361 | 3 | 33.3% | 1592 |
| US100.cash | index | -0.430 | -1.60 | -2.02 | 0.625 | 3 | 0.0% | 1592 |
### 第二次回测(T=8375 bars,品种: EURUSD/XAUUSD/AAVUSD/US30/US100/US500)
| 品种 | 组 | PnL | Sharpe | Sortino | MaxDD | 交易数 | 胜率 | 持仓h |
|------|-----|-----|--------|---------|-------|--------|------|-------|
| EURUSD | forex | +0.140 | +1.37 | +1.95 | 0.061 | 136 | 53.7% | 61.6 |
| XAUUSD | metals_comm | NaN | NaN | NaN | NaN | 1117 | 37.7% | 7.5 |
| AAVUSD | metals_comm | -0.364 | -0.28 | -0.33 | 1.433 | 1088 | 47.2% | 7.7 |
| US30.cash | index | -0.165 | -0.72 | -0.84 | 0.365 | 5 | 40.0% | 1675 |
| US100.cash | index | -0.316 | -0.99 | -1.25 | 0.628 | 3 | 33.3% | 2791 |
| US500.cash | index | -0.213 | -0.53 | -0.63 | 0.455 | 3 | 33.3% | 2791 |
> 注:USDJPY 和 COCOA.c 第二次因 parquet 缓存损坏未加载;XAUUSD 数据含 NaN 导致统计失效。
### 综合判定
| 组 | 公式 | 两次回测表现 | 判定 |
|----|------|-------------|------|
| **forex** | `MA_DIFF -> TS_MIN_10 -> NEG -> TS_MAX_10 -> TS_MEAN_10 -> TS_MEAN_20 -> TS_MEAN_10 -> TS_MEAN_20` | EURUSD Sharpe 1.82→1.37(两次正收益),USDJPY Sharpe 1.63 | ✅ **确定有效** |
| **metals_comm** | `RET20 -> RET -> WMA -> VOL_RATIO -> ATR -> TS_MEAN_20 -> GATE -> TS_CORR_10` | COCOA.c Sharpe 2.54(第一次),AAVUSD Sharpe -0.28(第二次),XAUUSD NaN | ⚠️ **不稳定** |
| **index** | `RET5 -> ABS -> ABS -> SIGN -> ABS -> ABS -> NEG -> TS_RANK_10` | 两次全亏,3-5笔交易 | ❌ **无效(过拟合)** |
---
## 二、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]`
### 栈式虚拟机执行过程
全部为 1 元算子,依次变换:
| 步骤 | 操作 | 栈顶值 | 含义 |
|------|------|--------|------|
| 1 | 压入 MA_DIFF | MA_DIFF | 快慢均线差 = MA10/MA30 - 1 |
| 2 | TS_MIN_10 | TS_MIN_10(MA_DIFF) | 过去10根的最低均线差 |
| 3 | NEG | -TS_MIN_10(MA_DIFF) | 取反 → 反弹潜力 |
| 4 | TS_MAX_10 | TS_MAX_10(-TS_MIN_10(MA_DIFF)) | 反弹潜力的10根最高值 |
| 5 | TS_MEAN_10 | 平滑①(10期均值) | 去噪 |
| 6 | TS_MEAN_20 | 平滑②(20期均值) | 进一步去噪 |
| 7 | TS_MEAN_10 | 平滑③(10期均值) | 三重平滑 |
| 8 | TS_MEAN_20 | 平滑④(20期均值) | 四重平滑 → 最终信号 |
### 逐步逻辑解读
**第 1 步 — MA_DIFF(趋势感知)**
MA_DIFF = MA10/MA30 - 1,衡量短期均线相对长期均线的偏离。正值=多头排列,负值=空头排列。这是趋势的"温度计"。
**第 2 步 — TS_MIN_10(找极值)**
取过去 10 根 K 线的最低 MA_DIFF。当这个值很低(深度负值),说明近期出现过明显的空头趋势——价格在短期内大幅下跌。
**第 3 步 — NEG(反转信号)**
取反。`-TS_MIN_10(MA_DIFF)` 的逻辑是:**近期跌得越狠,反转潜力越大**。这是经典的均值回归假设——价格偏离越远,回弹力度越强。
**第 4 步 — TS_MAX_10(确认反转)**
取反转潜力的 10 根最高值。这一步是"确认机制":不仅要求反弹潜力存在,还要求它在近期达到过峰值——即反转已经开始发生,而不是还在继续下跌。这避免了"抄底抄在半山腰"。
**第 5-8 步 — 四重交替平滑(去噪)**
TS_MEAN_10 → TS_MEAN_20 → TS_MEAN_10 → TS_MEAN_20,交替使用 10 期和 20 期均值。这种级联平滑等价于一个低通滤波器:
- 第一层 MEAN_10 滤除 10 根以内的噪声
- 第二层 MEAN_20 滤除 20 根以内的波动
- 第三、四层进一步压制残余噪声
- 最终信号非常平滑,只有明确的趋势性信号才能通过
### 经济学原理
**本质:超跌反弹 + 趋势确认 + 多重平滑**
这个公式捕捉的是外汇市场的一个经典现象:**短期超卖后的均值回归**。EURUSD 和 USDJPY 作为主要货币对,其价格行为有强烈的均值回归特性(与股票指数的趋势性不同)——短期内过度偏离后,往往会出现反向修正。
公式的精妙之处在于:
1. **不直接抄底**:先等"反弹潜力"出现(TS_MIN_10 + NEG)
2. **确认反转**:再等反弹潜力达到峰值(TS_MAX_10),避免接飞刀
3. **四重平滑**:最后用四层均值过滤掉所有短期噪声,只保留高置信度信号
这解释了回测特征:
- **持仓 2-3 天**(57-62h):平滑后的信号变化缓慢,持仓周期中等
- **胜率 54-58%**:不是高频赌博,是中等胜率+正期望
- **MaxDD 仅 0.06-0.10**:四重平滑使信号极度平滑,避免了大幅回撤
- **跨品种一致**:EURUSD 和 USDJPY 共用公式都盈利,说明捕捉的是外汇共性
---
## 三、Metals_comm 组公式原理分析(不稳定)
### 公式
```
RET20 -> RET -> WMA -> VOL_RATIO -> ATR -> TS_MEAN_20 -> GATE -> TS_CORR_10
```
Token: `[2, 0, 46, 14, 5, 34, 27, 41]`
### 栈式虚拟机执行过程
包含 2 个特征 + 1 个 1 元算子 + 2 个特征 + 1 个 1 元算子 + 1 个 3 元算子 + 1 个 2 元算子:
| 步骤 | 操作 | 栈状态(底→顶) | 含义 |
|------|------|------------------|------|
| 1 | 压入 RET20 | [RET20] | 20期动量 |
| 2 | 压入 RET | [RET20, RET] | 单期收益率 |
| 3 | WMA(RET) | [RET20, WMA(RET)] | 加权平滑收益率 |
| 4 | 压入 VOL_RATIO | [RET20, WMA(RET), VOL_RATIO] | 量比 |
| 5 | 压入 ATR | [RET20, WMA(RET), VOL_RATIO, ATR] | 波动率 |
| 6 | TS_MEAN_20(ATR) | [RET20, WMA(RET), VOL_RATIO, TS_MEAN_20(ATR)] | 波动率均值 |
| 7 | GATE | [RET20, GATE(WMA(RET), VOL_RATIO, TS_MEAN_20(ATR))] | 条件选择 |
| 8 | TS_CORR_10 | [TS_CORR_10(RET20, GATE(...))] | 滚动相关系数 |
### GATE 算子详解
```
GATE(condition, x, y) = (condition > 0) * x + (condition <= 0) * y
```
在第 7 步中,弹出 3 个值(逆序弹出再 reverse):
- **condition** = WMA(RET)(加权短期收益率)
- **x** = VOL_RATIO(量比)
- **y** = TS_MEAN_20(ATR)(波动率均值)
含义:
- **当 WMA(RET) > 0(价格上涨时)**:信号 = VOL_RATIO → 关注成交量放大程度
- **当 WMA(RET) ≤ 0(价格下跌时)**:信号 = TS_MEAN_20(ATR) → 关注波动率水平
### TS_CORR_10 详解
第 8 步弹出 2 个值:
- **a** = RET20(中期动量)
- **b** = GATE(...)(上述条件信号)
计算 a 和 b 的 10 期滚动 Pearson 相关系数,值域 [-1, 1]。
### 经济学原理
**本质:条件性量价确认策略**
这个公式的逻辑是:
1. **上涨时看量**:当短期收益为正,提取"量比"信号——如果成交量放大(VOL_RATIO 高),说明上涨有量能配合,趋势可信
2. **下跌时看波动**:当短期收益为负,提取"波动率"信号——如果波动率高,可能是恐慌性抛售(可能见底);如果波动率低,可能是温和回调(可能继续跌)
3. **与中期动量相关**:最后计算这个条件信号与 20 期动量的相关性——正相关说明中期趋势得到了量/波的确认
### 为什么不稳定?
这个公式在 COCOA.c 上 Sharpe 2.54,但在 AAVUSD 上 Sharpe -0.28:
1. **COCOA.c 的特殊性**:可可期货是低流动性品种,量价关系更明显——放量上涨通常意味着真实的供需变化,信号质量高
2. **AAVUSD 的成交量噪声**:AAVUSD(白银相关)的成交量数据质量可能较差,VOL_RATIO 信号失真,导致 GATE 在上涨时选了一个噪声信号
3. **高频交易的成本侵蚀**:1088 笔交易、持仓 7.7h,每笔被点差吃一次——AAVUSD 的点差可能比 COCOA.c 更宽,高频策略在宽点差下必亏
4. **XAUUSD 的 NaN**:黄金数据含 NaN(可能是缓存损坏),无法评估
**结论**:metals_comm 组公式对品种的成交量数据质量高度敏感,不具备跨品种泛化能力。COCOA.c 上的好成绩可能是特例,而非真正的有效因子。
---
## 四、Index 组公式分析(过拟合失败)
### 公式
```
RET5 -> ABS -> ABS -> SIGN -> ABS -> ABS -> NEG -> TS_RANK_10
```
### 为什么失败
连续 6 个 ABS/SIGN 操作:
- `ABS(ABS(x))` = `ABS(x)` — 冗余
- `SIGN(ABS(x))` = `SIGN(ABS(x))` = 1(当 x≠0 时恒为 1)
- 后续 ABS 对常数 1 无影响
- `NEG(1)` = -1(常数)
- `TS_RANK_10(-1)` = 0(常数在 10 期窗口内排名恒为 0)
**这个公式最终输出接近常数,导致几乎不产生交易信号**(3-5 笔交易,持仓 66-116 天)。训练时触发 8/8 次熵坍塌重启,说明搜索空间塌缩——模型找到了一个"在训练集上得分高但无实际意义"的公式。
训练分 Best=7.562 是三组最高,但回测全亏——**典型的过拟合:训练分越高,泛化越差**。
---
## 五、总结
| 公式 | 原理 | 有效性 | 根本原因 |
|------|------|--------|----------|
| forex | 超跌反弹 + 趋势确认 + 四重平滑 | ✅ 有效 | 捕捉外汇市场均值回归特性,逻辑稳健,跨品种一致 |
| metals_comm | 条件性量价确认 | ⚠️ 不稳定 | 依赖成交量数据质量,对品种敏感,高频成本侵蚀 |
| index | 符号堆砌(退化) | ❌ 无效 | 连续 ABS/SIGN 导致输出近似常数,过拟合 |
**核心发现**:
1. **好的因子逻辑可解释**:forex 组每一步都有明确的统计含义,对应"超跌反弹"这一经典金融现象
2. **训练分高 ≠ 好因子**:index 组训练分最高(7.56)但回测崩溃,forex 组训练分中等(5.98)但回测最好
3. **跨品种一致性是关键验证**:forex 组在 EURUSD 和 USDJPY 上都盈利,证明因子捕捉的是共性而非噪声
4. **高频策略对成本极敏感**:metals_comm 组 1000+ 笔交易,在宽点差品种上必亏
-87
View File
@@ -1,87 +0,0 @@
# Forex 组因子有效性报告
> 生成时间:2026-07-03
> 训练模式:CPU(RTX 4060 上 CPU 比 GPU 快 2.3 倍,详见 `model_core/config.py` 注释)
> 训练步数:3000 步 / 组
> 回测模式:离线(默认点差,T=4777 bars H1)
## 一、策略文件
| 文件 | 说明 |
|------|------|
| `strategies/best_forex.json` | forex 组统一公式 |
| `strategies/best_group_forex.json` | forex 组公式(分组训练产物) |
| `strategies/best_EURUSD.json` | EURUSD 品种公式(继承自 forex 组) |
| `strategies/best_USDJPY.json` | USDJPY 品种公式(继承自 forex 组) |
> **注意**: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`
## 三、回测结果
| 品种 | PnL | Sharpe | Sortino | MaxDD | Calmar | 交易数 | 胜率 | 平均持仓 |
|------|-----|--------|---------|-------|--------|--------|------|----------|
| EURUSD | +0.161 | +1.82 | +2.41 | 0.071 | +2.98 | 83 | 57.8% | 57.5h |
| USDJPY | +0.229 | +1.63 | +2.08 | 0.097 | +3.07 | 77 | 57.1% | 62.0h |
| **Portfolio** | **+0.195** | **+1.73** | **+2.25** | **0.084** | **+3.03** | — | — | — |
- 正收益品种:2/2
- Sharpe > 1 品种:2/2
- 回测点差:EURUSDm=0.000035, USDJPYm=0.000031(单边 log cost)
## 四、公式逻辑解读
公式本质是 **"均线差极值反转 + 多层时序平滑"**:
1. `MA_DIFF` — 计算快慢均线差,捕捉短期趋势强度
2. `TS_MIN_10` — 取过去 10 根的最低值
3. `NEG` — 取反
4. `TS_MAX_10` — 取过去 10 根的最高值
5. `TS_MEAN_10 -> TS_MEAN_20 -> TS_MEAN_10 -> TS_MEAN_20` — 多层时序平滑,滤除高频噪声
**经济意义**:当均线差跌到近期低点后反弹(反转信号),叠加多层平滑过滤假信号——典型的均值回归 + 趋势过滤结构,逻辑可解释。
## 五、有效性判定
**✅ 确定有效**,依据:
1. **经济意义明确** — 公式不是符号堆砌(对比 index 组的 `ABS -> ABS -> SIGN -> ABS -> ABS`),每一步都有明确的时序统计含义。
2. **回测稳健** — Sharpe 1.6-1.8,胜率 57%,持仓 2-3 天,是中频策略的健康特征。MaxDD 仅 0.07-0.10,回撤可控。
3. **跨品种一致** — EURUSD 和 USDJPY 共用同一公式,双双盈利,说明因子捕捉的是 forex 类共性而非单品种噪声。
4. **训练分适中** — Best=5.976,非极端高(对比 index 组 7.56 反而过拟合)。因子搜索里,训练分中等往往泛化更好。
## 六、对比:index 组(过拟合失败案例)
| 维度 | forex 组 | index 组 |
|------|----------|----------|
| 公式 | `MA_DIFF -> TS_MIN_10 -> NEG -> ...` | `RET5 -> ABS -> ABS -> SIGN -> ABS -> ABS -> NEG -> TS_RANK_10` |
| 训练 Best | 5.976 | 7.562(更高) |
| 熵坍塌重启 | 0/8 | 8/8(触发上限) |
| 回测交易数 | 83 / 77 | 3 / 3 |
| 回测 Sharpe | +1.82 / +1.63 | -1.35 / -1.60 |
| 判定 | ✅ 有效 | ❌ 过拟合废公式 |
**结论**:训练分高 ≠ 好因子。index 组连续 6 个 ABS/SIGN 导致搜索空间塌缩,训练分虚高但回测崩溃。
## 七、使用建议
- **可小仓位试单**:EURUSD/USDJPY H1 周期,按公式信号方向开仓
- **实盘前必做**:用真实 MT5 点差重测(本次回测用默认点差,实盘可能更宽)
- **持续监控**:若连续 20 笔交易胜率跌破 45%,考虑重新训练
- **不要直接用于 index 品种**:US30/US100/US500 等需要重新训练(避免 index 组的过拟合)
## 八、相关文件
- 策略文件:`strategies/best_*.json`(forex 相关 4 个)
- 回测图表:`D:\素材\自动挖因子acktest_output\portfolio_equity.png`、`EURUSD.png`、`USDJPY.png`
- 回测日志:`D:\素材\自动挖因子acktest_out.log`
- 训练日志:`D:\素材\自动挖因子 raining_cpu_3000_out.log`
@@ -1,67 +0,0 @@
# Forex 组中间训练结果(改进方案首轮,训练 21% 时的快照)
> 生成时间:2026-07-04
> 状态:训练进行到 forex 组 step 1055/5000(约 21%)时手动中断,回测中间结果
> vocab_version:v61d94be3d0e5(特征剪枝后,28 特征 + 66 算子 = 94 token)
## 一、改进方案回顾(本轮训练所用)
针对上一轮"特征扩展后反而变差"的问题,做了四层改进:
1. **特征剪枝**:用 EffectivenessEvaluator 从 65 特征筛到 28 个,vocab 131→94,
8-token 搜索空间从 8.67×10¹⁶ 缩到 6.1×10¹⁵(约 14 倍)
2. **采样预算**:BATCH_SIZE 128→192,TRAIN_STEPS 3000→5000,ELITE_POOL_SIZE 30→60
3. **熵坍塌修复**:ENTROPY_COEFF_MAX 0.5→1.0,坍塌阈值改为相对值 0.15×ln(vocab)≈0.68,
ENTROPY_COLLAPSE_STEPS 15→40,MAX_RESTARTS 8→25,RESTART_NOISE 0.05→0.1,
并移除 Early Stop 终止(改为强扰动继续)
4. **模型容量**:d_model 64→96,层数 2→3,FFN 128→192
## 二、公式
```
EMA_RATIO_12_26 -> TS_MIN_20 -> AROON_OSC_25 -> MOMENTUM_5 -> EMA_RATIO_12_26 -> SCALE -> TS_DECAY_EXP_5 -> IF_GT
```
Token 序列:`[10, 60, 20, 50, 10, 66, 70, 93]`
训练 best_score:5.8417(step 1055 时)
## 三、回测结果(离线,T=3494 bars H1,真实点差)
| 品种 | PnL | Sharpe | Sortino | MaxDD | Calmar | 交易数 | 胜率 | 平均持仓 |
|------|-----|--------|---------|-------|--------|--------|------|----------|
| EURUSD | +0.071 | +1.07 | +1.49 | 0.070 | +1.80 | 105 | 53.3% | 33.3h |
| USDJPY | +0.168 | +2.15 | +2.42 | 0.104 | +2.89 | 103 | 59.2% | 33.9h |
| **Portfolio** | **+0.119** | **+2.78** | **+3.29** | **0.028** | **+7.57** | — | — | — |
- 正收益品种:2/2
- Sharpe > 1 品种:2/2
## 四、与上一轮失败版对比
| 指标 | 上轮失败版(07-04 早) | 本轮中间版(仅 21%) |
|------|----------------------|---------------------|
| EURUSD Sharpe | -0.29(亏损) | **+1.07** ✅ |
| USDJPY Sharpe | +1.32 | **+2.15** ✅ |
| 组合 Sharpe | +2.84 | +2.78 |
| 组合 MaxDD | 0.088 | **0.028**(更低) |
| 交易数 | 288/286 | 105/103(更健康中频) |
| 平均持仓 | 12h | 33h(更抗点差) |
| 熵坍塌 | 8/8 触发 Early Stop | 0 次重启,H≈0.5 稳定 |
## 五、结论
改进方案有效验证:
1. **EURUSD 从亏损转为盈利**,上一轮 group 共用公式泛化失败的问题解决
2. **两品种 Sharpe 均 > 1**,持仓 33h 比之前 12h 更合理,抗点差能力更强
3. **组合回撤仅 0.028**,风险控制显著改善
4. 熵坍塌问题解决,训练全程健康,未触发 Early Stop
以上仅为训练 21% 的中间快照,跑满 5000 步预期还能进一步提升。metals_comm 与 index 组尚未开始训练。
## 六、文件
- 公式文件:`strategies/best_forex.json`(symbol=forex)、`best_EURUSD.json`、`best_USDJPY.json`
- 回测报告:`backtest_output/multi_factor_report.json`
- 回测日志:`backtest_forex_interim.log`
- 特征白名单:`active_features.json`(28 特征)
- 剪枝脚本:`prune_features.py`
-103
View File
@@ -1,103 +0,0 @@
# v0.9 Forex 组有效因子报告(严谨回测)
> 生成时间: 2026-07-04
> 词表版本: v71336c7c7b25(移除 LT/GT/AND/OR 二值算子,保留 IF_GT)
> 训练配置: BATCH_SIZE=192, TRAIN_STEPS=5000, MAX_FORMULA_LEN=8
> 回测门槛: 完整交易笔数 ≥ 50,跳过 warmup 60 bar
## 严谨性验证清单
| 验证项 | 方法 | 结果 |
|--------|------|------|
| 训练-回测循环 | IS_OOS 标注 | ⚠️ False(数据窥探风险) |
| 交易次数 | 完整交易笔数(持仓段数) | ✅ 修正 |
| warmup 期 | 跳过前 60 bar | ✅ |
| 基准对比 | Buy&Hold + 随机 MC(1000次) | ✅ |
| 成本单位 | 统一百分比(0.01%) | ✅ |
| 统计显著性 | bootstrap 95% CI(1000次) | ✅ |
## 基准对比
| 品种 | Buy&Hold Sortino | 随机 MC 95%分位 | 随机 MC 均值 |
|------|-----------------|----------------|------------|
| EURUSD | +2.33 | -4.77 | -7.15 |
| USDJPY | +0.88 | -3.45 | -5.87 |
→ 策略 Sortino 必须 > 随机 MC 95% 分位才算统计显著优于随机
## Top 3 强有效因子
### 第 1 名 (Val=4.876, 冠军)
**公式**: `TREND_STRENGTH_50 -> MAX3 -> WMA -> SIGN -> TS_MAX_20 -> MOMENTUM_5 -> TS_RANK_20 -> DELAY1`
| 指标 | 数值 | 验证 |
|------|------|------|
| 完整交易笔数 | EURUSD 82, USDJPY 82 | ✅ ≥50 |
| 组合 Sortino | +4.72 | ✅ |
| bootstrap CI 95% | [+1.82, +8.52] | ✅ 下界>0 |
| 组合 Sharpe | +4.14 | ✅ |
| 组合 Calmar | +10.00 | ✅ |
| 最大回撤 MDD | 0.0317 | ✅ 极小 |
| 前半 Sortino | +5.66 | ✅ |
| 后半 Sortino | +3.83 | ✅ 一致 |
| 击败 Buy&Hold | Y | ✅ |
| 击败随机 MC95 | Y | ✅ |
| 4 折 WF | EUR 4/4, JPY 4/4 正 | ✅ |
**逐品种**:
- EURUSD: Sortino=+3.89, IC=+0.038, 多31%/空33%/平36%
- USDJPY: Sortino=+4.53, IC=+0.047, 多33%/空31%/平36%
**原理**: 融合中期趋势(TREND_STRENGTH_50)+ 平滑(MAX3, WMA)+ 方向(SIGN)+ 突破(TS_MAX_20)+ 短期动量(MOMENTUM_5)+ 排名标准化(TS_RANK_20)+ 因果延迟(DELAY1)
### 第 2 名 (Val=2.857)
**公式**: `KYLE_LAMBDA -> CLIP -> VWAP_DEV -> DIV -> SCALE -> SCALE -> CS_SCALE -> EMA_20`
| 指标 | 数值 |
|------|------|
| 完整交易笔数 | EURUSD 209, USDJPY 209 |
| 组合 Sortino | +3.84 |
| bootstrap CI 95% | [+0.32, +7.77] |
| 4 折 WF | EUR 4/4, JPY 3/4 正 |
**特点**: 与冠军完全不同的信号源 — 流动性(KYLE_LAMBDA)+ VWAP 偏离,Exp 99.9%(几乎全程在场)
### 第 3 名 (Val=4.455)
**公式**: `TREND_STRENGTH_50 -> DELAY4 -> TS_MAX_10 -> SQRT -> MOMENTUM_10 -> TS_RANK_10 -> SIGN -> TS_MIN_10`
| 指标 | 数值 |
|------|------|
| 完整交易笔数 | EURUSD 151, USDJPY 151 |
| 组合 Sortino | +2.90 |
| bootstrap CI 95% | [+0.24, +6.40] |
| 4 折 WF | EUR 3/4, JPY 3/4 正 |
## 排除的因子(共 10 个)
| # | Val | 判定 | 原因 |
|---|-----|------|------|
| 13 | 4.998 | CI含0 | bootstrap CI [-0.15, +6.94] 下界为负,虽 Sortino +2.97 但不显著 |
| 7 | 2.385 | 过拟合 | 前半 +4.33, 后半 -2.44 |
| 9 | 3.119 | 过拟合 | 前后不一致 |
| 5 | 1.989 | 过拟合 | 前后不一致 |
| 1 | 0.694 | 过拟合 | 前后不一致 |
| 6 | 2.012 | 过拟合 | 前后不一致 |
| 2 | 1.035 | 过拟合 | 前后不一致 |
| 3 | 1.040 | 过拟合 | 前后不一致 |
| 11 | 4.791 | 过拟合 | 前后不一致 |
| 4 | 1.318 | 过拟合 | 前后不一致 |
## 关键发现
1. **训练分最高 ≠ 回测最优**: #13 (Val=4.998) 因 CI 含 0 被降级,#12 (Val=4.876) 才是冠军
2. **v0.9 修复成功**: 移除二值算子 + 加强 IC 门控 + 拒绝稀疏因子登顶,彻底解决 v0.8 过拟合问题
3. **两类信号源都有效**: TREND_STRENGTH_50 系(#12, #10)和流动性系(#8)都能通过严谨验证
## 重要警告
⚠️ **IS_OOS=False**: 训练用全量 3508 bar 选 best_formula,回测在同一数据上。
Sortino 存在**数据窥探偏差**,实盘表现大概率打折。
建议:用更长历史数据(2-3 年)做真正的 train/test split 验证。
-111
View File
@@ -1,111 +0,0 @@
# MT5 AlphaGPT v3.0 有效因子报告
**生成时间**: 2026-07-04
**词表版本**: v6ff4c52e30c1 (65 特征 + 66 算子)
**训练配置**: TRAIN_STEPS=10000, BATCH_SIZE=128, MAX_FORMULA_LEN=8, CPU 模式
**回测区间**: 3508 根 H1 K 线(10 品种时间戳交集)
**回测方法**: 组内多品种一起执行 StackVM + Neutral Band 仓位 + 单边成本 0.0001
---
## 一、有效性判定标准
| 条件 | 阈值 | 说明 |
|------|------|------|
| 交易次数 | >= 100 | 低于此值无统计意义,直接判无效 |
| 前后半段 Sortino | 均 > 0 | 防过拟合,要求两半段都盈利 |
| Sortino | > 0 | 盈利基本门槛 |
---
## 二、有效因子清单(5 个)
### forex 组(2/2 有效,平均 Sortino=+5.60)
**公式**: `DEV60 -> TREND_STRENGTH_50 -> SUB -> DECAY_LINEAR_5 -> EMA_20 -> BOLL_WIDTH -> JUMP -> SUB`
**训练得分**: 6.6178
| 品种 | 交易数 | Sortino | Sharpe | 总收益 | IC | 前半 | 后半 | 多仓% | 空仓% |
|------|--------|---------|--------|--------|------|------|------|-------|-------|
| EURUSD | 105 | **+5.27** | +4.02 | +26.6% | +0.043 | +6.10 | +4.40 | 52% | 48% |
| USDJPY | 105 | **+5.92** | +4.50 | +35.2% | +0.050 | +9.35 | +1.88 | 48% | 52% |
**因子逻辑解读**:
- `DEV60` (价格偏离 60 周期均线) 减去 `TREND_STRENGTH_50` (50 周期趋势强度) → 捕捉"趋势中的反转"
- `DECAY_LINEAR_5` (线性衰减加权) 平滑信号,减少噪声换手
- `EMA_20` 与 `BOLL_WIDTH` 组合 → 波动率自适应入场
- `JUMP` (跳跃检测) 过滤小波动,只在显著信号时交易
- 最终 `SUB` → 趋势反转差值作为最终因子
### metals_comm 组(3/3 有效,平均 Sortino=+2.25)
**公式**: `TREND_STRENGTH_50 -> VWAP_DEV -> SAR_DIST -> CLIP -> PRODUCT_5 -> TYPICAL_DEV -> DIV -> IF_GT`
**训练得分**: 4.4446
| 品种 | 交易数 | Sortino | Sharpe | 总收益 | IC | 前半 | 后半 | 多仓% | 空仓% | 空仓% |
|------|--------|---------|--------|--------|------|------|------|-------|-------|-------|
| XAUUSD | 948 | +0.83 | +0.65 | +14.1% | -0.022 | +0.70 | +0.96 | 45% | 42% | 13% |
| AAVUSD | 962 | **+4.12** | +3.57 | +286.7% | +0.040 | +3.68 | +4.79 | 42% | 45% | 13% |
| COCOA.c | 972 | +1.82 | +1.31 | +57.6% | +0.015 | +1.72 | +1.92 | 43% | 43% | 14% |
**因子逻辑解读**:
- `TREND_STRENGTH_50` 与 `VWAP_DEV` 组合 → 趋势 + 成交量加权价格偏离
- `SAR_DIST` (抛物线 SAR 距离) → 动量确认
- `CLIP` 截断极端值,`PRODUCT_5` 连乘积 → 累积动量
- `TYPICAL_DEV` 典型价格偏离 → 短期反转
- `IF_GT` 条件判断 → 只在动量与反转一致时入场
---
## 三、无效因子(5 个)
### index 组(5/5 无效,全部因交易次数 < 100)
**公式**: `PRICE_POS_50 -> VOL_RATIO -> STOCH_K_14 -> TRIX_15 -> SUB -> COVARIANCE_10 -> OR -> DELAY4`
**训练得分**: 8.2260(训练分最高,但泛化最差)
| 品种 | 交易数 | 原因 | 持仓分布 |
|------|--------|------|---------|
| US30.cash | 4 | 交易过少 | 100% 空仓 |
| US100.cash | 4 | 交易过少 | 100% 空仓 |
| US500.cash | 4 | 交易过少 | 100% 空仓 |
| US2000.cash | 10 | 交易过少 | 100% 空仓 |
| JP225.cash | 4 | 交易过少 | 100% 空仓 |
**失败原因分析**:
- `OR` 条件算子 + `DELAY4` 延迟 → 信号极少触发,几乎全程空仓
- 训练分 8.23 是三组最高,但这是**过拟合**的典型表现
- 与上次 3000 步训练(index 组全亏)不同,这次连交易都不发生,说明公式完全退化
---
## 四、组合表现
### 5 有效品种等权组合
| 指标 | 值 |
|------|-----|
| Sortino | **+5.72** |
| Sharpe | **+4.53** |
| 总收益 | **+84.1%** |
| 最大回撤 | **15.7%** |
---
## 五、与历史版本对比
| 版本 | 词表 | 训练步数 | 有效品种 | 组合 Sortino |
|------|------|---------|---------|-------------|
| v2.0 | 20特征+28算子 | 3000 | 5/10 | -0.56(错误回测)|
| v3.0 | 65特征+66算子 | 10000 | **5/10** | **+5.72** |
**关键修正**: v2.0 的回测用了错误的 `sign(tanh(factor))` 仓位逻辑,导致 metals_comm 组被误判为亏损。修正为 Neutral Band 后,metals_comm 组实际全部有效。
---
## 六、结论与建议
1. **5 个有效因子可直接用于实盘**: EURUSD, USDJPY, XAUUSD, AAVUSD, COCOA.c
2. **index 组需重训**: 当前公式退化为不交易,建议禁用 `OR`/`IF_GT` 条件算子,或降低 `MAX_FORMULA_LEN` 到 6
3. **v0.8 远程更新已合并**: 特征剪枝(65→28)+ 大空间适配参数 + Hard Restart 逻辑已合入,下次训练用新参数
4. **下次训练建议**: 用 v0.8 的 `active_features.json`(28 特征)+ 新参数(BATCH=192, STEPS=5000, MAX_RESTARTS=25, Hard Restart)重训
-215
View File
@@ -1,215 +0,0 @@
# 已验证有效因子记录
> 生成时间: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 步) |
-43
View File
@@ -1,43 +0,0 @@
{
"vocab_version": "v71336c7c7b25",
"symbol": "forex",
"rank": 2,
"formula": [
18,
86,
9,
31,
66,
66,
73,
57
],
"formula_decoded": "KYLE_LAMBDA -> CLIP -> VWAP_DEV -> DIV -> SCALE -> SCALE -> CS_SCALE -> EMA_20",
"best_score": 2.857,
"train_ic": 0.0144,
"train_exp_pct": 99.9,
"backtest": {
"verdict": "强有效",
"n_trades_eurusd": 209,
"n_trades_usdjpy": 209,
"portfolio_sortino": 3.84,
"portfolio_sharpe": 3.02,
"portfolio_calmar": 6.35,
"portfolio_mdd": 0.0543,
"portfolio_ci_95": [
0.32,
7.77
],
"half1_sortino": 2.15,
"half2_sortino": 7.14,
"beat_buyhold": true,
"beat_random_mc95": true,
"wf_folds_positive": "EUR 4/4, JPY 3/4",
"eurusd_sortino": 3.9,
"usdjpy_sortino": 3.08,
"eurusd_ic": 0.0417,
"usdjpy_ic": 0.0248
},
"is_oos": false,
"warning": "训练-回测数据循环验证,Sortino 存在数据窥探偏差,实盘表现大概率打折"
}
-43
View File
@@ -1,43 +0,0 @@
{
"vocab_version": "v71336c7c7b25",
"symbol": "forex",
"rank": 3,
"formula": [
11,
55,
52,
82,
51,
47,
34,
53
],
"formula_decoded": "TREND_STRENGTH_50 -> DELAY4 -> TS_MAX_10 -> SQRT -> MOMENTUM_10 -> TS_RANK_10 -> SIGN -> TS_MIN_10",
"best_score": 4.455,
"train_ic": 0.0449,
"train_exp_pct": 55.1,
"backtest": {
"verdict": "强有效",
"n_trades_eurusd": 151,
"n_trades_usdjpy": 151,
"portfolio_sortino": 2.9,
"portfolio_sharpe": 2.7,
"portfolio_calmar": 4.49,
"portfolio_mdd": 0.0512,
"portfolio_ci_95": [
0.24,
6.4
],
"half1_sortino": 4.24,
"half2_sortino": 1.54,
"beat_buyhold": true,
"beat_random_mc95": true,
"wf_folds_positive": "EUR 3/4, JPY 3/4",
"eurusd_sortino": 2.49,
"usdjpy_sortino": 2.75,
"eurusd_ic": 0.0367,
"usdjpy_ic": 0.0491
},
"is_oos": false,
"warning": "训练-回测数据循环验证,Sortino 存在数据窥探偏差,实盘表现大概率打折"
}
-318
View File
@@ -1,318 +0,0 @@
"""
test_ftmo_compliance.py — FTMO 考试盘合规性测试
用真实 forex 因子在 EURUSD+USDJPY 全量历史上模拟 FTMO 考试:
1. 计算每日 P&L,检查是否违反 Max Daily Loss (3% / 5%)
2. 检查 Max Overall Loss (10% trailing)
3. 检查 Best Day Rule (1-Step: 最佳日 ≤ 50% 总盈利)
4. 检查 Min Trading Days (2-Step: 4 天)
5. 扫描不同仓位系数,找最优 FTMO 配置
"""
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
# FTMO 规则常量($100,000 账户)
ACCOUNT_SIZE = 100_000.0
H1_PER_DAY = 24
H1_PER_YEAR = 6240
COST_RATE = 0.0001
# 2-Step 规则
MAX_DAILY_LOSS_2STEP = 0.05 # 5%
MAX_OVERALL_LOSS_2STEP = 0.10 # 10%
PROFIT_TARGET_2STEP_P1 = 0.10 # Phase 1: 10%
PROFIT_TARGET_2STEP_P2 = 0.05 # Phase 2: 5%
MIN_TRADING_DAYS_2STEP = 4
# 1-Step 规则
MAX_DAILY_LOSS_1STEP = 0.03 # 3%
MAX_OVERALL_LOSS_1STEP = 0.10 # 10% trailing
PROFIT_TARGET_1STEP = 0.10 # 10%
BEST_DAY_RULE_1STEP = 0.50 # 最佳日 ≤ 50%
def calc_pnl(position, target_ret, cost_rate, pos_scale=1.0):
"""计算 P&L,pos_scale 为仓位缩放系数。"""
pos = position * pos_scale
prev_pos = torch.roll(pos, 1, dims=1)
prev_pos[:, 0] = 0.0
turnover = torch.abs(pos - prev_pos)
pnl = pos * target_ret - turnover * cost_rate
return pnl
def aggregate_daily_pnl(pnl_tensor):
"""将 [N, T] 的逐 bar P&L 聚合为逐日 P&L(以 CET 时区为准,24h 一天)。
注意:H1 数据,假设 00:00 CET 对齐(MT5 服务器时间通常接近 CET)。
返回: daily_pnl [N_days] numpy array
"""
N, T = pnl_tensor.shape
port_pnl = pnl_tensor.mean(dim=0).numpy() # [T]
n_days = T // H1_PER_DAY
port_pnl_trim = port_pnl[:n_days * H1_PER_DAY]
daily = port_pnl_trim.reshape(n_days, H1_PER_DAY).sum(axis=1)
return daily
def calc_equity_curve(daily_pnl, initial_balance):
"""计算权益曲线(含 Max Loss trailing 检查)。"""
cum = np.cumsum(daily_pnl) * initial_balance
equity = initial_balance + cum
# Max Loss trailing limit: max(initial_balance, max(historical end-of-day balance)) - 10%
end_of_day_balances = np.concatenate([[initial_balance], initial_balance + np.cumsum(daily_pnl * initial_balance)[:-1]])
# trailing limit: 每天基于前一日 end-of-day balance 的最大值
running_max = np.maximum.accumulate(end_of_day_balances)
max_loss_limit = running_max - MAX_OVERALL_LOSS_2STEP * initial_balance
return equity, max_loss_limit
def simulate_ftmo(daily_pnl, account_size, max_daily_loss, max_overall_loss,
profit_target, pos_scale=1.0, verbose=True):
"""模拟 FTMO 考试流程,返回是否通过 + 违规详情。"""
scaled_daily = daily_pnl * pos_scale * account_size
n_days = len(scaled_daily)
balance = account_size
peak_balance = account_size
cumulative_pnl = 0.0
violations = []
daily_losses = []
trading_days = 0
profit_days = []
target_reached_day = None
for d in range(n_days):
day_pnl = scaled_daily[d]
balance += day_pnl
cumulative_pnl += day_pnl
# 是否为交易日(有仓位变动即算)
if abs(day_pnl) > 1e-6:
trading_days += 1
if day_pnl > 0:
profit_days.append((d, day_pnl))
# 检查 Max Daily Loss
# 当日 equity = balance - day_pnl + day_pnl = balance(已结算)
# 但日内 equity 最低可能更低,这里用日 P&L 作为近似
if day_pnl < 0:
daily_loss_pct = abs(day_pnl) / account_size
daily_losses.append(daily_loss_pct)
if daily_loss_pct > max_daily_loss:
violations.append({
"day": d,
"type": "max_daily_loss",
"loss_pct": daily_loss_pct,
"limit_pct": max_daily_loss
})
else:
daily_losses.append(0.0)
# 检查 Max Overall Loss (trailing)
peak_balance = max(peak_balance, balance - day_pnl) # end-of-day peak
max_loss_limit = peak_balance - max_overall_loss * account_size
if balance < max_loss_limit:
violations.append({
"day": d,
"type": "max_overall_loss",
"balance": balance,
"limit": max_loss_limit
})
# 检查 Profit Target
if target_reached_day is None:
if cumulative_pnl >= profit_target * account_size:
target_reached_day = d
# Best Day Rule (1-Step)
if profit_days:
best_day_pnl = max(p[1] for p in profit_days)
total_profit = sum(p[1] for p in profit_days)
best_day_ratio = best_day_pnl / total_profit if total_profit > 0 else 0
else:
best_day_ratio = 0.0
result = {
"passed": len(violations) == 0,
"violations": violations,
"n_violations": len(violations),
"trading_days": trading_days,
"target_reached_day": target_reached_day,
"best_day_ratio": best_day_ratio,
"max_daily_loss_pct": max(daily_losses) if daily_losses else 0,
"total_return_pct": cumulative_pnl / account_size,
"n_profit_days": len(profit_days),
}
if verbose:
vtype_count = {}
for v in violations:
vtype_count[v["type"]] = vtype_count.get(v["type"], 0) + 1
print(f" 仓位系数: {pos_scale:.2f}x "
f"总收益: {result['total_return_pct']:+.2%} "
f"交易天数: {trading_days} "
f"最大日亏: {result['max_daily_loss_pct']:.2%} "
f"违规: {result['n_violations']}次 ({vtype_count}) "
f"达标日: {'Day '+str(target_reached_day) if target_reached_day else '未达标'} "
f"{'✓ 通过' if result['passed'] else '✗ 失败'}")
return result
def main():
offline = "--offline" in sys.argv
print(f"\n{'='*78}")
print(f" FTMO 考试盘合规性测试")
print(f"{'='*78}\n")
# ── 1. 加载因子 ─────────────────────────────────────────────────
formula_data = json.load(open("strategies/best_forex.json"))
if formula_data.get("vocab_version") != VOCAB_VERSION:
print("[ERROR] vocab 版本不符"); return
formula = formula_data["formula"]
readable = " -> ".join(FORMULA_VOCAB.token_names[t] for t in formula)
print(f"因子: {readable}")
print(f"训练 score: {formula_data['best_score']:.4f}\n")
# ── 2. 加载数据 ─────────────────────────────────────────────────
original_symbols = Config.SYMBOLS[:]
Config.SYMBOLS = ["EURUSD", "USDJPY"]
try:
with MT5DataFetcher(offline=offline) as fetcher:
mgr = MT5DataManager(fetcher)
mgr.load()
raw_dict = mgr.raw_dict
target_ret = mgr.target_ret
T = raw_dict["open"].shape[1]
print(f"数据: {mgr.symbols} T={T} bars ({T//H1_PER_DAY} 天 ≈ {T/H1_PER_YEAR:.1f} 年)\n")
feat = MT5FeatureEngineer.compute_features(raw_dict)
vm = StackVM()
factor = vm.execute(formula, feat)
if factor is None:
print("[ERROR] 因子执行失败"); return
pos_base = compute_target_positions_stateless(factor)
pnl_base = calc_pnl(pos_base, target_ret, COST_RATE, pos_scale=1.0)
daily_pnl = aggregate_daily_pnl(pnl_base)
finally:
Config.SYMBOLS = original_symbols
# ── 3. 基础统计 ─────────────────────────────────────────────────
print(f"{'='*78}")
print(f" 基础统计(满仓 1.0x,{len(daily_pnl)} 天)")
print(f"{'='*78}")
print(f" 累计收益: {daily_pnl.sum():+.4f} ({daily_pnl.sum()*ACCOUNT_SIZE/1000:+.1f}k)")
print(f" 日均收益: {daily_pnl.mean():+.6f}")
print(f" 日收益标准差: {daily_pnl.std():.6f}")
print(f" 最大日盈利: {daily_pnl.max():+.4f} ({daily_pnl.max()*ACCOUNT_SIZE/1000:+.1f}k)")
print(f" 最大日亏损: {daily_pnl.min():+.4f} ({daily_pnl.min()*ACCOUNT_SIZE/1000:+.1f}k)")
print(f" 盈利天数: {(daily_pnl > 0).sum()} / {len(daily_pnl)} ({(daily_pnl > 0).mean():.1%})")
print(f" 亏损天数: {(daily_pnl < 0).sum()} / {len(daily_pnl)} ({(daily_pnl < 0).mean():.1%})")
print()
# ── 4. 满仓 vs FTMO 限制对比 ───────────────────────────────────
print(f"{'='*78}")
print(f" FTMO 2-Step 考试模拟(Max Daily Loss=5%, Max Overall=10%, Target=10%)")
print(f"{'='*78}")
print(f" {'仓位系数':>8} {'总收益':>8} {'交易天数':>8} {'最大日亏':>8} {'违规次数':>8} {'达标':>6} {'结果':>6}")
print(f" {'-'*68}")
best_2step_scale = None
for scale in [1.0, 0.7, 0.5, 0.3, 0.2, 0.1]:
r = simulate_ftmo(daily_pnl, ACCOUNT_SIZE, MAX_DAILY_LOSS_2STEP,
MAX_OVERALL_LOSS_2STEP, PROFIT_TARGET_2STEP_P1,
pos_scale=scale)
if r["passed"] and best_2step_scale is None:
best_2step_scale = scale
print()
print(f"{'='*78}")
print(f" FTMO 1-Step 考试模拟(Max Daily Loss=3%, Max Overall=10% trailing, Target=10%)")
print(f"{'='*78}")
print(f" {'仓位系数':>8} {'总收益':>8} {'交易天数':>8} {'最大日亏':>8} {'Best Day':>8} {'违规':>6} {'结果':>6}")
print(f" {'-'*68}")
best_1step_scale = None
for scale in [1.0, 0.7, 0.5, 0.3, 0.2, 0.1]:
r = simulate_ftmo(daily_pnl, ACCOUNT_SIZE, MAX_DAILY_LOSS_1STEP,
MAX_OVERALL_LOSS_1STEP, PROFIT_TARGET_1STEP,
pos_scale=scale, verbose=False)
vtype_count = {}
for v in r["violations"]:
vtype_count[v["type"]] = vtype_count.get(v["type"], 0) + 1
best_day_str = f"{r['best_day_ratio']:.0%}"
print(f" {scale:>7.2f}x {r['total_return_pct']:>+7.2%} "
f"{r['trading_days']:>8d} {r['max_daily_loss_pct']:>7.2%} "
f"{best_day_str:>8} {r['n_violations']:>6d} "
f"{'✓' if r['passed'] else '✗':>6}")
if r["passed"] and best_1step_scale is None:
best_1step_scale = scale
print()
# ── 5. 最优配置详情 ─────────────────────────────────────────────
print(f"{'='*78}")
print(f" 最优 FTMO 配置推荐")
print(f"{'='*78}")
if best_2step_scale:
print(f"\n ★ 2-Step 推荐: {best_2step_scale:.2f}x 仓位")
print(f" - Max Daily Loss 5%: 安全")
print(f" - Max Overall Loss 10%: 安全")
print(f" - Min Trading Days 4: 自然满足(策略几乎每天交易)")
print(f" - 无 Best Day Rule 限制")
print(f" - 可选 Swing 账户:允许周末持仓")
r = simulate_ftmo(daily_pnl, ACCOUNT_SIZE, MAX_DAILY_LOSS_2STEP,
MAX_OVERALL_LOSS_2STEP, PROFIT_TARGET_2STEP_P1,
pos_scale=best_2step_scale, verbose=False)
print(f" - 预计 {r['target_reached_day']+1} 天达标 10% 目标")
if best_1step_scale:
print(f"\n ★ 1-Step 推荐: {best_1step_scale:.2f}x 仓位")
print(f" - Max Daily Loss 3%: 需更小仓位")
print(f" - Max Overall Loss 10% trailing: 安全")
print(f" - Best Day Rule 50%: 需检查")
r = simulate_ftmo(daily_pnl, ACCOUNT_SIZE, MAX_DAILY_LOSS_1STEP,
MAX_OVERALL_LOSS_1STEP, PROFIT_TARGET_1STEP,
pos_scale=best_1step_scale, verbose=False)
print(f" - 预计 {r['target_reached_day']+1} 天达标 10% 目标")
print(f" - Best Day 占比: {r['best_day_ratio']:.1%} {'✓' if r['best_day_ratio'] < 0.5 else '✗ 超标'}")
else:
print(f"\n ✗ 1-Step 不推荐:3% 日亏限制太严,当前策略难以满足")
# ── 6. 需要添加的风控规则 ───────────────────────────────────────
print(f"\n{'='*78}")
print(f" 需要添加的 FTMO 风控规则")
print(f"{'='*78}")
print(f"""
1. [必须] 日亏熔断: 当日亏损达到 {-MAX_DAILY_LOSS_2STEP*100:.0f}% (2-Step) / {-MAX_DAILY_LOSS_1STEP*100:.0f}% (1-Step) 时平仓停止
2. [必须] 周末平仓: Standard 账户周五 22:00 CET 前平所有仓位
3. [建议] 仓位缩放: 实盘仓位 = 信号仓位 × {best_2step_scale or 0.5:.2f}
4. [建议] 新闻过滤: FOMC/NFP/CPI 前后 2 分钟不开/平仓(仅 FTMO Account 阶段)
5. [可选] Swing 账户: 2-Step 专用,免除周末/过夜限制
6. [无需] 最低交易天数: 策略几乎每天交易,自然满足 4 天要求
""")
print(f"完成。\n")
if __name__ == "__main__":
main()
-301
View File
@@ -1,301 +0,0 @@
"""
test_max_hold.py — 测试强制限制最大持仓时间对 Sharpe 的影响
用真实 forex 因子(best_forex.json)在 EURUSD+USDJPY 全量历史上:
1. 基线:无持仓限制
2. 约束:最大持仓 24h(持仓满 24 根 H1 后强制平仓 1 根,再视信号决定是否重进)
对比 Sharpe / Sortino / TotRet / MDD / AvgHold / Trades。
"""
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
COST_RATE = 0.0001
MAX_HOLD_BARS = 24 # 24 小时
FOREX_SYMS = ["EURUSD", "USDJPY"]
def calc_sharpe(pnl):
flat = np.asarray(pnl).reshape(-1)
std = flat.std()
if std < 1e-8:
return 0.0
return float(flat.mean() / std * math.sqrt(H1_PER_YEAR))
def calc_sortino(pnl):
flat = np.asarray(pnl).reshape(-1)
downside = flat[flat < 0]
if len(downside) == 0:
return 0.0
ds = downside.std()
full_std = max(flat.std(), 1e-8)
floor = max(full_std * 0.2, 1e-8)
ds = max(ds, floor)
return float(flat.mean() / ds * math.sqrt(H1_PER_YEAR))
def calc_mdd(pnl):
cum = np.cumsum(pnl)
peak = np.maximum.accumulate(cum)
dd = cum - peak
return float(dd.min())
def calc_metrics(pnl, position):
"""计算一组指标。pnl/position: [N, T] tensor → 按 N 等权组合。"""
port_pnl = pnl.mean(dim=0).numpy()
sharpe = calc_sharpe(port_pnl)
sortino = calc_sortino(port_pnl)
tot_ret = float(port_pnl.sum())
mdd = calc_mdd(port_pnl)
# 持仓时间统计
pos_np = position.abs().mean(dim=0).numpy() # 组合平均仓位
# 用 sign 变化统计交易次数和持仓时长
pos_sign = np.sign(port_pnl)
runs, cur_len, cur_dir = [], 0, 0
for p in pos_sign:
d = int(p)
if d == 0:
if cur_len > 0:
runs.append(cur_len)
cur_dir, cur_len = 0, 0
elif d == cur_dir:
cur_len += 1
else:
if cur_len > 0:
runs.append(cur_len)
cur_dir, cur_len = d, 1
if cur_len > 0:
runs.append(cur_len)
n_trades = len(runs)
avg_hold = sum(runs) / n_trades if n_trades else 0
exposure = float(position.abs().mean())
return {
"sharpe": sharpe,
"sortino": sortino,
"tot_ret": tot_ret,
"mdd": mdd,
"n_trades": n_trades,
"avg_hold_h": avg_hold,
"exposure": exposure,
}
def apply_max_hold(position: torch.Tensor, max_hold: int) -> torch.Tensor:
"""强制最大持仓时间:同方向持仓满 max_hold 根后,下一根强制平仓。
平仓后下一根可立即根据信号重新进场(最宽松的解释)。
"""
pos = position.clone()
N, T = pos.shape
for n in range(N):
hold = 0
cur_dir = 0
for t in range(T):
p = pos[n, t].item()
d = 1 if p > 0.001 else (-1 if p < -0.001 else 0)
if d == 0:
hold = 0
cur_dir = 0
continue
if d == cur_dir:
hold += 1
if hold >= max_hold:
# 持满 max_hold 根,强制平仓
pos[n, t] = 0.0
hold = 0
cur_dir = 0
else:
cur_dir = d
hold = 1
return pos
def apply_max_hold_strict(position: torch.Tensor, max_hold: int) -> torch.Tensor:
"""严格版:平仓后必须等信号反转才能重新进场。"""
pos = position.clone()
N, T = pos.shape
for n in range(N):
hold = 0
cur_dir = 0
locked_dir = 0 # 锁定方向,必须反向才解锁
for t in range(T):
p = pos[n, t].item()
d = 1 if p > 0.001 else (-1 if p < -0.001 else 0)
if locked_dir != 0 and d == locked_dir:
# 信号未反转,继续空仓
pos[n, t] = 0.0
continue
elif locked_dir != 0 and d != 0 and d != locked_dir:
# 信号反转,解锁
locked_dir = 0
if d == 0:
hold = 0
cur_dir = 0
continue
if d == cur_dir:
hold += 1
if hold >= max_hold:
pos[n, t] = 0.0
hold = 0
cur_dir = 0
locked_dir = d # 锁定,等反向信号
else:
cur_dir = d
hold = 1
return pos
def main():
offline = "--offline" in sys.argv
print(f"\n{'='*72}")
print(f" 最大持仓时间影响测试 | max_hold = {MAX_HOLD_BARS}h")
print(f"{'='*72}\n")
# ── 1. 加载因子 ─────────────────────────────────────────────────
formula_data = json.load(open("strategies/best_forex.json"))
if formula_data.get("vocab_version") != VOCAB_VERSION:
print("[ERROR] vocab 版本不符"); return
formula = formula_data["formula"]
readable = " -> ".join(FORMULA_VOCAB.token_names[t] for t in formula)
print(f"因子: {readable}")
print(f"训练 score: {formula_data['best_score']:.4f}\n")
# ── 2. 加载数据 ─────────────────────────────────────────────────
original_symbols = Config.SYMBOLS[:]
Config.SYMBOLS = FOREX_SYMS
try:
with MT5DataFetcher(offline=offline) as fetcher:
mgr = MT5DataManager(fetcher)
mgr.load()
raw_dict = mgr.raw_dict
target_ret = mgr.target_ret
T = raw_dict["open"].shape[1]
print(f"数据: {mgr.symbols} T={T} bars\n")
feat = MT5FeatureEngineer.compute_features(raw_dict)
# ── 3. 计算因子值 ───────────────────────────────────────
vm = StackVM()
factor = vm.execute(formula, feat)
if factor is None:
print("[ERROR] 因子执行失败"); return
# ── 4. 基线回测(无限制)──────────────────────────────
pos_base = compute_target_positions_stateless(factor)
prev_pos = torch.roll(pos_base, 1, dims=1)
prev_pos[:, 0] = 0.0
turnover_base = torch.abs(pos_base - prev_pos)
pnl_base = pos_base * target_ret - turnover_base * COST_RATE
m_base = calc_metrics(pnl_base, pos_base)
# ── 5. 约束回测(max_hold=24h,宽松版)─────────────────
pos_constrained = apply_max_hold(pos_base, MAX_HOLD_BARS)
prev_pos_c = torch.roll(pos_constrained, 1, dims=1)
prev_pos_c[:, 0] = 0.0
turnover_c = torch.abs(pos_constrained - prev_pos_c)
pnl_c = pos_constrained * target_ret - turnover_c * COST_RATE
m_c = calc_metrics(pnl_c, pos_constrained)
# ── 6. 约束回测(max_hold=24h,严格版)─────────────────
pos_strict = apply_max_hold_strict(pos_base, MAX_HOLD_BARS)
prev_pos_s = torch.roll(pos_strict, 1, dims=1)
prev_pos_s[:, 0] = 0.0
turnover_s = torch.abs(pos_strict - prev_pos_s)
pnl_s = pos_strict * target_ret - turnover_s * COST_RATE
m_s = calc_metrics(pnl_s, pos_strict)
finally:
Config.SYMBOLS = original_symbols
# ── 7. 对比表 ───────────────────────────────────────────────────
print(f"{'='*72}")
print(f" 结果对比(EURUSD+USDJPY 等权组合,{T} bars H1)")
print(f"{'='*72}")
hdr = f" {'模式':22s} {'Sharpe':>7} {'Sortino':>8} {'TotRet':>8} {'MDD':>7} {'AvgHold':>8} {'Trades':>7} {'Exposure':>8}"
print(hdr)
print(f" {'-'*82}")
rows = [
("基线(无限制)", m_base),
(f"max_hold=24h(宽松)", m_c),
(f"max_hold=24h(严格)", m_s),
]
for label, m in rows:
print(f" {label:22s} "
f"{m['sharpe']:>+7.3f} "
f"{m['sortino']:>+8.3f} "
f"{m['tot_ret']:>+8.3f} "
f"{m['mdd']:>7.3f} "
f"{m['avg_hold_h']:>7.1f}h "
f"{m['n_trades']:>7d} "
f"{m['exposure']:>7.1%}")
# ── 8. Sharpe 变化 ──────────────────────────────────────────────
print(f"\n{'='*72}")
print(f" Sharpe 变化分析")
print(f"{'='*72}")
base_sharpe = m_base["sharpe"]
for label, m in rows[1:]:
delta = m["sharpe"] - base_sharpe
pct = (delta / abs(base_sharpe) * 100) if base_sharpe != 0 else 0
print(f" {label:22s}: Sharpe {base_sharpe:+.3f} → {m['sharpe']:+.3f} "
f"(Δ={delta:+.3f}, {pct:+.1f}%)")
# ── 9. 不同 max_hold 的敏感性扫描 ───────────────────────────────
print(f"\n{'='*72}")
print(f" 敏感性扫描:不同 max_hold 下的 Sharpe")
print(f"{'='*72}")
print(f" {'max_hold':>8} {'Sharpe':>7} {'Sortino':>8} {'TotRet':>8} {'AvgHold':>8} {'Trades':>7}")
print(f" {'-'*52}")
Config.SYMBOLS = FOREX_SYMS
try:
with MT5DataFetcher(offline=offline) as fetcher:
mgr = MT5DataManager(fetcher)
mgr.load()
raw_dict = mgr.raw_dict
target_ret = mgr.target_ret
feat = MT5FeatureEngineer.compute_features(raw_dict)
vm = StackVM()
factor = vm.execute(formula, feat)
pos_base = compute_target_positions_stateless(factor)
for mh in [6, 12, 24, 48, 96, 240, 999999]:
pos_c = apply_max_hold(pos_base, mh)
prev = torch.roll(pos_c, 1, dims=1)
prev[:, 0] = 0.0
to = torch.abs(pos_c - prev)
pnl = pos_c * target_ret - to * COST_RATE
m = calc_metrics(pnl, pos_c)
label = f"{mh}h" if mh < 999999 else "无限制"
print(f" {label:>8} {m['sharpe']:>+7.3f} {m['sortino']:>+8.3f} "
f"{m['tot_ret']:>+8.3f} {m['avg_hold_h']:>7.1f}h {m['n_trades']:>7d}")
finally:
Config.SYMBOLS = original_symbols
print(f"\n完成。\n")
if __name__ == "__main__":
main()
+44
View File
@@ -0,0 +1,44 @@
"""
train_ftmo_index.py — FTMO 专属训练,仅训练 index 组
(跳过 forex 和 metals_comm,直接训练 index)
"""
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from config import Config
from data_pipeline.fetcher import MT5DataFetcher
from model_core.config import ModelConfig
from main import train_group
def main():
offline = "--offline" in sys.argv
t0 = time.time()
print(f"\n{'='*60}")
print(f" FTMO 专属因子训练 — index 组")
print(f"{'='*60}")
print(f" 奖励模式: REWARD_MODE = '{ModelConfig.REWARD_MODE}'")
print(f" 训练组 : index")
print(f" 品种 : {Config.SYMBOL_GROUPS['index']}")
print(f" offline={offline}")
print(f"{'='*60}")
with MT5DataFetcher(offline=offline) as fetcher:
gsyms = Config.SYMBOL_GROUPS["index"]
eng = train_group(fetcher, "index", gsyms, offline)
if eng is not None:
print(f"\n<<< [index] 完成: score={eng.best_score:.4f}")
print(f" {eng._decode_formula(eng.best_formula)}")
else:
print("\n<<< [index] 失败")
elapsed = time.time() - t0
print(f"\n耗时 {elapsed/3600:.2f}h")
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
"""
train_ftmo_island.py — FTMO 专属 + Island 多起点并行训练
训练 index 组,使用 IslandAlphaEngine:
- 3 个独立模型(islands)
- 每 200 步迁移一次 elite
- 自适应噪声、部分参数重置、elite decay 已启用
"""
import sys
import time
from pathlib import Path
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.config import ModelConfig
from model_core.island_engine import IslandAlphaEngine
def main():
offline = "--offline" in sys.argv
t0 = time.time()
print(f"\n{'='*60}")
print(f" FTMO + Island 多起点并行训练 — index 组")
print(f"{'='*60}")
print(f" 奖励模式: {ModelConfig.REWARD_MODE}")
print(f" 公式长度: {ModelConfig.MAX_FORMULA_LEN}")
print(f" Islands : {ModelConfig.N_ISLANDS}")
print(f" Migration: every {ModelConfig.MIGRATION_INTERVAL} steps, Top-{ModelConfig.MIGRATION_TOP_K}")
print(f" Adaptive noise: {ModelConfig.ADAPTIVE_NOISE}")
print(f" Partial reset: {ModelConfig.PARTIAL_RESET}")
print(f" Elite decay: {ModelConfig.ELITE_DECAY}")
print(f" 品种: {Config.SYMBOL_GROUPS['index']}")
print(f"{'='*60}\n")
with MT5DataFetcher(offline=offline) as fetcher:
# 临时切换 SYMBOLS 到 index 组
original_symbols = Config.SYMBOLS[:]
Config.SYMBOLS = Config.SYMBOL_GROUPS["index"]
try:
mgr = MT5DataManager(fetcher)
mgr.load()
engine = IslandAlphaEngine(mgr)
engine.train()
formula, score = engine.get_global_best()
print(f"\n最终全局最优: score={score:.4f}")
print(f"公式: {formula}")
finally:
Config.SYMBOLS = original_symbols
elapsed = time.time() - t0
print(f"\n总耗时 {elapsed/3600:.2f}h")
if __name__ == "__main__":
main()
-64
View File
@@ -1,64 +0,0 @@
"""
train_next_groups.py — 训练剩余组(跳过已完成的 forex)
用法:
python train_next_groups.py --offline
依次训练 metals_comm、index 组,使用 D:\K线数据 全量历史。
"""
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from config import Config
from data_pipeline.fetcher import MT5DataFetcher
from main import train_group
# 跳过已完成的 forex 组
GROUPS_TO_TRAIN = ["metals_comm", "index"]
def main():
offline = "--offline" in sys.argv
t0 = time.time()
print(f"\n{'='*60}")
print(f" 训练剩余组: {GROUPS_TO_TRAIN}")
print(f" 跳过: forex(已完成,Best=0.485)")
print(f" offline={offline} BARS_COUNT={Config.BARS_COUNT}")
print(f"{'='*60}")
results = {}
with MT5DataFetcher(offline=offline) as fetcher:
for gname in GROUPS_TO_TRAIN:
gsyms = Config.SYMBOL_GROUPS.get(gname, [])
if not gsyms:
print(f" [跳过] 组 {gname} 不存在")
continue
print(f"\n>>> 开始训练 [{gname}] 组: {gsyms}")
eng = train_group(fetcher, gname, gsyms, offline)
if eng is not None:
results[gname] = {
"score": eng.best_score,
"formula": eng.best_formula,
"readable": eng._decode_formula(eng.best_formula),
}
print(f"<<< [{gname}] 完成: score={eng.best_score:.4f}")
print(f" {results[gname]['readable']}")
else:
print(f"<<< [{gname}] 失败")
elapsed = time.time() - t0
print(f"\n{'='*60}")
print(f" 全部训练完成 耗时 {elapsed/3600:.2f}h")
print(f"{'='*60}")
for gname, r in results.items():
print(f" [{gname:12s}] score={r['score']:.4f}")
print(f" {r['readable']}")
if __name__ == "__main__":
main()