612 lines
26 KiB
Python
612 lines
26 KiB
Python
"""
|
||
model_core/backtest.py — MT5 回测评估器(组合级多目标 Reward)
|
||
|
||
评分框架(5品种组合版):
|
||
final_score =
|
||
0.35 * portfolio_sortino # 组合整体风险调整收益
|
||
+ 0.20 * portfolio_calmar # 组合整体回撤控制
|
||
+ 0.15 * ts_ic_stability # 时序IC稳定性(比横截面IC更重要)
|
||
+ 0.10 * symbol_consistency # 品种一致性(防止单品种拖累)
|
||
+ 0.10 * cost_stress # 成本压力测试(2x成本下仍盈利)
|
||
+ 0.10 * turnover_quality # 换手率质量(交易频率奖励)
|
||
- complexity_penalty # 公式长度惩罚
|
||
- correlation_penalty # 因子相关性惩罚(由 engine 施加)
|
||
|
||
symbol_consistency 规则:
|
||
- N 个品种中至少 ceil(N*0.6) 个 Sortino > 0 → 正分
|
||
- 任何品种 Sortino < -2.0 → 重惩罚
|
||
- 全部品种 Sortino > 0 → 额外奖励
|
||
"""
|
||
import math
|
||
import torch
|
||
from torch import Tensor
|
||
|
||
from strategy_manager.signal import compute_target_positions_stateless
|
||
from .config import ModelConfig
|
||
|
||
_H1_PERIODS_PER_YEAR = 6240
|
||
_SORTINO_CLIP = 20.0
|
||
|
||
_SECONDS_PER_YEAR = 365.25 * 86400.0
|
||
|
||
|
||
def estimate_periods_per_year(times) -> int:
|
||
"""从时间戳序列估计「每年 bar 数」(年化因子)。
|
||
|
||
用 T / years_span,years_span = (t_last - t_first) / SECONDS_PER_YEAR。
|
||
该方法自动适应不同市场(A 股 / 外汇 / 加密)与周期——因为数据里只包含
|
||
交易时段的 bar,跨日历年的 bar 密度天然反映了该市场的交易频率,
|
||
无需按周期/市场写死。
|
||
|
||
替代了原先全局写死 _H1_PERIODS_PER_YEAR=6240(仅外汇 H1 正确)的做法:
|
||
A 股日线(~244 bar/年)、A 股 15min(~3904 bar/年)、加密日线(365 bar/年)
|
||
都会被正确年化,不再被按 H1 放大/缩小。
|
||
|
||
Args:
|
||
times: [N, T] 或 [T] 的 Unix 秒时间戳(torch.Tensor 或 np.ndarray)。
|
||
|
||
Returns:
|
||
int,每年 bar 数;数据不足时回退到 _H1_PERIODS_PER_YEAR。
|
||
"""
|
||
import numpy as _np
|
||
if hasattr(times, "detach"):
|
||
arr = times.detach().cpu().numpy()
|
||
else:
|
||
arr = _np.asarray(times)
|
||
arr = arr.astype(_np.float64)
|
||
if arr.ndim == 2:
|
||
t_count = arr.shape[1]
|
||
row = arr[0]
|
||
elif arr.ndim == 1:
|
||
t_count = arr.shape[0]
|
||
row = arr
|
||
else:
|
||
return _H1_PERIODS_PER_YEAR
|
||
if t_count < 2:
|
||
return _H1_PERIODS_PER_YEAR
|
||
span = float(row[-1] - row[0])
|
||
if span <= 0:
|
||
return _H1_PERIODS_PER_YEAR
|
||
years = span / _SECONDS_PER_YEAR
|
||
if years <= 0:
|
||
return _H1_PERIODS_PER_YEAR
|
||
ppy = t_count / years
|
||
# 合理范围钳制,防止异常时间戳产生极端值
|
||
return int(max(10, min(600000, round(ppy))))
|
||
|
||
|
||
class MT5Backtest:
|
||
"""MT5 组合级回测评估器。"""
|
||
|
||
def __init__(
|
||
self,
|
||
cost_rate: float = 0.0001,
|
||
periods_per_year: int = _H1_PERIODS_PER_YEAR,
|
||
):
|
||
self.cost_rate = cost_rate
|
||
self.periods_per_year = periods_per_year
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# 基础统计
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
|
||
def _sortino(self, pnl: Tensor, eps: float = 1e-8) -> Tensor:
|
||
flat = pnl.reshape(-1)
|
||
mean_pnl = flat.mean()
|
||
downside = flat[flat < 0]
|
||
raw_std = downside.std(unbiased=False) if downside.numel() > 0 \
|
||
else torch.tensor(0.0, dtype=flat.dtype, device=flat.device)
|
||
# P0b 修复:下行标准差地板改为全序列 std 的 20%,防止稀疏 PnL 靠极小分母刷高分。
|
||
# 原来 floor=|mean_pnl| 对稀疏序列趋近于零,导致 Sortino 爆炸。
|
||
full_std = flat.std(unbiased=False).clamp(min=eps)
|
||
floor = torch.clamp(full_std * 0.2, min=eps)
|
||
downside_std = torch.clamp(raw_std, min=floor)
|
||
sortino = mean_pnl / downside_std * math.sqrt(self.periods_per_year)
|
||
return torch.clamp(sortino, -_SORTINO_CLIP, _SORTINO_CLIP)
|
||
|
||
def _calmar(self, pnl: Tensor, eps: float = 1e-8) -> Tensor:
|
||
"""Calmar = annualized_return / max_drawdown(截断到 [-10, 10])。"""
|
||
flat = pnl.reshape(-1)
|
||
ann_ret = flat.mean() * self.periods_per_year
|
||
cum = torch.cumsum(flat, dim=0)
|
||
peak = torch.cummax(cum, dim=0).values
|
||
drawdown = (peak - cum).max()
|
||
drawdown = torch.clamp(drawdown, min=eps)
|
||
calmar = ann_ret / drawdown
|
||
return torch.clamp(calmar, -10.0, 10.0)
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# 组合级评分组件
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
|
||
def _ts_ic_stability(self, factors: Tensor, target_ret: Tensor) -> float:
|
||
"""时序 IC 稳定性:每个品种内部 factor[t] 与 ret[t+1] 的相关性均值。
|
||
|
||
比横截面 IC 更适合 5 品种宇宙(横截面 N=5 统计意义弱)。
|
||
|
||
Returns:
|
||
float,约 [-1, 1],正值代表因子有预测力。
|
||
"""
|
||
N, T = factors.shape
|
||
if T < 10:
|
||
return 0.0
|
||
|
||
ic_list = []
|
||
for n in range(N):
|
||
x = factors[n, :-1]
|
||
y = target_ret[n, 1:]
|
||
xm = x - x.mean()
|
||
ym = y - y.mean()
|
||
sx = (xm ** 2).mean().sqrt()
|
||
sy = (ym ** 2).mean().sqrt()
|
||
if sx < 1e-6 or sy < 1e-6:
|
||
continue
|
||
ic = (xm * ym).mean() / (sx * sy + 1e-8)
|
||
ic_list.append(ic.item())
|
||
|
||
if not ic_list:
|
||
return 0.0
|
||
|
||
ic_mean = sum(ic_list) / len(ic_list)
|
||
ic_std = (sum((v - ic_mean) ** 2 for v in ic_list) / len(ic_list)) ** 0.5
|
||
# 稳定性 = IC均值 / IC标准差(IR,截断到 [-3, 3])
|
||
stability = ic_mean / (ic_std + 1e-6)
|
||
return float(max(-3.0, min(3.0, stability)))
|
||
|
||
def _symbol_consistency(
|
||
self,
|
||
per_symbol_sortino: list[float],
|
||
per_symbol_trade_count: list[int] | None = None,
|
||
eval_bars: int = 0,
|
||
) -> float:
|
||
"""品种一致性惩罚/奖励。
|
||
|
||
规则(优先级从高到低):
|
||
1. 无交易品种超过 40%:重惩罚 -3.0
|
||
2. P0a 新增:有交易的品种中,交易笔数 < eval_bars/100 (约每100bar少于1笔)
|
||
视为"稀疏有效",等同无效。防止3~6笔偶发交易刷高 Sortino。
|
||
3. 任何品种 Sortino < -2.0:重惩罚 -2.0
|
||
4. 有效品种中正收益比例决定奖惩
|
||
"""
|
||
N = len(per_symbol_sortino)
|
||
if N == 0:
|
||
return 0.0
|
||
|
||
# 最小有效交易数:每 100 bar 至少 1 笔,下限 5 笔
|
||
min_trades = max(5, eval_bars // 100) if eval_bars > 0 else 5
|
||
|
||
# 重新判定"活跃"品种(必须交易数 >= min_trades)
|
||
if per_symbol_trade_count is not None:
|
||
n_inactive = sum(1 for c in per_symbol_trade_count if c < min_trades)
|
||
inactive_ratio = n_inactive / N
|
||
if inactive_ratio > 0.4:
|
||
return -3.0
|
||
else:
|
||
n_inactive = 0
|
||
inactive_ratio = 0.0
|
||
|
||
if any(s < -2.0 for s in per_symbol_sortino):
|
||
return -2.0
|
||
|
||
if per_symbol_trade_count is not None:
|
||
active_sortinos = [
|
||
s for s, c in zip(per_symbol_sortino, per_symbol_trade_count)
|
||
if c >= min_trades
|
||
]
|
||
else:
|
||
active_sortinos = per_symbol_sortino
|
||
|
||
if not active_sortinos:
|
||
return -3.0
|
||
|
||
n_positive = sum(1 for s in active_sortinos if s > 0)
|
||
ratio = n_positive / len(active_sortinos)
|
||
|
||
if ratio < 0.6:
|
||
score = (ratio - 0.6) / 0.6 * 1.0
|
||
else:
|
||
score = (ratio - 0.6) / 0.4 * 1.0
|
||
|
||
if ratio == 1.0:
|
||
score += 0.5
|
||
|
||
return float(score)
|
||
|
||
def _cost_stress(
|
||
self,
|
||
position: Tensor,
|
||
target_ret: Tensor,
|
||
stress_mult: float = 2.0,
|
||
) -> float:
|
||
"""成本压力测试:2 倍成本下的 Sortino 是否还 > 0。
|
||
|
||
Returns:
|
||
float,压力测试 Sortino(截断到 [-5, 5])。
|
||
"""
|
||
prev_pos = torch.roll(position, 1, dims=1)
|
||
prev_pos[:, 0] = 0.0
|
||
turnover = torch.abs(position - prev_pos)
|
||
stressed_pnl = position * target_ret - turnover * self.cost_rate * stress_mult
|
||
sortino = self._sortino(stressed_pnl)
|
||
return float(torch.clamp(sortino, -5.0, 5.0))
|
||
|
||
def _turnover_quality(self, position: Tensor) -> float:
|
||
"""交易频率质量奖励(每天约 1 笔为最优)。
|
||
|
||
目标:每 12 bar 一笔(H1 每天约一笔)。
|
||
"""
|
||
N, T = position.shape
|
||
pos_2d = position.tolist()
|
||
all_runs, total_trades = [], 0
|
||
|
||
for n in range(N):
|
||
runs, cur_len, cur_dir = [], 0, 0
|
||
for p in pos_2d[n]:
|
||
pi = int(p)
|
||
if pi != 0:
|
||
if pi == cur_dir:
|
||
cur_len += 1
|
||
else:
|
||
if cur_len > 0: runs.append(cur_len)
|
||
cur_dir, cur_len = pi, 1
|
||
else:
|
||
if cur_len > 0: runs.append(cur_len)
|
||
cur_dir, cur_len = 0, 0
|
||
if cur_len > 0: runs.append(cur_len)
|
||
all_runs.extend(runs)
|
||
total_trades += len(runs)
|
||
|
||
total_bars = N * T
|
||
target_trades = total_bars / 12.0
|
||
actual_ratio = total_trades / max(target_trades, 1.0)
|
||
|
||
if actual_ratio <= 0:
|
||
freq_score = -2.0
|
||
elif actual_ratio < 0.05:
|
||
freq_score = -2.0 + actual_ratio / 0.05
|
||
elif actual_ratio < 0.5:
|
||
freq_score = -1.0 + (actual_ratio - 0.05) / 0.45
|
||
elif actual_ratio <= 2.0:
|
||
log_r = math.log(actual_ratio) / math.log(2.0)
|
||
freq_score = 1.0 * math.exp(-0.5 * log_r ** 2)
|
||
elif actual_ratio <= 8.0:
|
||
freq_score = 0.5 - (actual_ratio - 2.0) / 6.0 * 1.5
|
||
else:
|
||
freq_score = -2.0
|
||
|
||
hold_bonus = 0.0
|
||
if all_runs:
|
||
avg_hold = sum(all_runs) / len(all_runs)
|
||
hold_bonus = min(0.3, math.log(max(avg_hold, 1.0)) / math.log(30.0) * 0.3)
|
||
|
||
return float(freq_score + hold_bonus)
|
||
|
||
def _beta_neutral_penalty(self, position: Tensor) -> float:
|
||
"""Beta 中性惩罚:多空比例严重失衡时扣分。
|
||
|
||
因子输出 >85% 同方向时,说明不是 alpha 因子而是 beta 因子
|
||
(如 index 组的 TS_RANK 连续使用导致恒正输出)。
|
||
|
||
Returns:
|
||
float,惩罚值(负数或零)
|
||
"""
|
||
flat = position.reshape(-1)
|
||
long_ratio = (flat > 0.05).float().mean().item()
|
||
short_ratio = (flat < -0.05).float().mean().item()
|
||
max_ratio = max(long_ratio, short_ratio)
|
||
if max_ratio > 0.85:
|
||
# 超过 85% 同方向,重罚
|
||
excess = (max_ratio - 0.85) / 0.15 # 0~1
|
||
return -2.0 * excess # 最多 -2.0
|
||
elif max_ratio > 0.70:
|
||
# 70-85% 轻度失衡,轻罚
|
||
excess = (max_ratio - 0.70) / 0.15 # 0~1
|
||
return -0.5 * excess # 最多 -0.5
|
||
return 0.0
|
||
|
||
def _half_consistency_bonus(self, pnl: Tensor) -> float:
|
||
"""前后一致性奖励:前半段和后半段 Sortino 同号时加分。
|
||
|
||
防止因子只在某一段市场环境(如牛市)有效。
|
||
|
||
Returns:
|
||
float,奖励/惩罚值
|
||
"""
|
||
T = pnl.shape[1]
|
||
if T < 20:
|
||
return 0.0
|
||
half = T // 2
|
||
s1 = self._sortino(pnl[:, :half]).item()
|
||
s2 = self._sortino(pnl[:, half:]).item()
|
||
if s1 > 0 and s2 > 0:
|
||
return 0.5 # 前后都赚钱,奖励
|
||
elif s1 * s2 < 0:
|
||
return -1.0 # 前后相反,重罚(如 index 组的 beta 因子)
|
||
return 0.0 # 一正一零或两零,不奖不罚
|
||
|
||
def _exposure_penalty(self, position: Tensor) -> float:
|
||
"""在场时间惩罚(仅下限,无上限):收益优先模式。
|
||
|
||
只惩罚极稀疏交易(<10%在场),不惩罚高在场时间。
|
||
高在场时间(满仓趋势跟踪)是外汇市场最赚钱的形态之一,不应受罚。
|
||
"""
|
||
flat = position.reshape(-1).abs()
|
||
exposure = flat.mean().item() # 连续仓位:均值即平均持仓量
|
||
if exposure < 0.10:
|
||
# 极稀疏:平均持仓 < 10% → 线性惩罚 [-2, 0)
|
||
return float((exposure / 0.10 - 1.0) * 2.0)
|
||
return 0.0
|
||
|
||
def _turnover_penalty(self, turnover: Tensor) -> Tensor:
|
||
"""梯度式换手率惩罚。"""
|
||
mean_to = turnover.mean()
|
||
penalty = torch.clamp(
|
||
(mean_to - 0.2) * 3.0,
|
||
min=0.0,
|
||
max=3.0,
|
||
)
|
||
return -penalty
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# Walk-Forward 辅助接口
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
|
||
def evaluate_fold(
|
||
self,
|
||
factors: Tensor,
|
||
target_ret: Tensor,
|
||
train_start: int,
|
||
train_end: int,
|
||
val_start: int,
|
||
val_end: int,
|
||
) -> tuple[Tensor, Tensor]:
|
||
"""在指定训练/验证切片上计算组合多目标得分。
|
||
|
||
train_score:用于 REINFORCE 梯度更新(in-sample 多目标)。
|
||
val_score:用于选冠军,加入 OOS Sortino 门控:
|
||
- OOS Sortino <= 0:乘以 0.1~0.5 惩罚,强制冠军必须在验证段盈利
|
||
- OOS Sortino > 0:乘以最多 1.2 奖励
|
||
"""
|
||
position = compute_target_positions_stateless(factors) # neutral band
|
||
|
||
prev_pos = torch.roll(position, 1, dims=1)
|
||
prev_pos[:, 0] = 0.0
|
||
turnover = torch.abs(position - prev_pos)
|
||
pnl = position * target_ret - turnover * self.cost_rate
|
||
|
||
pnl_train = pnl[:, train_start:train_end]
|
||
pnl_val = pnl[:, val_start:val_end]
|
||
|
||
# 训练段:多目标 + 换手率惩罚
|
||
train_bars = train_end - train_start
|
||
train_score = self._multi_objective(
|
||
factors[:, train_start:train_end],
|
||
target_ret[:, train_start:train_end],
|
||
pnl_train,
|
||
position[:, train_start:train_end],
|
||
eval_bars=train_bars,
|
||
) + self._turnover_penalty(turnover[:, train_start:train_end])
|
||
|
||
# 验证段:多目标 × OOS Sortino 门控
|
||
val_bars = val_end - val_start
|
||
base_val = self._multi_objective(
|
||
factors[:, val_start:val_end],
|
||
target_ret[:, val_start:val_end],
|
||
pnl_val,
|
||
position[:, val_start:val_end],
|
||
eval_bars=val_bars,
|
||
)
|
||
oos_sor = self._sortino(pnl_val).item()
|
||
if oos_sor <= 0:
|
||
# OOS亏损:重惩罚(Sortino=-1 → mult=0.1;Sortino=0 → mult=0.5)
|
||
mult = max(0.1, 0.5 + oos_sor * 0.4)
|
||
else:
|
||
# OOS盈利:轻奖励(最多+20%)
|
||
mult = min(1.2, 1.0 + oos_sor * 0.1)
|
||
val_score = base_val * mult
|
||
|
||
return train_score, val_score
|
||
|
||
def _reversal_bonus(self, factors: Tensor) -> Tensor:
|
||
"""反转奖励:鼓励因子有低/负自相关(均值回归特征)。
|
||
|
||
计算每个品种的 lag-1 自相关系数,越接近 0 或负值 = 越好。
|
||
高度正自相关(>0.5)= 趋势跟踪,减分。
|
||
|
||
单品种模式:直接返回标量。
|
||
"""
|
||
N = factors.shape[0]
|
||
scores = []
|
||
for n in range(N):
|
||
x = factors[n, :-1] # t=0..T-2
|
||
y = factors[n, 1:] # t=1..T-1
|
||
xm = x - x.mean(); ym = y - y.mean()
|
||
sx = (xm**2).mean().sqrt(); sy = (ym**2).mean().sqrt()
|
||
ac1 = (xm*ym).mean() / (sx*sy + 1e-8) if sx > 1e-6 and sy > 1e-6 else torch.tensor(0.0)
|
||
# 奖励低自相关:bonus = 1 - |ac1|, 负自相关额外加分
|
||
bonus = 1.0 - torch.abs(ac1)
|
||
if ac1 < 0:
|
||
bonus = bonus + 0.5 # 负自相关(真正反转)额外加分
|
||
bonus = torch.clamp(bonus, -1.0, 2.0)
|
||
scores.append(bonus)
|
||
return torch.stack(scores).mean()
|
||
|
||
def _symmetry_check(self, position: Tensor) -> Tensor:
|
||
"""多空对称性检查:奖励 50/50 多空分布。
|
||
|
||
均值回归策略应该在多空之间大致平衡,
|
||
过度偏向某一侧 = 趋势跟踪特征,应惩罚。
|
||
"""
|
||
long_ratio = (position > 0).float().mean()
|
||
short_ratio = (position < 0).float().mean()
|
||
# 理想值:long_ratio ≈ 0.5, short_ratio ≈ 0.5
|
||
# 偏差:|long_ratio - 0.5| + |short_ratio - 0.5|
|
||
deviation = torch.abs(long_ratio - 0.5) + torch.abs(short_ratio - 0.5)
|
||
# 偏差 0 → 奖励 1.0; 偏差 1.0 → 奖励 -1.0
|
||
bonus = 1.0 - 2.0 * deviation
|
||
return torch.clamp(bonus, -1.0, 1.0)
|
||
|
||
def _multi_objective(
|
||
self,
|
||
factors: Tensor,
|
||
target_ret: Tensor,
|
||
pnl: Tensor,
|
||
position: Tensor,
|
||
eval_bars: int = 0,
|
||
) -> Tensor:
|
||
"""收益优先的多目标评分(2026-07-04 重构)。
|
||
|
||
核心改变:加入年化绝对收益项(权重 0.40),这是最主要的优化目标。
|
||
Sortino/Calmar 权重大幅下调,仅作为风险调整辅助。
|
||
clamp 上限放开(Sortino 40→20 保持,收益无上限)。
|
||
|
||
N=1 单品种模式权重略有不同(无 symbol_consistency/cost_stress)。
|
||
|
||
2026-07-08: 新增 forex 模式 — 偏向均值回归策略。
|
||
"""
|
||
N = pnl.shape[0]
|
||
|
||
# ── 绝对收益(年化 log return)──────────────────────────────────
|
||
# 连续仓位 pnl = position * target_ret - turnover * cost。
|
||
# pnl.mean() 已是单 bar 平均收益,因此年化只乘每年 bar 数;不能再除以样本长度。
|
||
ann_ret = pnl.mean() * self.periods_per_year # 标量张量,无截断
|
||
|
||
port_sortino = self._sortino(pnl)
|
||
port_calmar = self._calmar(pnl)
|
||
ts_ic = self._ts_ic_stability(factors, target_ret)
|
||
tq = self._turnover_quality(position)
|
||
exp_pen = self._exposure_penalty(position)
|
||
|
||
if N == 1:
|
||
beta_pen = self._beta_neutral_penalty(position)
|
||
consist = self._half_consistency_bonus(pnl)
|
||
|
||
if ModelConfig.REWARD_MODE == "forex":
|
||
# Forex 均值回归模式:
|
||
# - 降年化收益权重 (0.80→0.25):外汇趋势弱,避免奖励虚假趋势
|
||
# - 提 IC 权重 (0.03→0.25):信号质量是核心
|
||
# - 新增反转奖励 (0.20):奖励低/负因子自相关
|
||
# - 新增对称检查 (0.15):奖励 50/50 多空平衡
|
||
rev_bonus = self._reversal_bonus(factors)
|
||
sym_bonus = self._symmetry_check(position)
|
||
return (
|
||
0.25 * ann_ret # 年化收益(降权,外汇趋势噪声大)
|
||
+ 0.05 * port_sortino # 风险调整辅助
|
||
+ 0.05 * port_calmar # 回撤控制
|
||
+ 0.25 * ts_ic # 信号质量(大幅提权)
|
||
+ 0.20 * rev_bonus # 反转奖励(核心:反趋势)
|
||
+ 0.15 * sym_bonus # 多空对称(均值回归特征)
|
||
+ 0.05 * tq # 交易频率质量
|
||
+ exp_pen # 稀疏惩罚
|
||
+ beta_pen # Beta 中性惩罚
|
||
+ consist # 前后一致性奖惩
|
||
)
|
||
|
||
if ModelConfig.REWARD_MODE == "ftmo":
|
||
# FTMO 专属:年化收益 0.80,Calmar 0.10(控制 MDD 贴近 10% 上限)
|
||
return (
|
||
0.80 * ann_ret # 主目标:年化绝对收益(FTMO 加权)
|
||
+ 0.05 * port_sortino # 风险调整辅助(降权)
|
||
+ 0.10 * port_calmar # 回撤控制(保持,对齐 10% Max Loss)
|
||
+ 0.03 * ts_ic # IC 预测方向(降权)
|
||
+ 0.02 * tq # 交易频率质量(降权)
|
||
+ exp_pen # 稀疏惩罚
|
||
+ beta_pen # Beta 中性惩罚
|
||
+ consist # 前后一致性奖惩
|
||
)
|
||
return (
|
||
0.60 * ann_ret # 主目标:年化绝对收益
|
||
+ 0.15 * port_sortino # 风险调整辅助
|
||
+ 0.10 * port_calmar # 回撤控制辅助
|
||
+ 0.10 * ts_ic # IC 预测方向
|
||
+ 0.05 * tq # 交易频率质量
|
||
+ exp_pen # 稀疏惩罚
|
||
+ beta_pen # Beta 中性惩罚
|
||
+ consist # 前后一致性奖惩
|
||
)
|
||
|
||
per_sym_sortino = []
|
||
per_sym_trade_count = []
|
||
for n in range(N):
|
||
per_sym_sortino.append(self._sortino(pnl[n]).item())
|
||
# 连续仓位下,用 |position| 变化来估算交易次数
|
||
pos_n = position[n].abs()
|
||
# 视 tanh 输出均值作为持仓量,换手次数用前后差异估计
|
||
diff = (pos_n[1:] - pos_n[:-1]).abs()
|
||
trades = int((diff > 0.1).sum().item())
|
||
per_sym_trade_count.append(trades)
|
||
|
||
sym_cons = self._symbol_consistency(
|
||
per_sym_sortino, per_sym_trade_count, eval_bars=eval_bars
|
||
)
|
||
cost_s = self._cost_stress(position, target_ret)
|
||
beta_pen = self._beta_neutral_penalty(position)
|
||
consist = self._half_consistency_bonus(pnl)
|
||
|
||
if ModelConfig.REWARD_MODE == "ftmo":
|
||
# FTMO 专属:年化收益 0.75(提权),Calmar 0.10(对齐 10% Max Loss)
|
||
return (
|
||
0.75 * ann_ret # 主目标:年化绝对收益(FTMO 加权)
|
||
+ 0.05 * port_sortino # 风险调整辅助(降权)
|
||
+ 0.10 * port_calmar # 回撤控制(提权,控制 MDD)
|
||
+ 0.02 * ts_ic # IC 预测方向(降权)
|
||
+ 0.03 * sym_cons # 品种一致性(降权)
|
||
+ 0.02 * cost_s # 成本压力测试(降权)
|
||
+ 0.03 * tq # 交易频率质量(降权)
|
||
+ exp_pen # 稀疏惩罚
|
||
+ beta_pen # Beta 中性惩罚
|
||
+ consist # 前后一致性奖惩
|
||
)
|
||
|
||
return (
|
||
0.60 * ann_ret # 主目标:年化绝对收益
|
||
+ 0.10 * port_sortino # 风险调整辅助
|
||
+ 0.05 * port_calmar # 回撤控制辅助
|
||
+ 0.10 * ts_ic # IC 预测方向
|
||
+ 0.05 * sym_cons # 品种一致性
|
||
+ 0.05 * cost_s # 成本压力测试
|
||
+ 0.05 * tq # 交易频率质量
|
||
+ exp_pen # 稀疏惩罚
|
||
+ beta_pen # Beta 中性惩罚
|
||
+ consist # 前后一致性奖惩
|
||
)
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# 公开接口(非 Walk-Forward 模式)
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
|
||
def evaluate(
|
||
self,
|
||
factors: Tensor,
|
||
raw_dict: dict,
|
||
target_ret: Tensor,
|
||
) -> tuple[Tensor, float]:
|
||
"""评估一组 Alpha 因子(含 OOS 80/20 门控)。"""
|
||
position = compute_target_positions_stateless(factors)
|
||
|
||
prev_pos = torch.roll(position, 1, dims=1)
|
||
prev_pos[:, 0] = 0.0
|
||
turnover = torch.abs(position - prev_pos)
|
||
pnl = position * target_ret - turnover * self.cost_rate
|
||
|
||
T = factors.shape[1]
|
||
split = int(math.floor(T * 0.8))
|
||
|
||
score = self._multi_objective(
|
||
factors[:, :split], target_ret[:, :split],
|
||
pnl[:, :split], position[:, :split],
|
||
eval_bars=split,
|
||
) + self._turnover_penalty(turnover[:, :split])
|
||
|
||
# OOS 门控(最后 20%)
|
||
pnl_oos = pnl[:, split:]
|
||
oos_sor = self._sortino(pnl_oos).item()
|
||
if oos_sor <= 0:
|
||
mult = max(0.1, 0.5 + oos_sor * 0.4)
|
||
score = score * mult
|
||
else:
|
||
score = score * min(1.2, 1.0 + oos_sor * 0.1)
|
||
|
||
mean_oos = pnl_oos.mean().item()
|
||
return score, mean_oos
|