Files
shy3130 e0cd625ef4 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 通过
2026-09-05 15:41:15 +08:00

194 lines
7.2 KiB
Python

"""自动挖掘 L1 编排: 全量因子统计筛选 → 达标池。
流程定位 (对应方案「四层漏斗」):
- L1 本模块: 注册表全量因子批量检验, 按置信档门槛筛出达标因子 (近期窗口,
仅作"有信号"的先验过滤; 最终达标由挖掘引擎的逐折训练选择与嵌套样本外
验证裁定)。
- L2/L3/L4 由现有挖掘引擎完成: 相关性剪枝 (prune_correlated_factors)、
束搜索组合 (beam_search_factor_combinations)、嵌套样本外验证与达标
门槛 (evaluate_candidate_gate), 本模块不重复实现。
达标判据与检验页服务端判读同源 (|t_NW| / BH q / |IC| / |IR|), 按档放宽或收紧;
q 值缺失时按"通过"处理 (探索档小样本下 BH 校正保守)。
"""
from __future__ import annotations
import math
import time
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Any, Literal
from app.backtest.factor import FactorBacktestService, FactorBatchConfig
from app.factors.registry import factor_columns_view
Profile = Literal["exploratory", "balanced", "strict"]
# 挖掘请求的因子池上限 (与 MiningStartRequest.factor_names max_length 对齐)
MAX_AUTO_POOL = 48
# L1 筛选窗口: 近一年 (与挖掘窗口解耦, 只筛"近期有信号", 长窗口验证交给引擎)
SCREEN_WINDOW_DAYS = 365
@dataclass(frozen=True)
class ScreenGate:
min_abs_ic: float
min_abs_ir: float
min_abs_t: float
max_q: float
def to_dict(self) -> dict[str, float]:
return {
"min_abs_ic": self.min_abs_ic,
"min_abs_ir": self.min_abs_ir,
"min_abs_t": self.min_abs_t,
"max_q": self.max_q,
}
SCREEN_GATES: dict[str, ScreenGate] = {
"exploratory": ScreenGate(min_abs_ic=0.02, min_abs_ir=0.15, min_abs_t=1.5, max_q=0.20),
"balanced": ScreenGate(min_abs_ic=0.02, min_abs_ir=0.30, min_abs_t=2.0, max_q=0.10),
"strict": ScreenGate(min_abs_ic=0.03, min_abs_ir=0.50, min_abs_t=2.5, max_q=0.05),
}
def classify_factor(item: dict[str, Any], gate: ScreenGate) -> str | None:
"""返回 None 表示达标; 否则返回首个未过的门槛, 格式统一为「类别 (细节)」。"""
if item.get("error"):
return f"计算失败 ({str(item['error'])[:40]})"
ic = item.get("ic_mean")
ir = item.get("ir")
t = item.get("t_newey_west")
q = item.get("q_value")
if ic is None or ir is None:
return "样本不足 (无有效 IC/IR)"
if abs(ic) < gate.min_abs_ic:
return f"预测力弱 (|IC|<{gate.min_abs_ic:.2f})"
if abs(ir) < gate.min_abs_ir:
return f"稳定度低 (|IR|<{gate.min_abs_ir:.2f})"
if t is None:
return "样本不足 (无 NW t 值)"
if abs(t) < gate.min_abs_t:
return f"不显著 (|t|<{gate.min_abs_t:.1f})"
if q is not None and q > gate.max_q:
return f"多重检验未过 (q>{gate.max_q:.2f})"
return None
def _short_reason(reason: str) -> str:
"""失败原因归并到短类别 (「类别 (细节)」的前半段), 供原因分布统计。"""
return reason.split(" (", 1)[0].strip()
def _finite_or_none(value: Any) -> float | None:
"""NaN/Inf 一律归 None, 避免写入任务存储时产生非法 JSON。"""
if isinstance(value, (int, float)) and math.isfinite(value):
return float(value)
return None
def _metric_row(item: dict[str, Any]) -> dict[str, Any]:
return {
"factor_name": item.get("factor_name"),
"label": item.get("label") or item.get("factor_name"),
"group": item.get("group") or "",
"ic": _finite_or_none(item.get("ic_mean")),
"ir": _finite_or_none(item.get("ir")),
"t": _finite_or_none(item.get("t_newey_west")),
"q": _finite_or_none(item.get("q_value")),
"direction": 1 if (item.get("ic_mean") or 0) >= 0 else -1,
}
def screen_all_factors(
engine: Any,
*,
asset_type: str,
start: date | None,
end: date,
profile: str,
max_factors: int = MAX_AUTO_POOL,
) -> dict[str, Any]:
"""L1 全量筛选: 注册表全部适用因子批量检验 → 达标池 + 失败原因分布。
start=None 时取近 SCREEN_WINDOW_DAYS 天; 显式 start 只会收紧 (不放宽) 筛选窗口。
"""
gate = SCREEN_GATES.get(profile)
if gate is None:
raise ValueError(f"unknown mining profile: {profile}")
candidates = [
str(item["id"])
for item in factor_columns_view()
if asset_type in item.get("asset_types", ["stock"])
]
screen_start = max(start or date.min, end - timedelta(days=SCREEN_WINDOW_DAYS))
began = time.perf_counter()
service = FactorBacktestService(engine)
batch = service.run_batch(FactorBatchConfig(
factor_names=candidates,
symbols=None,
start=screen_start,
end=end,
rebalance="daily",
asset_type=asset_type,
))
elapsed_ms = round((time.perf_counter() - began) * 1000, 1)
qualified: list[dict[str, Any]] = []
failed: list[dict[str, Any]] = []
by_name = {str(getattr(item, "factor_name", None)): item for item in batch.results}
for name in candidates:
item = by_name.get(name)
if item is None:
failed.append({"factor_name": name, "label": name, "group": "",
"ic": None, "ir": None, "t": None, "q": None,
"reason": "未返回结果"})
continue
# 非有限值先清洗 (NaN 与任何比较均为 False, 会绕过门槛误判达标)
reason = classify_factor({
"error": getattr(item, "error", None),
"ic_mean": _finite_or_none(getattr(item, "ic_mean", None)),
"ir": _finite_or_none(getattr(item, "ir", None)),
"t_newey_west": _finite_or_none(getattr(item, "t_newey_west", None)),
"q_value": _finite_or_none(getattr(item, "q_value", None)),
}, gate)
row = _metric_row({
"factor_name": getattr(item, "factor_name", None),
"label": getattr(item, "label", None),
"group": getattr(item, "group", None),
"ic_mean": getattr(item, "ic_mean", None),
"ir": getattr(item, "ir", None),
"t_newey_west": getattr(item, "t_newey_west", None),
"q_value": getattr(item, "q_value", None),
})
if reason is None:
qualified.append(row)
else:
failed.append({**row, "reason": reason})
# 池按 |IC|*|IR| 降序 (截面信噪比口径), 截断到挖掘上限
qualified.sort(key=lambda row: abs(row["ic"] or 0.0) * abs(row["ir"] or 0.0), reverse=True)
pool = [row["factor_name"] for row in qualified[:max_factors]]
reason_counts: dict[str, int] = {}
for row in failed:
category = _short_reason(row["reason"])
reason_counts[category] = reason_counts.get(category, 0) + 1
return {
"profile": profile,
"gate": gate.to_dict(),
"screen_window": {"start": screen_start.isoformat(), "end": end.isoformat()},
"n_total": len(candidates),
"n_qualified": len(qualified),
"pool": pool,
"pool_truncated": len(qualified) > len(pool),
"qualified": qualified,
"failed": failed,
"reason_counts": dict(sorted(reason_counts.items(), key=lambda kv: -kv[1])),
"elapsed_ms": elapsed_ms,
}