diff --git a/model_core/backtest.py b/model_core/backtest.py index 726bb0f..7eb8244 100644 --- a/model_core/backtest.py +++ b/model_core/backtest.py @@ -27,6 +27,53 @@ 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 组合级回测评估器。""" diff --git a/model_core/engine.py b/model_core/engine.py index 0ebec9f..14a3b35 100644 --- a/model_core/engine.py +++ b/model_core/engine.py @@ -14,7 +14,7 @@ from tqdm import tqdm from .config import ModelConfig from .alphagpt import AlphaGPT, NewtonSchulzLowRankDecay, StableRankMonitor from .vm import StackVM -from .backtest import MT5Backtest +from .backtest import MT5Backtest, estimate_periods_per_year from .vocab import FORMULA_VOCAB, VOCAB_VERSION, VocabVersionMismatchError # task 12.2 # P3:冠军在场时间稳健性校验所需 @@ -471,6 +471,22 @@ class AlphaEngine: # 每个 t 的归一化参数只用 [t-w+1..t],walk-forward 折叠切片无泄露 feat = self.data_manager.feat_tensor.to(ModelConfig.DEVICE) t_ret = self.data_manager.target_ret.to(ModelConfig.DEVICE) + + # 数据驱动年化因子:按训练数据的实际时间戳估计每年 bar 数, + # 替代 MT5Backtest 默认的 H1=6240。A 股日线/15min、加密日线等 + # 非 H1 周期不再被按 H1 年化(否则 Sharpe/年化收益被放大数倍)。 + _dm_raw = getattr(self.data_manager, "raw_dict", None) or {} + _dm_time = _dm_raw.get("time", None) + if _dm_time is not None: + try: + _ppy = estimate_periods_per_year(_dm_time) + if _ppy != self.bt.periods_per_year: + if verbose_header: + print(f" 年化因子: {_ppy} bar/年(按数据周期自动估计)") + self.bt.periods_per_year = _ppy + except Exception: + pass # 估计失败则保留默认 6240 + bs = ModelConfig.BATCH_SIZE n_elite = max(1, int(bs * ModelConfig.ELITE_REPLAY_FRAC)) n_new = bs - n_elite diff --git a/model_core/ops.py b/model_core/ops.py index 2aeb87c..142b14d 100644 --- a/model_core/ops.py +++ b/model_core/ops.py @@ -41,9 +41,18 @@ def _op_gate(condition: torch.Tensor, x: torch.Tensor, y: torch.Tensor) -> torch return mask * x + (1.0 - mask) * y def _op_jump(x: torch.Tensor) -> torch.Tensor: - """降低稀疏度:阈值从 3σ 改为 1.5σ,让更多时间步有非零输出""" - mean = x.mean(dim=1, keepdim=True) - std = x.std(dim=1, keepdim=True) + 1e-6 + """降低稀疏度:阈值从 3σ 改为 1.5σ,让更多时间步有非零输出。 + + 因果 expanding zscore:每个 t 仅用 x[:, :t+1] 计算 mean/std, + 避免原 dim=1 全局聚合(含未来统计量)引入的 look-ahead bias。 + """ + N, T = x.shape + cnt = torch.arange(1, T + 1, device=x.device, dtype=x.dtype).view(1, T) + cumsum = x.cumsum(dim=1) + mean = cumsum / cnt # [N,T],t 位 = x[:,:t+1].mean() + cumsum_sq = (x * x).cumsum(dim=1) + var = (cumsum_sq / cnt) - mean * mean # E[x^2] - E[x]^2 + std = var.clamp(min=1e-12).sqrt() + 1e-6 z = (x - mean) / std return torch.tanh(z - 1.5) # tanh 软化,不再产生全零区间 diff --git a/run_backtest.py b/run_backtest.py index 7810d8e..806655d 100644 --- a/run_backtest.py +++ b/run_backtest.py @@ -27,6 +27,7 @@ from backtest_viz import BacktestEngine from model_core.vocab import FORMULA_VOCAB, VOCAB_VERSION from model_core.vm import StackVM from model_core.features import MT5FeatureEngineer +from model_core.backtest import estimate_periods_per_year from strategy_manager.signal import compute_target_positions_stateless _H1_PER_YEAR = 6240 @@ -129,13 +130,15 @@ def _setup_chinese_font() -> None: plt.rcParams["axes.unicode_minus"] = False -def plot_equity_curves(results_map: dict, output_dir: str, times_arr: np.ndarray | None = None): +def plot_equity_curves(results_map: dict, output_dir: str, times_arr: np.ndarray | None = None, + periods_per_year: int = _H1_PER_YEAR): """绘制各品种 + 等权组合的资金曲线(中文标注)。 Args: results_map: {symbol: {"pnl": np.array, "cum_pnl": np.array, ...}} output_dir: 输出目录 times_arr: 时间戳数组(Unix秒),用于 X 轴刻度 + periods_per_year: 年化因子(每年 bar 数),由调用方按数据周期估计 """ _setup_chinese_font() Path(output_dir).mkdir(parents=True, exist_ok=True) @@ -174,7 +177,7 @@ def plot_equity_curves(results_map: dict, output_dir: str, times_arr: np.ndarray ) ax_eq.plot( x, port_cum, linewidth=2.2, color="black", - label=f"等权组合(索提诺 {calc_sortino(port_pnl):+.2f})", + label=f"等权组合(索提诺 {calc_sortino(port_pnl, periods_per_year):+.2f})", ) ax_eq.fill_between(x, port_cum, 0, where=port_cum >= 0, alpha=0.06, color="#1565c0") ax_eq.fill_between(x, port_cum, 0, where=port_cum < 0, alpha=0.06, color="#b71c1c") @@ -188,8 +191,8 @@ def plot_equity_curves(results_map: dict, output_dir: str, times_arr: np.ndarray ax_eq.set_title( f"{title_head} | " f"总收益={show_cum[-1]:+.3f} " - f"夏普={calc_sharpe(show_pnl):+.2f} " - f"索提诺={calc_sortino(show_pnl):+.2f} " + f"夏普={calc_sharpe(show_pnl, periods_per_year):+.2f} " + f"索提诺={calc_sortino(show_pnl, periods_per_year):+.2f} " f"盈亏比={_fmt_pl_ratio(results_map)}", fontsize=11, pad=8, ) @@ -220,6 +223,7 @@ def export_equity_json( times_arr: np.ndarray | None = None, max_points: int = 1500, rolling_window: int = 500, + periods_per_year: int = _H1_PER_YEAR, ): """导出资金曲线原始数据为 JSON,供前端渲染交互式 HTML 图表。 @@ -277,7 +281,8 @@ def export_equity_json( } for s in syms: cum = results_map[s]["cum_pnl"] - roll = calc_rolling_sharpe(results_map[s]["pnl"], window=rolling_window) + roll = calc_rolling_sharpe(results_map[s]["pnl"], window=rolling_window, + periods_per_year=periods_per_year) pl = results_map[s].get("profit_loss_ratio") out["symbols"][s] = { "equity": _sample(cum), @@ -297,9 +302,10 @@ def export_equity_json( port_pl = float(sum(pl_vals) / len(pl_vals)) if pl_vals else None out["portfolio"] = { "equity": _sample(port_cum), - "rolling_sharpe": _sample(calc_rolling_sharpe(port_pnl, window=rolling_window)), - "sharpe": round(float(calc_sharpe(port_pnl)), 4), - "sortino": round(float(calc_sortino(port_pnl)), 4), + "rolling_sharpe": _sample(calc_rolling_sharpe(port_pnl, window=rolling_window, + periods_per_year=periods_per_year)), + "sharpe": round(float(calc_sharpe(port_pnl, periods_per_year)), 4), + "sortino": round(float(calc_sortino(port_pnl, periods_per_year)), 4), "total_return": round(float(port_cum[-1]), 6), "profit_loss_ratio": round(port_pl, 4) if port_pl is not None else None, } @@ -437,7 +443,10 @@ def main(): T = raw_dict["open"].shape[1] times_all = raw_dict.get("time", None) - print(f" 品种: {syms} T={T} bars\n") + # 数据驱动年化因子:按实际时间戳估计每年 bar 数,替代写死的 H1=6240。 + # A 股日线(~244)、A 股 15min(~3904)、外汇 H1(6240)、加密日线(365) 都会被正确年化。 + ppy = estimate_periods_per_year(times_all) if times_all is not None else _H1_PER_YEAR + print(f" 品种: {syms} T={T} bars 年化因子={ppy} bar/年\n") # ── 4. 为每品种计算因子 + 回测 ─────────────────────────────── vm = StackVM() @@ -458,15 +467,16 @@ def main(): feat_i = feat[i:i+1] raw_i = {k: v[i:i+1] for k, v in raw_dict.items()} - engine = BacktestEngine(formula=formula, cost_rate=cost_rate) + engine = BacktestEngine(formula=formula, cost_rate=cost_rate, + periods_per_year=ppy) sym_res = engine.run(raw_i, feat_i, [sym]) backtest_results.extend(sym_res) r = sym_res[0] pnl_arr = r.pnl cum_arr = r.cum_pnl - sharpe = calc_sharpe(pnl_arr) - sortino = calc_sortino(pnl_arr) + sharpe = calc_sharpe(pnl_arr, ppy) + sortino = calc_sortino(pnl_arr, ppy) pl_ratio = r.profit_loss_ratio results_map[sym] = { @@ -507,8 +517,8 @@ def main(): all_pnls = np.stack([d["pnl"] for d in results_map.values()], axis=0) port_pnl = all_pnls.mean(axis=0) port_cum = np.cumsum(port_pnl) - p_sharpe = calc_sharpe(port_pnl) - p_sortino = calc_sortino(port_pnl) + p_sharpe = calc_sharpe(port_pnl, ppy) + p_sortino = calc_sortino(port_pnl, ppy) pl_vals = [d["profit_loss_ratio"] for d in results_map.values() if d["profit_loss_ratio"] is not None] p_pl_ratio = float(sum(pl_vals) / len(pl_vals)) if pl_vals else None @@ -527,8 +537,8 @@ def main(): Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) if results_map: times_np = times_all[0].numpy() if times_all is not None else None - plot_equity_curves(results_map, OUTPUT_DIR, times_np) - export_equity_json(results_map, OUTPUT_DIR, times_np) + plot_equity_curves(results_map, OUTPUT_DIR, times_np, periods_per_year=ppy) + export_equity_json(results_map, OUTPUT_DIR, times_np, periods_per_year=ppy) # ── 7. 资金曲线图已在步骤 6 生成;跳过 K 线/逐笔交易图以加快回测 ─────