Files
tick-stock-panel/backend/app/api/factors.py
T
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

455 lines
19 KiB
Python

"""因子注册表 API — 因子库 (P1) + 公式校验/试算 (P2) + 自定义/复合因子 CRUD (P3)。"""
from __future__ import annotations
from datetime import date, timedelta
import polars as pl
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.factors import store
from app.factors.dsl import FACTOR_COLUMN, compile_formula
from app.factors.registry import all_factors, unregister_factor
router = APIRouter(prefix="/api/factors", tags=["factors"])
@router.get("")
def list_factors(asset_type: str | None = Query(default=None, pattern="^(stock|etf)$")) -> dict:
"""注册表因子列表; asset_type 过滤适用资产 (财务因子仅股票)。"""
specs = all_factors(asset_type=asset_type)
return {
"factors": [
{
"id": spec.id,
"label": spec.label,
"group": spec.group,
"kind": spec.kind,
"version": spec.version,
"formula": spec.formula_text,
"direction": spec.direction,
"unit": spec.unit,
"warmup_bars": spec.warmup_bars,
"pit": spec.pit,
"asset_types": sorted(spec.asset_types),
"stability": spec.stability,
"scale_free": spec.scale_free,
"dependencies": sorted(spec.dependencies),
}
for spec in specs
]
}
class FormulaValidateRequest(BaseModel):
formula: str = Field(..., min_length=1, max_length=2000)
def _compiled_payload(compiled) -> dict:
return {
"ok": compiled.ok,
"errors": [error.to_dict() for error in compiled.errors],
"dependencies": sorted(compiled.dependencies),
"referenced_factors": sorted(compiled.referenced_factors),
"warmup_bars": compiled.warmup_bars,
"cross_sectional": compiled.cross_sectional,
}
@router.post("/validate")
def validate_formula(req: FormulaValidateRequest) -> dict:
"""公式校验: 语法/语义/窗口纪律/依赖推导, 编译期 fail-closed。"""
return _compiled_payload(compile_formula(req.formula))
class FormulaTrialRequest(FormulaValidateRequest):
asset_type: str = Field(default="stock", pattern="^(stock|etf)$")
days: int = Field(default=40, ge=20, le=120)
@router.post("/trial")
def trial_formula(req: FormulaTrialRequest, request: Request) -> dict:
"""公式试算: 最近 N 个交易日截面 Rank IC 快照 (复用回测面板与虚拟因子物化路径)。"""
compiled = compile_formula(req.formula)
if not compiled.ok:
raise HTTPException(status_code=400, detail={"errors": [error.to_dict() for error in compiled.errors]})
from app.api.backtest import _get_engine
# 交易日 → 自然日换算 (A股年均 243 交易日 ≈ 1.48 自然日/交易日), 留 buffer
calendar_days = int((compiled.warmup_bars + req.days) * 1.6) + 15
start = date.today() - timedelta(days=calendar_days)
# 面板基础物理列 (load_panel 只返回 parquet 物理列, 因子列由补算路径生成)
base_columns = ["symbol", "date", "open", "high", "low", "close", "volume", "amount", "turnover_rate"]
if "consecutive_limit_ups" in compiled.dependencies:
base_columns.append("consecutive_limit_ups")
engine = _get_engine(request)
panel = engine.load_panel(None, start, date.today(), columns=base_columns, asset_type=req.asset_type)
if panel.is_empty():
raise HTTPException(status_code=400, detail="当前数据目录无可用历史数据, 无法试算")
# 复用检验引擎同一条补算路径 (compute_indicators + 虚拟因子物化), 禁止第二套计算逻辑
from app.backtest.factor import FactorBacktestService
physical = set(panel.columns)
to_compute = set(compiled.referenced_factors) | {
dep for dep in compiled.dependencies if dep not in physical
}
if to_compute:
panel = FactorBacktestService._compute_missing_factors(panel, to_compute)
if compiled.frame_transform is None:
raise HTTPException(status_code=500, detail="编译产物缺少帧变换")
prepared = compiled.frame_transform(panel)
if prepared is None:
raise HTTPException(
status_code=400,
detail={"errors": [{
"code": "E013", "message": "依赖列不可用: 面板缺少公式所需列",
"position": {"offset": 0, "line": 1},
"detail": {"missing": sorted((compiled.dependencies | compiled.referenced_factors) - set(panel.columns))},
}]},
)
total_rows = panel.height
frame = (
prepared
.with_columns(
(pl.col("close").shift(-1).over("symbol") / pl.col("close") - 1.0).alias("_next_return")
)
.filter(pl.col(FACTOR_COLUMN).is_not_null())
.unique(subset=["symbol", "date"], keep="last")
.sort(["symbol", "date"])
)
non_null_rows = frame.height
if non_null_rows == 0:
return {
"ok": True, "n_dates": 0, "null_ratio": 1.0,
"ic_mean": None, "ic_std": None, "ir": None, "ic_win_rate": None,
"ic_series": [], "message": "试算区间内公式输出全为空 (检查预热窗口与数据范围)",
}
ic_frame = (
frame.filter(pl.col("_next_return").is_not_null())
.group_by("date")
.agg(
pl.corr(pl.col(FACTOR_COLUMN).rank(method="average"), pl.col("_next_return").rank(method="average")).alias("ic"),
pl.len().alias("n_symbols"),
)
.filter(pl.col("ic").is_not_null())
.sort("date")
.tail(req.days)
)
ic_series = [
{"date": str(row["date"]), "ic": round(row["ic"], 4), "n_symbols": row["n_symbols"]}
for row in ic_frame.to_dicts()
]
if ic_frame.is_empty():
return {
"ok": True, "n_dates": 0, "null_ratio": round(1.0 - non_null_rows / max(total_rows, 1), 4),
"ic_mean": None, "ic_std": None, "ir": None, "ic_win_rate": None,
"ic_series": [], "message": "无有效 IC 截面 (需每期 ≥2 只标的)",
}
stats = ic_frame.select(
pl.col("ic").mean().alias("mean"),
pl.col("ic").std(ddof=0).alias("std"),
(pl.col("ic") > 0).mean().alias("win"),
).row(0, named=True)
ic_std = stats["std"]
# Newey-West t (lag=1): 与检验页同源口径, 样本过少时不给 (fail-closed)
t_newey_west = None
if ic_frame.height >= 5:
from app.backtest.stats_v2 import newey_west_t
values = ic_frame["ic"].to_numpy()
nw = newey_west_t(values, lag=1)
if nw is not None:
t_newey_west = round(float(nw[0]), 3)
return {
"ok": True,
"n_dates": ic_frame.height,
"null_ratio": round(1.0 - non_null_rows / max(total_rows, 1), 4),
"ic_mean": round(stats["mean"], 4),
"ic_std": None if ic_std is None else round(ic_std, 4),
"ir": None if not ic_std or ic_std == 0 else round(stats["mean"] / ic_std, 3),
"ic_win_rate": round(stats["win"], 4),
"t_newey_west": t_newey_west,
"ic_series": ic_series,
}
# ── 自定义/复合因子 CRUD (P3) ──────────────────────────────
class CustomFactorCreateRequest(BaseModel):
id: str | None = Field(default=None, max_length=48)
label: str = Field(..., min_length=1, max_length=32)
group: str = Field(default="自定义", max_length=16)
formula: str = Field(..., min_length=1, max_length=2000)
description: str = Field(default="", max_length=500)
direction: str = Field(default="none", pattern="^(high|low|none)$")
class CompositeFactorCreateRequest(BaseModel):
id: str | None = Field(default=None, max_length=48)
label: str = Field(..., min_length=1, max_length=32)
group: str = Field(default="组合", max_length=16)
members: dict[str, float] = Field(..., min_length=2, max_length=8)
description: str = Field(default="", max_length=500)
direction: str = Field(default="none", pattern="^(high|low|none)$")
def _data_dir(request: Request):
from pathlib import Path
data_dir = getattr(getattr(request.app.state, "repo", None), "store", None)
root = getattr(data_dir, "data_dir", None) if data_dir is not None else None
if root is None:
raise HTTPException(status_code=500, detail="数据目录不可用")
return Path(root)
def _slugify_id(label: str, prefix: str) -> str:
base = "".join(ch if ch.isascii() and (ch.isalnum() or ch == "_") else "_" for ch in label.lower())
candidate = f"{prefix}_{base}".strip("_")[:44]
import re
candidate = re.sub(r"_+", "_", candidate)
return candidate or f"{prefix}_f"
def _resolve_id(requested: str | None, label: str, prefix: str) -> str:
return requested.strip() if requested and requested.strip() else _slugify_id(label, prefix)
def _next_version(data_dir, factor_id: str) -> int:
for definition in store.load_all(data_dir):
if str(definition.get("id")) == factor_id:
return int(definition.get("version", 1)) + 1
return 1
def _trial_nonempty(request: Request, formula: str, asset_type: str = "stock") -> None:
"""保存前置校验: 公式在最近 40 个交易日有非空输出 (设计 §3.5, fail-closed)。"""
compiled = compile_formula(formula)
if not compiled.ok:
raise HTTPException(status_code=400, detail={"errors": [e.to_dict() for e in compiled.errors]})
from app.api.backtest import _get_engine
from app.backtest.factor import FactorBacktestService
calendar_days = int((compiled.warmup_bars + 40) * 1.6) + 15
base_columns = ["symbol", "date", "open", "high", "low", "close", "volume", "amount", "turnover_rate"]
engine = _get_engine(request)
panel = engine.load_panel(None, date.today() - timedelta(days=calendar_days), date.today(), columns=base_columns, asset_type=asset_type)
if panel.is_empty():
raise HTTPException(status_code=400, detail="当前无历史数据, 无法完成保存前试算 (fail-closed)")
physical = set(panel.columns)
to_compute = set(compiled.referenced_factors) | {d for d in compiled.dependencies if d not in physical}
if to_compute:
panel = FactorBacktestService._compute_missing_factors(panel, to_compute)
prepared = compiled.frame_transform(panel) if compiled.frame_transform else None
if prepared is None or prepared[FACTOR_COLUMN].is_not_null().sum() == 0:
raise HTTPException(status_code=400, detail="公式在最近 40 个交易日输出全为空, 拒绝保存")
@router.post("/custom")
def create_custom_factor(req: CustomFactorCreateRequest, request: Request) -> dict:
"""保存自定义公式因子: 编译通过 + 服务端试算非空 (fail-closed)。"""
data_dir = _data_dir(request)
factor_id = _resolve_id(req.id, req.label, "uf")
definition = {
"id": factor_id,
"kind": "custom",
"version": _next_version(data_dir, factor_id),
"label": req.label,
"group": req.group,
"formula": req.formula,
"description": req.description,
"direction": req.direction,
"status": "draft",
"created_at": store._now(),
"updated_at": store._now(),
}
try:
store.to_spec(definition) # 先做 schema/id/编译校验
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_trial_nonempty(request, req.formula)
try:
store.register_definition(definition)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
store.save_one(data_dir, definition)
return {"ok": True, "id": factor_id, "version": definition["version"]}
@router.post("/composite")
def create_composite_factor(req: CompositeFactorCreateRequest, request: Request) -> dict:
"""保存复合因子: 成员校验 + 循环引用检查 (无需试算, 值由成员物化路径计算)。"""
data_dir = _data_dir(request)
factor_id = _resolve_id(req.id, req.label, "cf")
definition = {
"id": factor_id,
"kind": "composite",
"version": _next_version(data_dir, factor_id),
"label": req.label,
"group": req.group,
"members": req.members,
"description": req.description,
"direction": req.direction,
"status": "draft",
"created_at": store._now(),
"updated_at": store._now(),
}
try:
store.register_definition(definition)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
store.save_one(data_dir, definition)
return {"ok": True, "id": factor_id, "version": definition["version"]}
class CustomFactorUpdateRequest(BaseModel):
label: str = Field(..., min_length=1, max_length=32)
group: str = Field(default="自定义", max_length=16)
formula: str = Field(..., min_length=1, max_length=2000)
description: str = Field(default="", max_length=500)
direction: str = Field(default="none", pattern="^(high|low|none)$")
@router.post("/custom/{factor_id}/update")
def update_custom_factor(factor_id: str, req: CustomFactorUpdateRequest, request: Request) -> dict:
"""编辑已有自定义因子: 编译校验 + 试算非空 (与创建同一门禁) → 版本提升注册。
公式变化时状态回 draft (生命周期语义: 编辑后需重新检验激活); 仅改名称/分组保留状态。
"""
data_dir = _data_dir(request)
target = None
for definition in store.load_all(data_dir):
if str(definition.get("id")) == factor_id:
target = definition
break
if target is None:
raise HTTPException(status_code=404, detail=f"自定义因子不存在: {factor_id}")
if str(target.get("kind", "custom")) != "custom":
raise HTTPException(status_code=400, detail=f"仅自定义因子支持公式编辑 (kind={target.get('kind')})")
formula_changed = str(target.get("formula")) != req.formula
if formula_changed:
_trial_nonempty(request, req.formula)
target.update({
"label": req.label,
"group": req.group,
"formula": req.formula,
"description": req.description,
"direction": req.direction,
"version": int(target.get("version", 1)) + 1, # 版本提升 → 注册表允许覆盖
"status": "draft" if formula_changed else str(target.get("status", "draft")),
"updated_at": store._now(),
})
try:
store.register_definition(target)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
store.save_one(data_dir, target)
return {"ok": True, "id": factor_id, "version": target["version"], "status": target["status"]}
def _find_references(data_dir, factor_id: str) -> list[str]:
"""扫描策略与复合因子定义中的引用 (删除前 fail-closed 检查)。"""
references: list[str] = []
strategies_dir = data_dir / "strategies"
if strategies_dir.is_dir():
for file in strategies_dir.glob("*.json"):
try:
text = file.read_text(encoding="utf-8")
if factor_id in text:
references.append(f"strategies/{file.name}")
except OSError:
continue
for definition in store.load_all(data_dir):
if str(definition.get("id")) == factor_id:
continue
members = definition.get("members")
if isinstance(members, dict) and factor_id in members:
references.append(f"custom_factors/{definition.get('id')}.json")
return references
@router.delete("/custom/{factor_id}")
def delete_custom_factor(factor_id: str, request: Request, force: bool = Query(default=False)) -> dict:
"""删除自定义/复合因子; 有引用时列出引用方并拒绝 (需 force)。"""
data_dir = _data_dir(request)
from app.factors.registry import get_factor
if get_factor(factor_id) is None and not store.delete_one(data_dir, factor_id):
raise HTTPException(status_code=404, detail=f"因子不存在: {factor_id}")
references = _find_references(data_dir, factor_id)
if references and not force:
raise HTTPException(
status_code=409,
detail={"message": "该因子仍有引用, 拒绝删除 (可带 force=true 强制)", "references": references},
)
try:
unregister_factor(factor_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
store.delete_one(data_dir, factor_id)
return {"ok": True, "id": factor_id, "removed_references": references}
class FactorStatusRequest(BaseModel):
status: str = Field(..., pattern="^(draft|active|watch|retired)$")
@router.post("/custom/{factor_id}/status")
def update_factor_status(factor_id: str, req: FactorStatusRequest, request: Request) -> dict:
"""生命周期状态迁移 (P4): draft->active->watch->retired, 编辑后回 draft。"""
data_dir = _data_dir(request)
target = None
for definition in store.load_all(data_dir):
if str(definition.get("id")) == factor_id:
target = definition
break
if target is None:
raise HTTPException(status_code=404, detail=f"自定义因子不存在: {factor_id}")
target["status"] = req.status
target["updated_at"] = store._now()
try:
# 动态因子先注销再注册: 元数据变更 (status/group) 不提升版本,
# 直接 register 会因"版本未提升"被拒 (启动加载后的真实路径)
unregister_factor(factor_id)
store.register_definition(target) # 状态与 stability 联动
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
store.save_one(data_dir, target)
return {"ok": True, "id": factor_id, "status": req.status}
class FactorGroupRequest(BaseModel):
group: str = Field(..., min_length=1, max_length=24)
@router.post("/custom/{factor_id}/group")
def update_factor_group(factor_id: str, req: FactorGroupRequest, request: Request) -> dict:
"""修改单个自定义/复合因子的分组 (内置因子分组与快照/预设绑定, 不可改)。"""
data_dir = _data_dir(request)
group = req.group.strip()
if not group:
raise HTTPException(status_code=400, detail="分组名不能为空")
target = None
for definition in store.load_all(data_dir):
if str(definition.get("id")) == factor_id:
target = definition
break
if target is None:
raise HTTPException(status_code=404, detail=f"自定义因子不存在: {factor_id}")
target["group"] = group
target["updated_at"] = store._now()
try:
unregister_factor(factor_id)
store.register_definition(target)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
store.save_one(data_dir, target)
return {"ok": True, "id": factor_id, "group": group}