mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
- 因子平台: /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 通过
194 lines
7.2 KiB
Python
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,
|
|
}
|