mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat(platform): 因子平台与因子↔策略双向联动 v0.2.3
- 因子平台: /factors 一级页(检验/因子库/编辑器/组合/挖掘), DSL 公式因子(25 算子点选、双语字段、我的因子模板、脏公式守卫), 版本与生命周期, 自动挖掘 L1 统计筛选 - 因子↔策略四条桥: 触发器 Zap 快建因子条件信号、因子一键生成排名策略、自定义信号 AI 提示词接入因子分组、策略回测因子归因(胜/败单入场信号日因子均值, 独立 tab, 双语因子名) - 回测: 统计卡新增盈亏比(≥1 红/<1 绿), 蒙卡回撤合并为中位/95% 双值卡(自适应字号), 高级设置基础过滤与策略编辑器参数对齐(5 组区间) - 信号库独立页 /signals(原设置 tab 迁出), 持仓提醒入导航; 挖掘并入因子页第 5 tab, /mining 旧链接重定向 - 研究线配套: 因子目录 61→77(评分/矩阵双内核), stats_v2(Newey-West/BH-FDR/DSR), enriched 管道与异动/报价服务配套调整 - 文档: README 导航与特性表、features.md 因子平台章节、操作说明书 9.2、factor-platform-plan 执行状态与 §5、二开文档桥接说明; 交流与支持节改版 - 版本 0.2.2 → 0.2.3; 后端全量 1625 passed(1 例环境性跳过), 前端 build 通过
This commit is contained in:
@@ -591,6 +591,7 @@ class StrategyBacktestResult:
|
||||
trades: list[dict] = field(default_factory=list)
|
||||
per_symbol_stats: list[dict] = field(default_factory=list)
|
||||
strategy_info: dict = field(default_factory=dict)
|
||||
factor_attribution: dict | None = None
|
||||
elapsed_ms: float = 0.0
|
||||
error: str | None = None
|
||||
|
||||
@@ -655,6 +656,56 @@ class BacktestResultPolicy:
|
||||
return {key: value for key, value in stats.items() if key in keep}
|
||||
|
||||
|
||||
def _factor_attribution_summary(
|
||||
snapshot: pl.DataFrame,
|
||||
trades: list,
|
||||
) -> dict | None:
|
||||
"""v1 因子归因: 入场信号日因子快照 x 成交盈亏, 对比盈利/亏损单因子均值。
|
||||
|
||||
snapshot 来自 _apply_score 物化的候选行 (与评分同一条计算管线), 模拟结束后
|
||||
按 (symbol, 信号日) 关联成交。快照缺失、无可关联行或因子列全空时返回 None,
|
||||
归因失败不影响回测主结果。
|
||||
"""
|
||||
factor_cols = [c for c in snapshot.columns if c not in ("symbol", "date")]
|
||||
if not factor_cols or not trades:
|
||||
return None
|
||||
normalized = snapshot.with_columns(
|
||||
pl.col("date").cast(pl.Utf8).str.slice(0, 10).alias("date")
|
||||
)
|
||||
symbols: list[str] = []
|
||||
days: list[str] = []
|
||||
pnls: list[float] = []
|
||||
for trade in trades:
|
||||
day = trade.entry_signal_date or trade.entry_date
|
||||
if day is None:
|
||||
continue
|
||||
symbols.append(trade.symbol)
|
||||
days.append(str(day)[:10])
|
||||
pnls.append(float(trade.pnl_pct))
|
||||
if not symbols:
|
||||
return None
|
||||
frame = pl.DataFrame({"symbol": symbols, "date": days, "pnl_pct": pnls})
|
||||
joined = frame.join(normalized, on=["symbol", "date"], how="left")
|
||||
win = joined.filter(pl.col("pnl_pct") > 0)
|
||||
lose = joined.filter(pl.col("pnl_pct") <= 0)
|
||||
factors: list[dict] = []
|
||||
for col in factor_cols:
|
||||
win_vals = win.get_column(col).drop_nulls().cast(pl.Float64)
|
||||
lose_vals = lose.get_column(col).drop_nulls().cast(pl.Float64)
|
||||
if win_vals.is_empty() and lose_vals.is_empty():
|
||||
continue
|
||||
factors.append({
|
||||
"factor": col,
|
||||
"win_mean": round(float(win_vals.mean()), 6) if not win_vals.is_empty() else None,
|
||||
"lose_mean": round(float(lose_vals.mean()), 6) if not lose_vals.is_empty() else None,
|
||||
"win_n": int(win_vals.len()),
|
||||
"lose_n": int(lose_vals.len()),
|
||||
})
|
||||
if not factors:
|
||||
return None
|
||||
return {"factors": factors, "n_win": win.height, "n_lose": lose.height}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedMatrixBacktest:
|
||||
"""Job-scoped immutable market data reused by every optimizer trial."""
|
||||
@@ -1012,6 +1063,8 @@ class StrategyBacktestService:
|
||||
t0 = time.perf_counter()
|
||||
run_id = uuid.uuid4().hex[:10]
|
||||
result_policy = result_policy or BacktestResultPolicy()
|
||||
# 因子归因快照容器: 日线路径在 _apply_score 里填充, 其余路径保持空
|
||||
factor_snapshot: dict = {}
|
||||
|
||||
def _err(msg: str) -> StrategyBacktestResult:
|
||||
return StrategyBacktestResult(
|
||||
@@ -1505,7 +1558,7 @@ class StrategyBacktestService:
|
||||
|
||||
candidate_filter_mask = self._build_candidate_filter_mask(panel, s, params)
|
||||
candidate_mask = basic_mask & candidate_filter_mask
|
||||
panel = self._apply_score(panel, s, overrides, universe_mask=candidate_mask)
|
||||
panel = self._apply_score(panel, s, overrides, universe_mask=candidate_mask, factor_snapshot=factor_snapshot)
|
||||
formal_candidate_mask = candidate_mask & formal_range
|
||||
entry_mask = self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals)
|
||||
entry_mask = entry_mask & formal_range
|
||||
@@ -1662,6 +1715,16 @@ class StrategyBacktestService:
|
||||
|
||||
selected_stats = result_policy.select_stats(result.stats)
|
||||
|
||||
# 因子归因 (fail-open): 快照与成交按信号日关联, 失败只记日志不影响结果
|
||||
factor_attribution = None
|
||||
if factor_snapshot and result.trades and result_policy.include_trades:
|
||||
try:
|
||||
factor_attribution = _factor_attribution_summary(
|
||||
factor_snapshot["frame"], result.trades
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("factor attribution failed: %s", exc)
|
||||
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
|
||||
return StrategyBacktestResult(
|
||||
@@ -1682,6 +1745,7 @@ class StrategyBacktestService:
|
||||
else []
|
||||
),
|
||||
strategy_info=strategy_info,
|
||||
factor_attribution=factor_attribution,
|
||||
elapsed_ms=round(elapsed, 1),
|
||||
)
|
||||
|
||||
@@ -2465,6 +2529,7 @@ class StrategyBacktestService:
|
||||
s: StrategyDef,
|
||||
overrides: dict | None,
|
||||
universe_mask: pl.Series | None = None,
|
||||
factor_snapshot: dict | None = None,
|
||||
) -> pl.DataFrame:
|
||||
scoring = effective_scoring(s.meta.get("scoring"), overrides)
|
||||
directions = effective_scoring_directions(overrides)
|
||||
@@ -2475,6 +2540,18 @@ class StrategyBacktestService:
|
||||
if has_universe:
|
||||
work = work.with_columns(universe_mask.rename("_score_universe"))
|
||||
|
||||
# 因子归因快照: 在临时因子列被 _finish 丢弃前, 截取候选行的
|
||||
# (symbol, date, 因子值)。与评分共用同一份物化结果, 无第二次计算。
|
||||
if factor_snapshot is not None:
|
||||
snapshot_cols = ["symbol", "date"] + [
|
||||
name for name in scoring if name in work.columns
|
||||
]
|
||||
if len(snapshot_cols) > 2:
|
||||
frame = work
|
||||
if has_universe:
|
||||
frame = frame.filter(pl.col("_score_universe"))
|
||||
factor_snapshot["frame"] = frame.select(snapshot_cols)
|
||||
|
||||
def _value_in_universe(value: pl.Expr) -> pl.Expr:
|
||||
if has_universe:
|
||||
return pl.when(pl.col("_score_universe")).then(value).otherwise(None)
|
||||
|
||||
Reference in New Issue
Block a user