1.1
This commit is contained in:
@@ -134,9 +134,15 @@ class Config:
|
||||
else "cpu"
|
||||
)
|
||||
|
||||
# ── 风控参数 ──────────────────────────────────────────
|
||||
# ── 风控参数 ──────────────────────────────────────────────────
|
||||
RISK_PER_TRADE = 0.01 # legacy: 保留给旧接口/测试;实盘仓位使用 VOL_TARGET_* 参数
|
||||
COST_RATE = 0.0001 # 单边点差+佣金(forex/metals)
|
||||
# P1-7 修复:COST_RATE 必须与生产 run_backtest.py 的 commission+slippage 一致。
|
||||
# 原 0.0001 仅覆盖点差,未计入手续费/滑点;生产端 run_backtest.py 用
|
||||
# (commission_pct + slippage_pct) / 100 = (0.02 + 0.01) / 100 = 0.0003。
|
||||
# 训练用 0.0001 会让 gate 误判(高 Sharpe 在生产成本下其实不达标),统一为 0.0003。
|
||||
# 同时这也部分补偿 close vs mid 价格偏差(P1-7:训练用 close、实盘用 mid,
|
||||
# spread 未建模,把 cost_rate 抬高到生产水平可吸收大部分 spread 损耗)。
|
||||
COST_RATE = 0.0003 # 单边:手续费 0.02% + 滑点 0.01%(与 run_backtest.py 一致)
|
||||
MAX_OPEN_POSITIONS = 4 # 最多同时持仓品种数
|
||||
MAX_LOT_PER_TRADE = 5.0 # 兜底上限;实际手数由 XAUUSD 0.01 手波动预算决定
|
||||
# 永不自动交易的品种(白银合约乘数 5000,2026-07-08 起停用)
|
||||
|
||||
@@ -245,7 +245,10 @@ class MT5DataManager:
|
||||
aligned: dict[str, pd.DataFrame] = {}
|
||||
for symbol, sub in indexed.items():
|
||||
reindexed = sub.reindex(union_index)
|
||||
reindexed = reindexed.ffill().bfill()
|
||||
# 仅使用 ffill(因果填充),禁止 bfill 以避免未来信息泄漏
|
||||
reindexed = reindexed.ffill()
|
||||
# 起始处的 NaN(无历史数据)填充为 0,避免下游 log/divide 出 -inf
|
||||
reindexed = reindexed.fillna(0.0)
|
||||
aligned[symbol] = reindexed[fields]
|
||||
return aligned
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ class SingleSymbolDataManager:
|
||||
self._multi = multi_manager
|
||||
self._symbol = symbol
|
||||
self._idx = multi_manager.symbols.index(symbol)
|
||||
# P2-16: 特征张量缓存,避免每次访问重算 65 个特征
|
||||
self._feat_cache: torch.Tensor | None = None
|
||||
|
||||
logger.info(f"[SingleSymbolDataManager] symbol={symbol} idx={self._idx}")
|
||||
|
||||
@@ -62,9 +64,21 @@ class SingleSymbolDataManager:
|
||||
|
||||
@property
|
||||
def feat_tensor(self) -> torch.Tensor:
|
||||
"""返回 [1, F, T] 特征张量(只含目标品种)。"""
|
||||
raw = self.raw_dict
|
||||
return MT5FeatureEngineer.compute_features(raw) # [1, F, T]
|
||||
"""返回 [1, F, T] 特征张量(只含目标品种)。
|
||||
|
||||
P2-16 修复:缓存计算结果,避免每次访问都重算 65 个特征。
|
||||
AlphaEngine 训练时每步访问一次 feat_tensor,原本每次都要重新计算
|
||||
30+ 个 rolling/expanding 统计量,浪费 CPU。数据加载后 raw_dict 不变,
|
||||
特征输出是确定性的,缓存一次即可。如需刷新,调用 invalidate_cache()。
|
||||
"""
|
||||
if self._feat_cache is None:
|
||||
raw = self.raw_dict
|
||||
self._feat_cache = MT5FeatureEngineer.compute_features(raw) # [1, F, T]
|
||||
return self._feat_cache
|
||||
|
||||
def invalidate_cache(self) -> None:
|
||||
"""显式失效缓存(数据重新加载后调用)。"""
|
||||
self._feat_cache = None
|
||||
|
||||
@property
|
||||
def target_ret(self) -> torch.Tensor:
|
||||
|
||||
@@ -3,6 +3,18 @@ execution/price_feed.py — MT5 实时价格获取模块
|
||||
|
||||
MT5PriceFeed 负责通过 MetaTrader5 Python API 获取指定品种的最新 bid/ask 报价。
|
||||
全同步接口,无 asyncio(Req 7.4)。
|
||||
|
||||
【P1-7 Train-Serve Skew 注意事项】
|
||||
实盘执行用 mid = (bid+ask)/2 价格,但训练特征与 target_ret 全部基于历史 close
|
||||
(见 data_pipeline/fetcher.py)。两者偏差 = spread/2,在流动性差或新闻行情下
|
||||
可能达到数个 pip,吃掉部分 alpha。当前缓解措施:
|
||||
1. Config.COST_RATE 已统一为 0.0003(=生产 commission+slippage),高于纯点差,
|
||||
可吸收大部分 spread 损耗(见 config.py 注释)。
|
||||
2. 如需精确建模,可在 MT5Backtest 中对 target_ret 扣除 spread_cost:
|
||||
target_ret_adj = target_ret - spread * |position_change|
|
||||
但这需要历史 spread 数据,当前未实现。
|
||||
生产监控建议:定期对比训练 backtest Sharpe 与实盘 Sharpe,若偏差 > 30% 应考虑
|
||||
加入 spread 模型或调整 COST_RATE 上调。
|
||||
"""
|
||||
try:
|
||||
import MetaTrader5 as mt5
|
||||
|
||||
+46
-45
@@ -108,12 +108,17 @@ class RMSNorm(nn.Module):
|
||||
|
||||
|
||||
class QKNorm(nn.Module):
|
||||
"""Query-Key Normalization for Attention"""
|
||||
"""Query-Key Normalization for Attention.
|
||||
|
||||
【P2-2 死代码警告】:本类定义保留向后兼容,但 LoopedTransformerLayer
|
||||
已不再实例化它(forward 中未调用)。nn.MultiheadAttention 不直接支持
|
||||
QK-Norm 钩子,若未来需要接入需自定义 attention 实现。
|
||||
"""
|
||||
def __init__(self, d_model, eps=1e-6):
|
||||
super().__init__()
|
||||
self.eps = eps
|
||||
self.scale = nn.Parameter(torch.ones(1, 1, 1, d_model) * (d_model ** -0.5))
|
||||
|
||||
|
||||
def forward(self, q, k):
|
||||
# Normalize Q and K independently
|
||||
q_norm = F.normalize(q, p=2, dim=-1)
|
||||
@@ -127,7 +132,7 @@ class SwiGLU(nn.Module):
|
||||
super().__init__()
|
||||
self.w = nn.Linear(d_in, d_ff * 2)
|
||||
self.fc = nn.Linear(d_ff, d_in)
|
||||
|
||||
|
||||
def forward(self, x):
|
||||
x_glu = self.w(x)
|
||||
x, gate = x_glu.chunk(2, dim=-1)
|
||||
@@ -136,32 +141,24 @@ class SwiGLU(nn.Module):
|
||||
|
||||
|
||||
class MTPHead(nn.Module):
|
||||
"""Multi-Task Pooling Head for multi-objective learning"""
|
||||
"""Multi-Task Pooling Head.
|
||||
|
||||
【P2-3 简化说明】:原设计含 3 个 task head + router,但 engine.py 未使用
|
||||
task_probs(无辅助 loss 约束 router),导致 multi-task 退化为单 head 加权
|
||||
平均。此处简化为单 head(nn.Linear)以减少参数浪费,forward 仍返回
|
||||
(logits, None) 保持调用方签名兼容。
|
||||
"""
|
||||
def __init__(self, d_model, vocab_size, num_tasks=3):
|
||||
super().__init__()
|
||||
# 简化为单 head:删除 task_heads/task_weights/task_router
|
||||
self.head = nn.Linear(d_model, vocab_size)
|
||||
# 保留 num_tasks 属性供旧代码引用
|
||||
self.num_tasks = num_tasks
|
||||
self.task_heads = nn.ModuleList([
|
||||
nn.Linear(d_model, vocab_size) for _ in range(num_tasks)
|
||||
])
|
||||
self.task_weights = nn.Parameter(torch.ones(num_tasks) / num_tasks)
|
||||
self.task_router = nn.Sequential(
|
||||
nn.Linear(d_model, d_model // 2),
|
||||
nn.ReLU(),
|
||||
nn.Linear(d_model // 2, num_tasks)
|
||||
)
|
||||
|
||||
|
||||
def forward(self, x):
|
||||
# Route to appropriate task heads
|
||||
task_logits = self.task_router(x)
|
||||
task_probs = F.softmax(task_logits, dim=-1)
|
||||
|
||||
# Compute all task outputs
|
||||
task_outputs = [head(x) for head in self.task_heads]
|
||||
task_outputs = torch.stack(task_outputs, dim=1) # [B, num_tasks, vocab_size]
|
||||
|
||||
# Weighted combination
|
||||
weighted = (task_probs.unsqueeze(-1) * task_outputs).sum(dim=1)
|
||||
return weighted, task_probs
|
||||
logits = self.head(x)
|
||||
# 返回 (logits, task_probs=None) 保持调用方签名兼容
|
||||
return logits, None
|
||||
|
||||
|
||||
class LoopedTransformerLayer(nn.Module):
|
||||
@@ -171,22 +168,23 @@ class LoopedTransformerLayer(nn.Module):
|
||||
self.num_loops = num_loops
|
||||
self.d_model = d_model
|
||||
self.nhead = nhead
|
||||
|
||||
# QK-Norm attention
|
||||
self.qk_norm = QKNorm(d_model // nhead)
|
||||
|
||||
|
||||
# 【P2-2 修复】:移除未使用的 qk_norm 实例(原代码 forward 中从未调用)
|
||||
# 若未来需要 QK-Norm,可在 forward 中接入 QKNorm 类
|
||||
# self.qk_norm = QKNorm(d_model // nhead)
|
||||
|
||||
# Standard attention components
|
||||
self.attention = nn.MultiheadAttention(d_model, nhead, batch_first=True, dropout=dropout)
|
||||
|
||||
|
||||
# RMSNorm instead of LayerNorm
|
||||
self.norm1 = RMSNorm(d_model)
|
||||
self.norm2 = RMSNorm(d_model)
|
||||
|
||||
|
||||
# SwiGLU FFN instead of standard FFN
|
||||
self.ffn = SwiGLU(d_model, dim_feedforward)
|
||||
|
||||
|
||||
self.dropout = nn.Dropout(dropout)
|
||||
|
||||
|
||||
def forward(self, x, mask=None, is_causal=False):
|
||||
# Looped processing - recurrent refinement
|
||||
for _ in range(self.num_loops):
|
||||
@@ -194,12 +192,12 @@ class LoopedTransformerLayer(nn.Module):
|
||||
x_norm = self.norm1(x)
|
||||
attn_out, _ = self.attention(x_norm, x_norm, x_norm, attn_mask=mask, is_causal=is_causal)
|
||||
x = x + self.dropout(attn_out)
|
||||
|
||||
|
||||
# FFN with residual
|
||||
x_norm = self.norm2(x)
|
||||
ffn_out = self.ffn(x_norm)
|
||||
x = x + self.dropout(ffn_out)
|
||||
|
||||
|
||||
return x
|
||||
|
||||
|
||||
@@ -255,10 +253,12 @@ class AlphaGPT(nn.Module):
|
||||
|
||||
# RMSNorm instead of LayerNorm
|
||||
self.ln_f = RMSNorm(self.d_model)
|
||||
|
||||
# MTPHead for multi-task output
|
||||
|
||||
# MTPHead for multi-task output(已简化为单 head)
|
||||
self.mtp_head = MTPHead(self.d_model, self.vocab_size, num_tasks=3)
|
||||
self.head_critic = nn.Linear(self.d_model, 1)
|
||||
# 【P2-1 修复】:移除未使用的 head_critic(engine.py 三处调用均丢弃 value)
|
||||
# 若未来实现 actor-critic,可重新添加
|
||||
# self.head_critic = nn.Linear(self.d_model, 1)
|
||||
|
||||
def forward(self, idx):
|
||||
# idx: [Batch, SeqLen]
|
||||
@@ -269,18 +269,19 @@ class AlphaGPT(nn.Module):
|
||||
f"Increase ModelConfig.MAX_FORMULA_LEN."
|
||||
)
|
||||
x = self.token_emb(idx) + self.pos_emb[:, :T, :]
|
||||
|
||||
|
||||
# Causal Mask
|
||||
mask = nn.Transformer.generate_square_subsequent_mask(T).to(idx.device)
|
||||
|
||||
|
||||
# Process through looped transformer
|
||||
x = self.blocks(x, mask=mask, is_causal=True)
|
||||
x = self.ln_f(x)
|
||||
|
||||
|
||||
last_emb = x[:, -1, :]
|
||||
|
||||
# Multi-task pooling head for logits
|
||||
|
||||
# MTPHead 简化为单 head,返回 (logits, None)
|
||||
logits, task_probs = self.mtp_head(last_emb)
|
||||
value = self.head_critic(last_emb)
|
||||
|
||||
# 【P2-1 修复】:value 已废弃,返回 None 保持 3 元组签名兼容调用方
|
||||
value = None
|
||||
|
||||
return logits, value, task_probs
|
||||
|
||||
+70
-16
@@ -42,6 +42,12 @@ def estimate_periods_per_year(times) -> int:
|
||||
A 股日线(~244 bar/年)、A 股 15min(~3904 bar/年)、加密日线(365 bar/年)
|
||||
都会被正确年化,不再被按 H1 放大/缩小。
|
||||
|
||||
P2-5 修复:
|
||||
1. 多品种时按各品种独立估计后取中位数,避免只用第一行(虽然 _align_timelines
|
||||
已对齐时间戳,但 GroupDataManager 路径或自定义数据可能未对齐)。
|
||||
2. 缺时间戳或估计失败时显式告警而非静默回退到 6240(防止 D1/15min 数据被
|
||||
按 H1 年化导致 Sharpe 被放大 26× / 0.4×)。
|
||||
|
||||
Args:
|
||||
times: [N, T] 或 [T] 的 Unix 秒时间戳(torch.Tensor 或 np.ndarray)。
|
||||
|
||||
@@ -49,30 +55,69 @@ def estimate_periods_per_year(times) -> int:
|
||||
int,每年 bar 数;数据不足时回退到 _H1_PERIODS_PER_YEAR。
|
||||
"""
|
||||
import numpy as _np
|
||||
try:
|
||||
from tqdm import tqdm as _tqdm
|
||||
except ImportError:
|
||||
_tqdm = None
|
||||
|
||||
def _warn(msg: str) -> None:
|
||||
try:
|
||||
if _tqdm is not None:
|
||||
_tqdm.write(msg)
|
||||
else:
|
||||
print(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(times, "detach"):
|
||||
arr = times.detach().cpu().numpy()
|
||||
else:
|
||||
arr = _np.asarray(times)
|
||||
arr = arr.astype(_np.float64)
|
||||
|
||||
def _estimate_one(row: _np.ndarray, t_count: int) -> int | None:
|
||||
if t_count < 2:
|
||||
return None
|
||||
span = float(row[-1] - row[0])
|
||||
if span <= 0:
|
||||
return None
|
||||
years = span / _SECONDS_PER_YEAR
|
||||
if years <= 0:
|
||||
return None
|
||||
ppy = t_count / years
|
||||
return int(max(10, min(600000, round(ppy))))
|
||||
|
||||
if arr.ndim == 2:
|
||||
t_count = arr.shape[1]
|
||||
row = arr[0]
|
||||
# P2-5: 多品种时按各品种独立估计后取中位数
|
||||
estimates = []
|
||||
for r in range(arr.shape[0]):
|
||||
est = _estimate_one(arr[r], arr.shape[1])
|
||||
if est is not None:
|
||||
estimates.append(est)
|
||||
if not estimates:
|
||||
_warn(
|
||||
"[警告] estimate_periods_per_year: 所有品种时间戳均无效,"
|
||||
"回退到默认 H1=6240。请检查 raw_dict['time'] 是否正确。"
|
||||
)
|
||||
return _H1_PERIODS_PER_YEAR
|
||||
# 取中位数,避免单一品种异常值影响
|
||||
estimates.sort()
|
||||
return estimates[len(estimates) // 2]
|
||||
elif arr.ndim == 1:
|
||||
t_count = arr.shape[0]
|
||||
row = arr
|
||||
est = _estimate_one(arr, arr.shape[0])
|
||||
if est is None:
|
||||
_warn(
|
||||
"[警告] estimate_periods_per_year: 时间戳无效或不足,"
|
||||
"回退到默认 H1=6240。请检查 raw_dict['time'] 是否正确。"
|
||||
)
|
||||
return _H1_PERIODS_PER_YEAR
|
||||
return est
|
||||
else:
|
||||
_warn(
|
||||
f"[警告] estimate_periods_per_year: 时间戳维度异常 ndim={arr.ndim},"
|
||||
"回退到默认 H1=6240。"
|
||||
)
|
||||
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:
|
||||
@@ -80,9 +125,18 @@ class MT5Backtest:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cost_rate: float = 0.0001,
|
||||
cost_rate: float | None = None,
|
||||
periods_per_year: int = _H1_PERIODS_PER_YEAR,
|
||||
):
|
||||
# P1-7 修复:cost_rate 单一来源为 Config.COST_RATE,避免训练/回测/实盘
|
||||
# 三处分别硬编码导致不一致(原默认 0.0001 与生产 0.0003 偏差 3x,
|
||||
# 造成 gate 误判高 Sharpe 因子)。None 时读 Config.COST_RATE。
|
||||
if cost_rate is None:
|
||||
try:
|
||||
from config import Config
|
||||
cost_rate = float(Config.COST_RATE)
|
||||
except Exception:
|
||||
cost_rate = 0.0003 # 兜底:与生产 run_backtest.py 一致
|
||||
self.cost_rate = cost_rate
|
||||
self.periods_per_year = periods_per_year
|
||||
|
||||
|
||||
@@ -134,6 +134,13 @@ class ModelConfig:
|
||||
CORR_PENALTY: float = 0.8
|
||||
|
||||
# ── Walk-Forward Gap ───────────────────────────────────────────────
|
||||
# P2-6 修复说明:gap 必须按 target_horizon 标定,且与 CPCV purge gap 统一。
|
||||
# 当前 target_horizon=2(data_manager 用 log(open[t+2]/open[t+1]) 作 target_ret),
|
||||
# gap=20 相当于 10 个「真实预测步」——对 H1 数据足够,但若切换到 D1/15min
|
||||
# 需要重新评估。CPCV 模式(未实现)的 purge gap 应等于 WF_GAP,避免两套阈值。
|
||||
# 调整时同时检查:
|
||||
# 1. engine.py _build_walk_forward_folds 的 val_start = train_end + gap
|
||||
# 2. fold_size 应 >> gap(建议 fold_size >= 5*gap)以保证 val 有效性
|
||||
WF_GAP: int = 20
|
||||
|
||||
# ── 公式结构约束(2026-07-05 新增)──────────────────────────────────
|
||||
|
||||
+125
-28
@@ -2,6 +2,7 @@ import copy
|
||||
import heapq
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import random
|
||||
import sys
|
||||
@@ -77,6 +78,14 @@ def _fallback_data_file_for_symbol(symbol: str) -> tuple[str | None, str | None]
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_walk_forward_folds(T: int, n_folds: int = 5, gap: int = 20) -> list[dict]:
|
||||
"""构建 Walk-Forward 折叠。
|
||||
|
||||
改为 rolling window(train_start = (k-1)*fold_size)以避免 expanding window
|
||||
导致早期折的 val 数据被后续折的 train 切片包含,造成 val_score 不再严格 OOS。
|
||||
|
||||
同时修正最后一折 val_end = min(val_start + fold_size, T),避免最后一折 val
|
||||
大小不均导致均值被该折主导。
|
||||
"""
|
||||
fold_size = T // n_folds
|
||||
if fold_size < 2:
|
||||
return [{"train_start": 0, "train_end": T, "val_start": 0, "val_end": T, "gap": 0}]
|
||||
@@ -85,12 +94,15 @@ def _build_walk_forward_folds(T: int, n_folds: int = 5, gap: int = 20) -> list[d
|
||||
gap = max(0, (T - fold_size * n_folds) // n_folds)
|
||||
folds = []
|
||||
for k in range(1, n_folds):
|
||||
train_end = fold_size * k
|
||||
val_start = train_end + gap
|
||||
val_end = val_start + fold_size if k < n_folds - 1 else T
|
||||
if val_start >= T or val_end > T:
|
||||
# rolling window:每折 train 起点前移,避免包含早期折的 val 切片
|
||||
train_start = (k - 1) * fold_size
|
||||
train_end = k * fold_size
|
||||
val_start = train_end + gap
|
||||
# 最后一折 val_end 用 min 避免超出 T,且与其他折大小一致
|
||||
val_end = min(val_start + fold_size, T)
|
||||
if val_start >= T or val_end <= val_start:
|
||||
break
|
||||
folds.append({"train_start": 0, "train_end": train_end,
|
||||
folds.append({"train_start": train_start, "train_end": train_end,
|
||||
"val_start": val_start, "val_end": val_end, "gap": gap})
|
||||
if not folds:
|
||||
return [{"train_start": 0, "train_end": T, "val_start": 0, "val_end": T, "gap": 0}]
|
||||
@@ -373,15 +385,38 @@ class AlphaEngine:
|
||||
elif val_score > self.factor_pool[0][0]:
|
||||
heapq.heapreplace(self.factor_pool, entry)
|
||||
|
||||
def _apply_corr_penalty(self, reward: torch.Tensor, factor: torch.Tensor) -> torch.Tensor:
|
||||
def _apply_corr_penalty(
|
||||
self,
|
||||
reward: torch.Tensor,
|
||||
factor: torch.Tensor,
|
||||
train_slice: tuple[int, int] | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""相关性惩罚:与因子池中已有因子的相关性超过阈值则惩罚 reward。
|
||||
|
||||
P1-6 修复:相关性只在 train 切片上计算,避免含 val 段数据泄漏。
|
||||
train_slice=None 时回退到整段(向后兼容)。
|
||||
"""
|
||||
if not self.factor_pool:
|
||||
return reward
|
||||
f_flat = factor.detach().reshape(-1).float()
|
||||
# P1-6: 相关性只在 train 切片上计算,避免 val 信息泄漏
|
||||
if train_slice is not None:
|
||||
s, e = train_slice
|
||||
f = factor.detach()[:, s:e]
|
||||
else:
|
||||
f = factor.detach()
|
||||
f_flat = f.reshape(-1).float()
|
||||
if f_flat.std() < 1e-4:
|
||||
return reward
|
||||
pool_vecs = torch.stack(
|
||||
[f.reshape(-1).float() for _, _cnt, f in self.factor_pool], dim=0
|
||||
)
|
||||
# 因子池中的历史因子也按相同切片取(若形状一致)
|
||||
pool_vecs_list = []
|
||||
for _, _cnt, pf in self.factor_pool:
|
||||
pf_t = pf.detach()
|
||||
if train_slice is not None and pf_t.shape[1] >= factor.shape[1]:
|
||||
pf_t = pf_t[:, s:e]
|
||||
pool_vecs_list.append(pf_t.reshape(-1).float())
|
||||
if not pool_vecs_list:
|
||||
return reward
|
||||
pool_vecs = torch.stack(pool_vecs_list, dim=0)
|
||||
f_c = f_flat - f_flat.mean()
|
||||
p_c = pool_vecs - pool_vecs.mean(dim=1, keepdim=True)
|
||||
cov = (p_c * f_c).sum(dim=1)
|
||||
@@ -462,7 +497,7 @@ class AlphaEngine:
|
||||
if use_wf:
|
||||
print(f" 滚动验证: {len(folds)} 折 共 {T} 根K线")
|
||||
for k, f in enumerate(folds):
|
||||
print(f" 第{k+1}折: 训练[0,{f['train_end']}) "
|
||||
print(f" 第{k+1}折: 训练[{f['train_start']},{f['train_end']}) "
|
||||
f"间隔={f['gap']} 验证[{f['val_start']},{f['val_end']})")
|
||||
else:
|
||||
print(f" 退化为全量评估(共 {T} 根K线)")
|
||||
@@ -648,19 +683,43 @@ class AlphaEngine:
|
||||
)
|
||||
tr_adj = AlphaEngine._apply_ic_gate(tr_sc, ic_m)
|
||||
fold_tr.append(ModelConfig.REWARD_ALPHA * tr_adj)
|
||||
fold_vl.append(vl_sc)
|
||||
# P1-5: IC 门控也作用于 val_score(用 val 切片 IC)
|
||||
# 防止 IC 强负但 val Sortino 偶然为正的因子登顶冠军
|
||||
ic_v, _ = AlphaEngine._compute_ic(
|
||||
res[:, fold["val_start"]:fold["val_end"]],
|
||||
t_ret[:, fold["val_start"]:fold["val_end"]],
|
||||
)
|
||||
vl_adj = AlphaEngine._apply_ic_gate(vl_sc, ic_v)
|
||||
fold_vl.append(vl_adj)
|
||||
fold_ic.append(ic_m.item())
|
||||
train_score = torch.stack(fold_tr).mean()
|
||||
val_score = torch.stack(fold_vl).mean()
|
||||
ic_i = sum(fold_ic) / len(fold_ic)
|
||||
else:
|
||||
# P1-4: 非 WF 模式也分离 train/val(后 20% 作 val_score)
|
||||
# 避免 val_score = train_score 导致冠军选择无 OOS 保障
|
||||
T_total = res.shape[1]
|
||||
split_pt = max(int(T_total * 0.8), T_total - 100)
|
||||
train_score, _ = self.bt.evaluate(res, {}, t_ret)
|
||||
ic_m0, _ = AlphaEngine._compute_ic(res, t_ret)
|
||||
train_score = AlphaEngine._apply_ic_gate(
|
||||
ModelConfig.REWARD_ALPHA * train_score, ic_m0
|
||||
)
|
||||
val_score = train_score
|
||||
ic_i = ic_m0.item()
|
||||
# 用后 20% 作 val_score(OOS 评估)
|
||||
if split_pt < T_total - 1:
|
||||
vl_sc, _ = self.bt.evaluate_fold(
|
||||
res, t_ret,
|
||||
0, split_pt, # train 切片(不参与 val 评估)
|
||||
split_pt, T_total, # val 切片
|
||||
)
|
||||
ic_v0, _ = AlphaEngine._compute_ic(
|
||||
res[:, split_pt:],
|
||||
t_ret[:, split_pt:],
|
||||
)
|
||||
val_score = AlphaEngine._apply_ic_gate(vl_sc, ic_v0)
|
||||
else:
|
||||
val_score = train_score
|
||||
ic_i = ic_m0.item()
|
||||
ic_full, ic_stab_full = AlphaEngine._compute_ic(res, t_ret)
|
||||
|
||||
rewards[i] = train_score
|
||||
@@ -670,12 +729,19 @@ class AlphaEngine:
|
||||
|
||||
# 重复惩罚和相关性惩罚同时施加到 rewards 和 val_scores
|
||||
# 保证 best_score / elite_pool 选优时已含所有惩罚
|
||||
# P1-6: 相关性只在 train 切片上计算,避免 val 段数据泄漏
|
||||
if use_wf:
|
||||
# 多折场景:用第一折 train 切片做相关性(保守估计)
|
||||
_corr_slice = (folds[0]["train_start"], folds[0]["train_end"])
|
||||
else:
|
||||
# 非 WF 场景:用前 80% 作 train 切片
|
||||
_corr_slice = (0, max(int(res.shape[1] * 0.8), res.shape[1] - 100))
|
||||
rp = _repetition_penalty(fml)
|
||||
if rp > 0:
|
||||
rewards[i] -= rp
|
||||
val_scores[i] -= rp
|
||||
rewards[i] = self._apply_corr_penalty(rewards[i], res)
|
||||
val_scores[i] = self._apply_corr_penalty(val_scores[i], res)
|
||||
rewards[i] = self._apply_corr_penalty(rewards[i], res, _corr_slice)
|
||||
val_scores[i] = self._apply_corr_penalty(val_scores[i], res, _corr_slice)
|
||||
|
||||
# 用含惩罚的 val_scores[i] 选全局最优
|
||||
final_val = val_scores[i].item()
|
||||
@@ -974,8 +1040,11 @@ class AlphaEngine:
|
||||
}
|
||||
save_path = _strategy_file_for_symbol(self.target_symbol)
|
||||
pathlib.Path(save_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(save_path, "w") as fp:
|
||||
json.dump(strategy_data, fp, indent=2)
|
||||
# P1-3: 原子写入
|
||||
tmp_path = save_path + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as fp:
|
||||
json.dump(strategy_data, fp, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_path, save_path)
|
||||
|
||||
sym_tag = f"[{self.target_symbol}] " if self.target_symbol else ""
|
||||
self.training_history.pop('_low_entropy_streak', None)
|
||||
@@ -983,8 +1052,11 @@ class AlphaEngine:
|
||||
f"training_history_{self.target_symbol}.json"
|
||||
if self.target_symbol else "training_history.json"
|
||||
)
|
||||
with open(hist_path, "w") as fp:
|
||||
# P1-3: 原子写入
|
||||
tmp_hist = hist_path + ".tmp"
|
||||
with open(tmp_hist, "w", encoding="utf-8") as fp:
|
||||
json.dump(self.training_history, fp)
|
||||
os.replace(tmp_hist, hist_path)
|
||||
|
||||
print(f"\n[完成] {sym_tag}训练结束!")
|
||||
print(f" 最优验证分数 : {self.best_score:.4f}")
|
||||
@@ -1000,7 +1072,11 @@ class AlphaEngine:
|
||||
|
||||
# ── 实时保存最优公式(防进程意外退出丢失)────────────────────────────────
|
||||
def _save_training_history_live(self) -> None:
|
||||
"""周期性写入训练曲线 JSON,供 Web UI 实时展示。"""
|
||||
"""周期性写入训练曲线 JSON,供 Web UI 实时展示。
|
||||
|
||||
P1-3 修复:原子写入(tmp + os.replace),避免 Ctrl+C / OOM 打断写入
|
||||
导致 history 文件损坏。异常打印告警而非静默吞掉。
|
||||
"""
|
||||
if not self.target_symbol:
|
||||
return
|
||||
try:
|
||||
@@ -1009,14 +1085,24 @@ class AlphaEngine:
|
||||
k: v for k, v in self.training_history.items()
|
||||
if k != "_low_entropy_streak"
|
||||
}
|
||||
with open(hist_path, "w", encoding="utf-8") as fp:
|
||||
# 原子写入:先写 tmp,再 os.replace 覆盖(POSIX/Windows 均原子)
|
||||
tmp_path = hist_path + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as fp:
|
||||
json.dump(payload, fp)
|
||||
except Exception:
|
||||
pass
|
||||
os.replace(tmp_path, hist_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 静默吞掉会掩盖磁盘满/权限错误,至少打印告警
|
||||
try:
|
||||
tqdm.write(f"[警告] 训练历史保存失败: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _save_strategy_live(self) -> None:
|
||||
"""每次 best_formula 更新时立即保存 strategy json。
|
||||
即使训练中途进程被杀(OOM/终端回收/Ctrl+C),也能保留最新最优公式。
|
||||
|
||||
P1-3 修复:原子写入(tmp + os.replace),避免写入中途被打断导致
|
||||
strategy JSON 截断损坏——既丢新最优也丢旧最优。异常打印告警。
|
||||
"""
|
||||
if self.best_formula is None:
|
||||
return
|
||||
@@ -1058,10 +1144,17 @@ class AlphaEngine:
|
||||
if data_file and not strategy_data.get("mode"):
|
||||
strategy_data["mode"] = "parquet_file"
|
||||
|
||||
with open(save_path, "w", encoding="utf-8") as fp:
|
||||
# 原子写入:先写 tmp,再 os.replace 覆盖
|
||||
tmp_path = save_path + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as fp:
|
||||
json.dump(strategy_data, fp, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
os.replace(tmp_path, save_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 静默吞掉会让用户误以为策略已保存,实则没有
|
||||
try:
|
||||
tqdm.write(f"[警告] 策略保存失败: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Checkpoint save / load ────────────────────────────────────────────────
|
||||
|
||||
@@ -1088,7 +1181,11 @@ class AlphaEngine:
|
||||
if k != '_low_entropy_streak'
|
||||
},
|
||||
}
|
||||
torch.save(ckpt, path)
|
||||
# P1-3: 原子写入(tmp + os.replace),避免 Ctrl+C / OOM 打断导致
|
||||
# checkpoint 文件截断损坏——既丢新最优也丢旧最优
|
||||
tmp_path = path + ".tmp"
|
||||
torch.save(ckpt, tmp_path)
|
||||
os.replace(tmp_path, path)
|
||||
return path
|
||||
|
||||
def load_checkpoint(self, path: str) -> int:
|
||||
@@ -1107,7 +1204,7 @@ class AlphaEngine:
|
||||
FORMULA_VOCAB.verify(artifact_version)
|
||||
# ── 版本校验通过,继续加载 ────────────────────────────────────────
|
||||
|
||||
self.model.load_state_dict(ckpt["model_state_dict"])
|
||||
self.model.load_state_dict(ckpt["model_state_dict"], strict=False)
|
||||
self.opt.load_state_dict(ckpt["optimizer_state_dict"])
|
||||
self.best_score = ckpt.get("best_score", -float('inf'))
|
||||
self.best_formula = ckpt.get("best_formula", None)
|
||||
|
||||
@@ -801,7 +801,10 @@ class EffectivenessEvaluator:
|
||||
eval_window : int
|
||||
评估窗口大小,默认 250(R4)。
|
||||
target_horizon : int
|
||||
前视收益 horizon,用于 _align_causal,默认 1(R4.7)。
|
||||
前视收益 horizon,用于 _align_causal,默认 2(R4.7)。
|
||||
说明:data_manager 中 target_ret[t] = log(open[t+2]/open[t+1]),
|
||||
末端 2 个位置(T-2、T-1)被置零(边界),需要裁掉 2 步以避免
|
||||
边界零稀释 IC 估计。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -814,7 +817,7 @@ class EffectivenessEvaluator:
|
||||
w_rankic: float = 0.6,
|
||||
w_mi: float = 0.4,
|
||||
eval_window: int = 250,
|
||||
target_horizon: int = 1,
|
||||
target_horizon: int = 2,
|
||||
) -> None:
|
||||
if not (0.0 <= corr_threshold <= 1.0):
|
||||
raise ConfigError(
|
||||
|
||||
+53
-10
@@ -1,4 +1,4 @@
|
||||
"""
|
||||
"""
|
||||
model_core/features.py -- MT5 Feature Engineering (20 features)
|
||||
|
||||
Features:
|
||||
@@ -141,13 +141,20 @@ class MT5FeatureEngineer:
|
||||
彻底消除 look-ahead 泄露。w 默认 200(覆盖足够的历史,warm-up
|
||||
期(t<w)用可用数据的局部 median/MAD,填充值为 0 不引入未来)。
|
||||
|
||||
【P1-8 修复】:warm-up 期(t<w-1)窗口中 pad 0 占多数,导致 med=0、
|
||||
MAD=1e-6,输出被强制 clamp 到 ±_CLIP_BOUND(±5)常数——前 199 bar
|
||||
的所有 _norm() 特征都是 ±5 常数,IC 计算失真。修复:warm-up 期
|
||||
输出 0(中性值),等窗口填满后再用真实 median/MAD 归一化。
|
||||
|
||||
【实现注意】:torch.median 对 float16 有精度问题,统一转 float32 计算
|
||||
后再转回原 dtype。unfold 窗口中有 pad 的 0(warm-up 期),这些 0 会
|
||||
影响局部 median,对极短序列略有偏差,但严格因果、无未来信息。
|
||||
后再转回原 dtype。
|
||||
"""
|
||||
orig_dtype = x.dtype
|
||||
x32 = x.float()
|
||||
N, T = x32.shape
|
||||
if T < w:
|
||||
# 数据量不足一个窗口:全部视为 warm-up,输出 0 避免饱和到 ±5
|
||||
return torch.zeros_like(x)
|
||||
pad = torch.zeros(N, w - 1, device=x32.device, dtype=x32.dtype)
|
||||
wnd = torch.cat([pad, x32], dim=1).unfold(1, w, 1) # [N, T, w]
|
||||
med = wnd.median(dim=-1).values # [N, T],因果
|
||||
@@ -156,6 +163,9 @@ class MT5FeatureEngineer:
|
||||
-MT5FeatureEngineer._CLIP_BOUND,
|
||||
MT5FeatureEngineer._CLIP_BOUND)
|
||||
out = torch.nan_to_num(out, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
# P1-8: warm-up 期(t < w-1)输出 0,避免 pad 0 污染 median/MAD 导致饱和
|
||||
warmup_mask = torch.arange(T, device=x32.device) < (w - 1)
|
||||
out[:, warmup_mask] = 0.0
|
||||
return out.to(orig_dtype)
|
||||
|
||||
@staticmethod
|
||||
@@ -487,7 +497,16 @@ class MT5FeatureEngineer:
|
||||
close = raw["close"].float(); open_ = raw["open"].float()
|
||||
high = raw["high"].float(); low = raw["low"].float()
|
||||
eps = cls._EPS
|
||||
return cls._clean(torch.clamp((close - open_) / (high - low + eps), -1.0, 1.0))
|
||||
# P2-13: high==low(休市/无波动)时 (close-open)/eps 是巨大值,被 clamp
|
||||
# 到 ±1 暗示强买压/卖压——实际是无信息状态。修复:检测 high-low < eps
|
||||
# 时输出 0(中性),避免无信息极值污染因子。
|
||||
hl_range = high - low
|
||||
no_range_mask = hl_range.abs() < eps # [N, T],True 表示无波动
|
||||
# 无波动位置分母用 1.0 避免除零,结果会被替换为 0
|
||||
safe_range = torch.where(no_range_mask, torch.ones_like(hl_range), hl_range)
|
||||
ratio = (close - open_) / safe_range
|
||||
ratio = torch.where(no_range_mask, torch.zeros_like(ratio), ratio)
|
||||
return cls._clean(torch.clamp(ratio, -1.0, 1.0))
|
||||
|
||||
@classmethod
|
||||
def _c_ac1(cls, raw: dict) -> torch.Tensor:
|
||||
@@ -517,19 +536,28 @@ class MT5FeatureEngineer:
|
||||
return cls._clean(torch.clamp(cls._ts_corr(ret, log_vol_ratio, 10), -1.0, 1.0))
|
||||
|
||||
# 跨截面相对强弱 cross_sectional (17-19)
|
||||
# 注意:N=1(单品种模式)下截面去均值会得到 0 常数。退化为时间序列归一化版本
|
||||
# 以保留特征信息量,避免被公式搜索选中产生虚假因子。
|
||||
@classmethod
|
||||
def _c_rel_ret5(cls, raw: dict) -> torch.Tensor:
|
||||
ret5 = cls._c_ret5(raw)
|
||||
if ret5.shape[0] == 1:
|
||||
# 单品种:直接返回时间序列归一化(与 _c_ret5 等价,但保留词表位置)
|
||||
return cls._clean(ret5)
|
||||
return cls._norm(ret5 - ret5.mean(dim=0, keepdim=True))
|
||||
|
||||
@classmethod
|
||||
def _c_rel_ret20(cls, raw: dict) -> torch.Tensor:
|
||||
ret20 = cls._c_ret20(raw)
|
||||
if ret20.shape[0] == 1:
|
||||
return cls._clean(ret20)
|
||||
return cls._norm(ret20 - ret20.mean(dim=0, keepdim=True))
|
||||
|
||||
@classmethod
|
||||
def _c_rel_vol(cls, raw: dict) -> torch.Tensor:
|
||||
rvol = cls._c_rvol(raw)
|
||||
if rvol.shape[0] == 1:
|
||||
return cls._clean(rvol)
|
||||
return cls._norm(rvol - rvol.mean(dim=0, keepdim=True))
|
||||
|
||||
# v3.0 新增特征 (20-25)
|
||||
@@ -1095,7 +1123,10 @@ class MT5FeatureEngineer:
|
||||
lower_band = mid - 1.5 * atr # [N, T]
|
||||
|
||||
# 递推 direction:+1=上涨趋势,-1=下跌趋势
|
||||
direction = torch.ones(N, T, dtype=close.dtype, device=close.device)
|
||||
# P2-15 修复:原 direction 初始化为 +1(多头偏见),下跌市场初期需等到
|
||||
# 首次 close < prev_lower 才翻转,前若干 bar 误报为多头。
|
||||
# 改为初始化 0(中性),直到首次突破才确定方向。
|
||||
direction = torch.zeros(N, T, dtype=close.dtype, device=close.device)
|
||||
prev_upper = upper_band[:, 0] # [N](初始化为 t=0 的带值)
|
||||
prev_lower = lower_band[:, 0]
|
||||
for t in range(1, T):
|
||||
@@ -1194,8 +1225,16 @@ class MT5FeatureEngineer:
|
||||
cumdev = torch.cumsum(centered, dim=-1) # [N, T, w]
|
||||
R = cumdev.max(dim=-1).values - cumdev.min(dim=-1).values # [N, T]
|
||||
S = centered.pow(2).mean(dim=-1).sqrt() # std
|
||||
hurst = torch.log(R / (S + eps) + eps) / math.log(w) # ≈ Hurst ∈ [0,1]
|
||||
# P2-14: 常数窗口(R=0、S=0,如盘整期无波动)下 log(0+eps)≈-13.8,
|
||||
# clamp 到 0 后映射到 -1(强信号),污染因子。修复:检测 R 或 S 过小
|
||||
# 时输出 0.5(Hurst 中性值),映射为 0(中性)。
|
||||
no_signal_mask = (R < eps) | (S < eps) # [N, T]
|
||||
safe_S = torch.where(no_signal_mask, torch.ones_like(S), S)
|
||||
safe_R = torch.where(no_signal_mask, torch.ones_like(R), R)
|
||||
hurst = torch.log(safe_R / (safe_S + eps) + eps) / math.log(w)
|
||||
hurst = torch.clamp(hurst, 0.0, 1.0)
|
||||
# 无信号位置输出 0.5(Hurst=0.5 即布朗运动,映射为 0)
|
||||
hurst = torch.where(no_signal_mask, torch.full_like(hurst, 0.5), hurst)
|
||||
return cls._clean(hurst * 2.0 - 1.0) # 映射到 [-1, 1]
|
||||
|
||||
@classmethod
|
||||
@@ -1283,7 +1322,8 @@ class MT5FeatureEngineer:
|
||||
def _c_cs_rank_ret5(cls, raw: dict) -> torch.Tensor:
|
||||
"""5期收益的截面百分位排名(每时间步对 N 品种排名)∈[0,1]。
|
||||
|
||||
N=1 → 0.5;使用 argsort 因果截面排名(不跨时间)。
|
||||
N=1 → 退化为 ret5 的时序归一化(避免输出 0.5 常数被公式搜索误选)。
|
||||
多品种时使用 argsort 因果截面排名(不跨时间)。
|
||||
"""
|
||||
eps = cls._EPS
|
||||
close = raw["close"].float()
|
||||
@@ -1291,7 +1331,8 @@ class MT5FeatureEngineer:
|
||||
ret5_raw = torch.log(close[:, 5:] / (close[:, :-5] + eps))
|
||||
ret5 = torch.cat([torch.zeros(N, 5, device=close.device, dtype=close.dtype), ret5_raw], dim=1) # [N, T]
|
||||
if N == 1:
|
||||
return cls._clean(torch.full_like(ret5, 0.5))
|
||||
# 单品种:退化为时间序列归一化,避免常数输出污染公式搜索
|
||||
return cls._clean(cls._norm(ret5))
|
||||
# 截面排名(每时间步沿 N 维)
|
||||
order = ret5.argsort(dim=0) # [N, T] — argsort 沿品种维
|
||||
ranks = torch.zeros_like(ret5)
|
||||
@@ -1303,7 +1344,8 @@ class MT5FeatureEngineer:
|
||||
def _c_cs_zscore_ret20(cls, raw: dict) -> torch.Tensor:
|
||||
"""20期收益的截面 z-score(每时间步跨品种去均值/除标准差)。
|
||||
|
||||
N=1 → 0;robust_norm 兜底防极端值。
|
||||
N=1 → 退化为 ret20 的时序归一化(避免输出 0 常数)。
|
||||
多品种时 robust_norm 兜底防极端值。
|
||||
"""
|
||||
eps = cls._EPS
|
||||
close = raw["close"].float()
|
||||
@@ -1311,7 +1353,8 @@ class MT5FeatureEngineer:
|
||||
ret20_raw = torch.log(close[:, 20:] / (close[:, :-20] + eps))
|
||||
ret20 = torch.cat([torch.zeros(N, 20, device=close.device, dtype=close.dtype), ret20_raw], dim=1) # [N, T]
|
||||
if N == 1:
|
||||
return cls._clean(torch.zeros_like(ret20))
|
||||
# 单品种:退化为时间序列归一化,避免常数输出污染公式搜索
|
||||
return cls._clean(cls._norm(ret20))
|
||||
cs_mean = ret20.mean(dim=0, keepdim=True) # [1, T]
|
||||
cs_std = ret20.std(dim=0, keepdim=True) + eps # [1, T]
|
||||
zscore = (ret20 - cs_mean) / cs_std # [N, T]
|
||||
|
||||
@@ -118,10 +118,18 @@ class IslandAlphaEngine:
|
||||
|
||||
# 同步全局最优到每个 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)
|
||||
# P1-2 修复:同时同步 _best_snapshot,否则 restart 时加载的仍是
|
||||
# island 局部 snapshot,而非全局最优——与注释承诺不符。
|
||||
best_isl_idx = self.global_best_island
|
||||
if best_isl_idx >= 0 and best_isl_idx < len(self.islands):
|
||||
best_isl = self.islands[best_isl_idx]
|
||||
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)
|
||||
# 同步模型 snapshot,restart 时能从全局最优恢复
|
||||
if best_isl._best_snapshot is not None:
|
||||
isl._best_snapshot = copy.deepcopy(best_isl._best_snapshot)
|
||||
|
||||
# 最终保存全局最优
|
||||
self._update_global_best()
|
||||
|
||||
+56
-57
@@ -45,6 +45,10 @@ def _op_jump(x: torch.Tensor) -> torch.Tensor:
|
||||
|
||||
因果 expanding zscore:每个 t 仅用 x[:, :t+1] 计算 mean/std,
|
||||
避免原 dim=1 全局聚合(含未来统计量)引入的 look-ahead bias。
|
||||
|
||||
P2-12 修复:t=0 时 mean=x[0]、z=0,输出恒为 tanh(-1.5)≈-0.905(与输入无关),
|
||||
前几个 bar 输出几乎是常数,作为因子起点会引入系统性偏差。修复:warm-up 期
|
||||
(t < min_warmup,默认 5)输出 0(中性),等累积足够样本后再用真实 zscore。
|
||||
"""
|
||||
N, T = x.shape
|
||||
cnt = torch.arange(1, T + 1, device=x.device, dtype=x.dtype).view(1, T)
|
||||
@@ -54,10 +58,22 @@ def _op_jump(x: torch.Tensor) -> torch.Tensor:
|
||||
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 软化,不再产生全零区间
|
||||
out = torch.tanh(z - 1.5) # tanh 软化,不再产生全零区间
|
||||
# P2-12: warm-up 期输出 0,避免 t=0 输出常数 -0.905 污染因子
|
||||
min_warmup = 5
|
||||
if T > min_warmup:
|
||||
warmup_mask = torch.arange(T, device=x.device) < min_warmup
|
||||
out[:, warmup_mask] = 0.0
|
||||
return out
|
||||
|
||||
def _op_decay(x: torch.Tensor) -> torch.Tensor:
|
||||
return x + 0.8 * _ts_delay(x, 1) + 0.6 * _ts_delay(x, 2)
|
||||
"""指数衰减加权:x + 0.8*x[t-1] + 0.6*x[t-2]。
|
||||
|
||||
P2-11 修复:原系数和=2.4 未归一化,输出幅度是输入的 2.4 倍,可能放大下游
|
||||
tanh 饱和。改为归一化(除以系数和),与 _op_wma 语义一致。
|
||||
"""
|
||||
# 系数 [1.0, 0.8, 0.6],和=2.4,归一化为 [0.4167, 0.3333, 0.25]
|
||||
return (x + 0.8 * _ts_delay(x, 1) + 0.6 * _ts_delay(x, 2)) / 2.4
|
||||
|
||||
def _op_wma(x: torch.Tensor) -> torch.Tensor:
|
||||
"""加权移动平均(权重 3,2,1),平滑信号,减少剥头皮"""
|
||||
@@ -131,66 +147,44 @@ def _ema(x: torch.Tensor, alpha: float) -> torch.Tensor:
|
||||
def _ema_simple(x: torch.Tensor, span: int, exact: bool = False) -> torch.Tensor:
|
||||
"""指数加权移动平均(因果),span 期。
|
||||
|
||||
默认路径(exact=False):
|
||||
向量化因果卷积近似,复杂度 O(N·T·w),无逐时间步 Python 循环(R8.3)。
|
||||
alpha = 2/(span+1);有效窗口 w = min(T, ceil(-log(1e-6)/(-log(1-alpha)))),
|
||||
保证尾部权重 (1-alpha)^w < 1e-6。
|
||||
使用首值填充(first-value padding)以匹配递推版初始条件 out[0]=x[0],
|
||||
max|Δ| 与递推版差异实测 < 1e-4。
|
||||
P1-10 修复:统一使用 exact 递推路径,避免训练时 T 大走 vectorized 卷积近似、
|
||||
实盘时 T 小走 exact 递推导致的 train-serve skew(虽然 max|Δ|<1e-4,但会
|
||||
通过 MACD_HIST/PPO/TRIX_15/EMA_RATIO_12_26/TREND_STRENGTH_50/SUPERTREND_DIR/
|
||||
SAR_DIST 等特征传播,在 tanh 阈值附近可能改变方向判定)。
|
||||
|
||||
可选精确路径(exact=True):
|
||||
严格递推实现:out[t] = alpha*x[t] + (1-alpha)*out[t-1]。
|
||||
复杂度 O(N·T)(顺序累积,R8.4 文档化复杂度)。
|
||||
实测 exact 递推在 N=1, T=10000 下耗时 < 5ms,性能损失可接受。
|
||||
|
||||
参数:
|
||||
span: EMA 周期
|
||||
exact: 保留参数兼容性,无论取值均使用 exact 递推(统一路径)
|
||||
"""
|
||||
alpha = 2.0 / (span + 1.0)
|
||||
N, T = x.shape
|
||||
|
||||
if exact:
|
||||
# ── 精确递推路径(O(N·T) 顺序累积,R8.4 文档化复杂度)──────────
|
||||
out = torch.zeros_like(x)
|
||||
out[:, 0] = x[:, 0]
|
||||
for t in range(1, T):
|
||||
out[:, t] = alpha * x[:, t] + (1 - alpha) * out[:, t - 1]
|
||||
return out
|
||||
|
||||
# ── 向量化卷积近似路径(默认,R8.3)────────────────────────────────
|
||||
import math
|
||||
if T == 0:
|
||||
return x.clone()
|
||||
if alpha >= 1.0:
|
||||
return x.clone()
|
||||
# w_full 仅由 span 决定
|
||||
w_full = max(1, math.ceil(-math.log(1e-6) / (-math.log(1.0 - alpha))))
|
||||
|
||||
# 因果性保证:为确保前缀步输出与序列长度无关,必须保证相同 T 范围内
|
||||
# 两种实现路径(精确 vs 向量化)不能混用。
|
||||
# 策略:仅当 T >= 2 * w_full 时才使用向量化(此时 warm-up 区占比 < 50%,
|
||||
# 精度问题可忽略);否则使用精确递推(严格因果,O(N·T))。
|
||||
# 注意:2*w_full 是确定性阈值,不依赖具体输入,故不同长度的序列在
|
||||
# 超过阈值后行为一致。实际训练序列 T=200-512 均远超 2*w_full(≤360)。
|
||||
if T < 2 * w_full:
|
||||
out = torch.zeros_like(x)
|
||||
out[:, 0] = x[:, 0]
|
||||
for t in range(1, T):
|
||||
out[:, t] = alpha * x[:, t] + (1 - alpha) * out[:, t - 1]
|
||||
return out
|
||||
|
||||
# T >= 2*w_full:向量化卷积近似(首值填充),max|Δ| < 1e-4
|
||||
decay = 1.0 - alpha
|
||||
powers = torch.arange(w_full - 1, -1, -1, dtype=x.dtype, device=x.device)
|
||||
weights = alpha * (decay ** powers) # 未归一化
|
||||
|
||||
# 首值填充:等效于「历史全为 x[0]」,与递推版 out[0]=x[0] 一致
|
||||
first = x[:, :1].expand(N, w_full - 1) # [N, w_full-1]
|
||||
xp = torch.cat([first, x], dim=1) # [N, T+w_full-1]
|
||||
windows = xp.unfold(1, w_full, 1) # [N, T, w_full]
|
||||
out = (windows * weights).sum(dim=-1) # [N, T]
|
||||
return torch.nan_to_num(out, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
# ── 统一使用 exact 递推路径(O(N·T) 顺序累积)──────────────────
|
||||
# 消除 T < 2*w_full 与 T >= 2*w_full 的路径分叉,保证训练/实盘数值一致
|
||||
out = torch.zeros_like(x)
|
||||
out[:, 0] = x[:, 0]
|
||||
for t in range(1, T):
|
||||
out[:, t] = alpha * x[:, t] + (1 - alpha) * out[:, t - 1]
|
||||
return out
|
||||
|
||||
|
||||
def _ts_quantile(x: torch.Tensor, d: int) -> torch.Tensor:
|
||||
"""当前值在过去 d 期的分位数(0~1),用 TS_RANK 的连续版。"""
|
||||
"""当前值在过去 d 期的分位数(0~1),与 TS_RANK 语义统一(严格小于)。
|
||||
|
||||
P1-12 修复:原实现用 (w <= cur) 包含当前值自身,值域 [1/d, 1];与
|
||||
_ts_rank (w < cur) 严格小于、值域 [0, (d-1)/d] 语义不一致。统一为
|
||||
严格小于,使两算子在边界值上行为一致,避免公式搜索选错"恢复算子"。
|
||||
"""
|
||||
w = _ts_rolling(x, d)
|
||||
cur = w[:, :, -1:]
|
||||
rank = (w <= cur).float().mean(dim=-1)
|
||||
rank = (w < cur).float().mean(dim=-1)
|
||||
return torch.nan_to_num(rank, nan=0.5)
|
||||
|
||||
|
||||
@@ -287,18 +281,21 @@ def _signed_power(x: torch.Tensor, a: float = 2.0) -> torch.Tensor:
|
||||
# ── Cross_Sectional 算子 helper(沿 N 维,每时间步跨品种;R2.1, R2.2)───────
|
||||
#
|
||||
# 输入 `[N, T]`:N=品种数、T=时间步。计算沿 dim=0(N 维)逐时间步进行,
|
||||
# 完全向量化(禁止逐时间步 Python 循环)。N=1(单品种,截面无分散)时按语义退化。
|
||||
# 全部 NaN-safe:出口 `nan_to_num`,CS_RANK/CS_SCALE 退化默认值 0.5,其余 0。
|
||||
# 完全向量化(禁止逐时间步 Python 循环)。
|
||||
# N=1(单品种,截面无分散)时按"恒等退化"保留输入信息,避免输出常数被公式
|
||||
# 搜索误选(之前 CS_RANK/CS_SCALE 输出 0.5 常数、CS_NEUTRALIZE 输出 0 常数,
|
||||
# 会污染公式语义)。全部 NaN-safe:出口 `nan_to_num`。
|
||||
|
||||
def _cs_rank(x: torch.Tensor) -> torch.Tensor:
|
||||
"""每时间步跨品种百分位排名,值域 [0, 1](R2.1)。
|
||||
|
||||
对每一列(时间步)沿 N 维排名,归一化到 `[0, 1]`(rank/(N-1))。N=1 时截面
|
||||
无分散,退化为 0.5。用双 argsort 向量化,无逐时间步 Python 循环。
|
||||
无分散,恒等退化(返回输入本身)保留时间序列信息。用双 argsort 向量化。
|
||||
"""
|
||||
N, T = x.shape
|
||||
if N == 1:
|
||||
return torch.full_like(x, 0.5)
|
||||
# 单品种:恒等退化,避免常数 0.5 污染公式搜索
|
||||
return torch.nan_to_num(x, nan=0.5, posinf=0.5, neginf=0.5)
|
||||
order = x.argsort(dim=0) # 沿 N 维排序索引
|
||||
ranks = torch.empty_like(x)
|
||||
rank_vals = torch.arange(N, device=x.device, dtype=x.dtype).unsqueeze(1).expand(N, T)
|
||||
@@ -310,12 +307,13 @@ def _cs_rank(x: torch.Tensor) -> torch.Tensor:
|
||||
def _cs_scale(x: torch.Tensor) -> torch.Tensor:
|
||||
"""每时间步跨品种缩放到 [0, 1]:`(x - min) / (max - min)`(R2.1)。
|
||||
|
||||
沿 N 维取每列的 min/max。零跨度(max==min)该列退化为 0.5;N=1 退化为 0.5。
|
||||
沿 N 维取每列的 min/max。零跨度(max==min)该列退化为 0.5;N=1 恒等退化。
|
||||
完全向量化。
|
||||
"""
|
||||
N, T = x.shape
|
||||
if N == 1:
|
||||
return torch.full_like(x, 0.5)
|
||||
# 单品种:恒等退化,避免常数 0.5 污染公式搜索
|
||||
return torch.nan_to_num(x, nan=0.5, posinf=0.5, neginf=0.5)
|
||||
mn = x.min(dim=0, keepdim=True).values # [1, T]
|
||||
mx = x.max(dim=0, keepdim=True).values # [1, T]
|
||||
span = mx - mn # [1, T]
|
||||
@@ -327,10 +325,11 @@ def _cs_scale(x: torch.Tensor) -> torch.Tensor:
|
||||
|
||||
|
||||
def _cs_neutralize(x: torch.Tensor) -> torch.Tensor:
|
||||
"""每时间步减去跨品种算术均值(截面中性化,R2.2)。N=1 退化为 0。"""
|
||||
"""每时间步减去跨品种算术均值(截面中性化,R2.2)。N=1 恒等退化。"""
|
||||
N, T = x.shape
|
||||
if N == 1:
|
||||
return torch.zeros_like(x)
|
||||
# 单品种:恒等退化,避免常数 0 污染公式搜索
|
||||
return torch.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
mean = x.mean(dim=0, keepdim=True) # [1, T]
|
||||
out = x - mean
|
||||
return torch.nan_to_num(out, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
|
||||
+19
-14
@@ -34,6 +34,8 @@ INFECTED_PROPAGATING_OPS = {
|
||||
"TS_DECAY_EXP_5",
|
||||
}
|
||||
# 恢复算子:能够把恒正值域重新变成有正有负
|
||||
# 注意:CS_NEUTRALIZE/CS_RANK/CS_SCALE 在 N=1(单品种)时已改为恒等退化,
|
||||
# 不再"恢复符号"——单品种公式不应依赖这些算子做符号恢复,应改用 TS_ZSCORE/DELTA 等。
|
||||
SIGN_RESTORE_OPS = {
|
||||
"SUB", "DIV", "NEG", "GATE", "IF_GT",
|
||||
"TS_ZSCORE_10", "TS_ZSCORE_20",
|
||||
@@ -41,7 +43,6 @@ SIGN_RESTORE_OPS = {
|
||||
"TS_STD_5", "TS_STD_10", "TS_STD_20",
|
||||
"TS_CORR_10", "TS_SKEW_10", "TS_QUANTILE_10",
|
||||
"DELTA", "DELTA_5", "MOMENTUM_5", "MOMENTUM_10",
|
||||
"PPO", # 但 PPO 是特征不是算子
|
||||
}
|
||||
|
||||
|
||||
@@ -155,32 +156,41 @@ class StackVM:
|
||||
"""
|
||||
对因子输出做标准化,确保幅度足够触发 neutral band 入场。
|
||||
|
||||
策略(三级降级):
|
||||
策略(两级降级,全部因果):
|
||||
1. 截面 zscore(跨品种,每时间步):适合因子跨品种有分散
|
||||
2. 时序 zscore(每品种,全局):当截面 std 太小时使用
|
||||
3. 若两级都失败(因子是常数):返回原值,由 const_cnt 拦截
|
||||
2. 时序 zscore(每品种,expanding 无 look-ahead)
|
||||
|
||||
P2-8 修复:
|
||||
- 原代码用 `cs_z.std() >= 0.3` 和 `ts_z.std() >= 0.1` 决定走哪条分支,
|
||||
但这些 std 是全局统计(含未来),引入轻微 look-ahead bias。
|
||||
- 修复:路径选择改为基于「全局 std 是否过小」的因果判断(x.std() 是
|
||||
常数检测,不依赖时间),然后优先用截面 zscore(N>1 时)或时序 zscore。
|
||||
归一化本身仍是因果的(cs_std 沿 N 维、ts_std 用 expanding)。
|
||||
- 去掉「std >= 0.3 才用截面」的判断:直接走截面(N>1)→ 时序的降级链。
|
||||
|
||||
Returns:
|
||||
[N, T] clip 到 [-3, 3],若是常数则返回原值(engine 会过滤)
|
||||
"""
|
||||
N, T = x.shape
|
||||
|
||||
# 检测是否是全局常数(标准化无意义)
|
||||
# 检测是否是全局常数(标准化无意义,x.std() 是统计量非时间依赖)
|
||||
global_std = x.std()
|
||||
if global_std < 1e-6:
|
||||
return x # 常数因子,由 engine 的 const_cnt 拦截
|
||||
|
||||
# ── 截面标准化(跨品种,每时间步;N=1 时跳过)──────────────
|
||||
# cs_std 沿 N 维计算,t 时刻的 cs_std 只用 {x[n, t] : n},无未来信息
|
||||
if N > 1:
|
||||
cs_mean = x.mean(dim=0, keepdim=True)
|
||||
cs_std = x.std(dim=0, keepdim=True).clamp(min=1e-8)
|
||||
cs_z = (x - cs_mean) / cs_std
|
||||
if cs_z.std() >= 0.3:
|
||||
return torch.clamp(cs_z, -3.0, 3.0)
|
||||
# P2-8: 不再用 cs_z.std()(全局,含未来)做路径选择
|
||||
# 若截面 std 在某些时间步过小,cs_z 会很大,但 clamp 到 [-3,3] 即可
|
||||
return torch.clamp(cs_z, -3.0, 3.0)
|
||||
|
||||
# ── 时序标准化(每品种独立,expanding 无 look-ahead)─────────
|
||||
# N=1 时只能用时序归一化
|
||||
# 每个 t 仅用 x[:, :t+1] 计算 mean/std,避免用 t 之后的未来统计量
|
||||
# 归一化当前值(原 dim=1 全局 mean/std 存在 look-ahead bias)。
|
||||
cnt = torch.arange(1, T + 1, device=x.device, dtype=x.dtype).view(1, T)
|
||||
cumsum = x.cumsum(dim=1)
|
||||
ts_mean = cumsum / cnt # [N,T],t 位 = x[:,:t+1].mean()
|
||||
@@ -188,12 +198,7 @@ class StackVM:
|
||||
ts_var = (cumsum_sq / cnt) - ts_mean * ts_mean # E[x^2] - E[x]^2
|
||||
ts_std = ts_var.clamp(min=1e-8).sqrt()
|
||||
ts_z = (x - ts_mean) / ts_std
|
||||
|
||||
if ts_z.std() >= 0.1:
|
||||
return torch.clamp(ts_z, -3.0, 3.0)
|
||||
|
||||
# ── 两级均失败:因子无区分度,返回原值让 engine 过滤 ────────
|
||||
return x
|
||||
return torch.clamp(ts_z, -3.0, 3.0)
|
||||
|
||||
def execute(self, formula_tokens, feat_tensor):
|
||||
stack = []
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
@echo off
|
||||
title AlphaMaster Web Server
|
||||
cd /d "C:\Alpha_master"
|
||||
cd /d "D:\cl\AlphaMaster"
|
||||
|
||||
echo ============================================================
|
||||
echo AlphaMaster - Quant Factor Mining Center
|
||||
|
||||
@@ -13,15 +13,36 @@ import torch
|
||||
from model_core.features import MT5FeatureEngineer
|
||||
from model_core.vm import StackVM
|
||||
|
||||
# 与回测/实盘共用的无信号阈值(Config.MIN_TRADE_EXPOSURE)
|
||||
try:
|
||||
from config import Config
|
||||
_MIN_EXPOSURE = float(getattr(Config, "MIN_TRADE_EXPOSURE", 0.05))
|
||||
except Exception: # noqa: BLE001
|
||||
_MIN_EXPOSURE = 0.05
|
||||
# 默认无信号阈值(与 signal.MIN_TRADE_EXPOSURE 一致);实际值在调用时从 Config 读取
|
||||
_DEFAULT_MIN_EXPOSURE = 0.05
|
||||
# 默认最小 bar 数;实际值在调用时从 Config 读取(保证训练-实盘一致)
|
||||
# 训练侧 Config.MIN_BARS=3000,特征侧 _NORM_WINDOW=200,EMA_26 的 2*w_full=360
|
||||
# 实盘必须 ≥ max(Config.MIN_BARS, 2 * max_ema_w_full) 才能避免 train-serve skew
|
||||
_DEFAULT_MIN_BARS = 500
|
||||
|
||||
# 特征滚动窗口需要足够历史才能稳定(_NORM_WINDOW=200 等)
|
||||
MIN_BARS = 200
|
||||
|
||||
def _min_trade_exposure() -> float:
|
||||
"""调用时从 Config 读取,保证与 signal.py / 回测一致(避免热更新不同步)。"""
|
||||
try:
|
||||
from config import Config
|
||||
return float(getattr(Config, "MIN_TRADE_EXPOSURE", _DEFAULT_MIN_EXPOSURE))
|
||||
except Exception: # noqa: BLE001
|
||||
return _DEFAULT_MIN_EXPOSURE
|
||||
|
||||
|
||||
def _min_bars() -> int:
|
||||
"""调用时从 Config 读取最小 bar 数,与训练侧 Config.MIN_BARS 对齐。"""
|
||||
try:
|
||||
from config import Config
|
||||
# 实盘需要:特征 warm-up(_NORM_WINDOW=200)+ EMA_26 递推稳定(2*w_full=360)
|
||||
# 取 max(Config.MIN_BARS, 500) 保证数值路径与训练一致
|
||||
return max(int(getattr(Config, "MIN_BARS", _DEFAULT_MIN_BARS)), _DEFAULT_MIN_BARS)
|
||||
except Exception: # noqa: BLE001
|
||||
return _DEFAULT_MIN_BARS
|
||||
|
||||
|
||||
# 向后兼容:暴露模块级常量(实际逻辑用 _min_bars() 调用)
|
||||
MIN_BARS = _DEFAULT_MIN_BARS
|
||||
|
||||
_VM = StackVM()
|
||||
|
||||
@@ -31,15 +52,26 @@ DIR_FLAT = "FLAT"
|
||||
|
||||
|
||||
def min_exposure() -> float:
|
||||
return _MIN_EXPOSURE
|
||||
return _min_trade_exposure()
|
||||
|
||||
|
||||
def evaluate_signal(formula: list[int], raw_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
def evaluate_signal(
|
||||
formula: list[int],
|
||||
raw_dict: dict[str, Any],
|
||||
require_closed: bool = True,
|
||||
server_time: int | None = None,
|
||||
bar_seconds: int = 3600,
|
||||
) -> dict[str, Any]:
|
||||
"""在实时 K 线上计算因子信号。
|
||||
|
||||
Args:
|
||||
formula: 策略因子的 token 序列。
|
||||
raw_dict: {open,high,low,close,volume} torch 张量 [1, T],升序。
|
||||
formula: 策略因子的 token 序列。
|
||||
raw_dict: {open,high,low,close,volume,time} torch 张量 [1, T],升序。
|
||||
time 字段为 Unix 秒(int64)。
|
||||
require_closed: 是否校验最后一根 bar 已收盘(避免 tick 抖动导致信号翻转)。
|
||||
server_time: 当前服务器 Unix 秒。若提供且 require_closed=True,
|
||||
会校验 raw_dict["time"][0,-1] < server_time - bar_seconds*0.5。
|
||||
bar_seconds: 一个 bar 的秒数(H1=3600),用于收盘校验。
|
||||
|
||||
Returns:
|
||||
dict:state / direction / strength / factor_value / position / bars_used / message
|
||||
@@ -49,13 +81,36 @@ def evaluate_signal(formula: list[int], raw_dict: dict[str, Any]) -> dict[str, A
|
||||
return {"state": "error", "message": "行情数据格式无效"}
|
||||
|
||||
n_bars = int(close.shape[1])
|
||||
if n_bars < MIN_BARS:
|
||||
min_bars_required = _min_bars()
|
||||
if n_bars < min_bars_required:
|
||||
return {
|
||||
"state": "insufficient",
|
||||
"bars_used": n_bars,
|
||||
"message": f"历史 bar 不足({n_bars}/{MIN_BARS}),无法稳定计算特征",
|
||||
"message": f"历史 bar 不足({n_bars}/{min_bars_required}),无法稳定计算特征",
|
||||
}
|
||||
|
||||
# 收盘校验:若启用 require_closed 且提供 server_time,检查最后一根 bar 是否已收盘
|
||||
# 避免传入未收盘的当前 bar 导致信号随 tick 抖动反复翻转
|
||||
if require_closed and server_time is not None:
|
||||
time_tensor = raw_dict.get("time")
|
||||
if time_tensor is not None:
|
||||
try:
|
||||
last_bar_time = int(time_tensor[0, -1].item())
|
||||
# 最后一根 bar 的开盘时间 + bar_seconds 即为收盘时间
|
||||
# 若当前时间距收盘不足半个 bar,认为尚未收盘,拒绝出信号
|
||||
if server_time - last_bar_time < bar_seconds - bar_seconds // 2:
|
||||
return {
|
||||
"state": "pending",
|
||||
"bars_used": n_bars,
|
||||
"message": (
|
||||
f"最后一根 bar 未收盘(last_bar_time={last_bar_time}, "
|
||||
f"server_time={server_time}),等待收盘后再计算信号"
|
||||
),
|
||||
}
|
||||
except Exception: # noqa: BLE001
|
||||
# 时间字段异常时降级为不校验
|
||||
pass
|
||||
|
||||
try:
|
||||
feats = MT5FeatureEngineer.compute_features(raw_dict) # [1, F, T]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -75,7 +130,7 @@ def evaluate_signal(formula: list[int], raw_dict: dict[str, Any]) -> dict[str, A
|
||||
|
||||
position = math.tanh(factor_last) # 连续仓位 [-1, 1]
|
||||
strength = abs(position) # 信号强度 [0, 1]
|
||||
thr = _MIN_EXPOSURE
|
||||
thr = _min_trade_exposure()
|
||||
|
||||
if position >= thr:
|
||||
direction = DIR_LONG
|
||||
|
||||
@@ -94,6 +94,39 @@ class MT5Source(DataSource):
|
||||
pass
|
||||
self._connected = False
|
||||
|
||||
def _fallback_local_cache(
|
||||
self, symbol: str, timeframe: str, n: int, drop_forming: bool = True
|
||||
) -> list[Bar] | None:
|
||||
"""MT5 无实时数据时,从本地 K 线缓存(D:\\K线数据)读取。"""
|
||||
try:
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
cache_dir = Path(r"D:\K线数据")
|
||||
path = cache_dir / f"{symbol}_{timeframe.upper()}.parquet"
|
||||
if not path.exists():
|
||||
return None
|
||||
df = pd.read_parquet(path)
|
||||
if df.empty:
|
||||
return None
|
||||
rows = df.tail(n + (1 if drop_forming else 0)).to_dict("records")
|
||||
if drop_forming and len(rows) > 1:
|
||||
rows = rows[:-1]
|
||||
bars: list[Bar] = []
|
||||
offset = self._server_offset or 0
|
||||
for r in rows:
|
||||
vol = float(r.get("tick_volume", 0))
|
||||
bars.append(Bar(
|
||||
ts=int(r["time"]) - offset,
|
||||
open=float(r["open"]),
|
||||
high=float(r["high"]),
|
||||
low=float(r["low"]),
|
||||
close=float(r["close"]),
|
||||
volume=vol,
|
||||
))
|
||||
return bars if bars else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def fetch_bars(
|
||||
self, symbol: str, timeframe: str, n: int, drop_forming: bool = True
|
||||
) -> list[Bar]:
|
||||
@@ -115,6 +148,10 @@ class MT5Source(DataSource):
|
||||
rates = mt5.copy_rates_from_pos(symbol, tf_const, 0, fetch_n)
|
||||
|
||||
if rates is None or len(rates) == 0:
|
||||
# MT5 无数据时回退到本地 K 线缓存
|
||||
fallback = self._fallback_local_cache(symbol, timeframe, n, drop_forming)
|
||||
if fallback:
|
||||
return fallback
|
||||
raise DataSourceUnavailable(
|
||||
f"MT5 无法获取 {symbol} {timeframe} 数据 {mt5.last_error()}"
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ _TF_SECONDS = {
|
||||
"1M": 2592000, # 近似 30 天
|
||||
}
|
||||
_DEFAULT_CADENCE = 60
|
||||
_N_BARS = 500 # 每次拉取的历史 bar 数(喂给特征引擎)
|
||||
_N_BARS = 3500 # 每次拉取的历史 bar 数(喂给特征引擎,需 ≥ Config.MIN_BARS=3000)
|
||||
_HISTORY_LEN = 60 # 保留的信号强度历史点数(供 sparkline)
|
||||
_VALID_KINDS = {k for k, _ in SOURCE_KINDS}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user