feat(backend): Phase 2 研究引擎 — ResearchSpec / 因子 / 评估 / 低频回测 / 引擎抽象
- domain:ResearchSpec(universe/factors/selection/rebalance/costs 校验)+ 标准化 BacktestResult / FactorTestReport - 因子引擎:注册表 + 元数据,内置 9 个行情因子(momentum/volatility/量比/乖离/反转),支持自定义注册;只用行情字段规避未来函数 - 评估:横截面 IC / RankIC(rank+pearson 免 scipy)/ ICIR / 分层收益 - 回测:TopK 等权低频,无未来函数记账(t 收盘成交、自 t+1 计收益),成本/涨跌停/停牌约束,未建模项显式写入 unimplemented(AGENT §24) - 引擎抽象 QuantEngine + LocalEngine(pandas 默认实现);qlib_adapter 桥接占位 —— pyqlib 无 aarch64+cp312 wheel(ROADMAP 已备注) - 真实链路冒烟:600519 2024 月度动量回测闭环产出标准结果 - 测试 60 passed / ruff clean
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""因子引擎:因子注册表、元数据与计算(Phase 2,低频选股因子)。
|
||||
|
||||
数据形态:行情长表 DataFrame(列 symbol/trade_date/close/high/low/volume/amount),
|
||||
因子计算返回 面板 DataFrame(index=trade_date,columns=symbol)。
|
||||
所有内置因子只用行情字段(无财务),天然规避未来函数;财务因子接入时必须以
|
||||
announce_date 控制可见性(见 domain.entities.market.FinancialIndicator)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FactorDef:
|
||||
"""因子元数据(AGENT.md §22 要求逐项明确)。"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
formula: str
|
||||
frequency: str = "daily"
|
||||
lookback: int = 20
|
||||
direction: str = "higher_is_better" # | lower_is_better
|
||||
requires: tuple[str, ...] = ("close",)
|
||||
|
||||
|
||||
FactorFn = Callable[[dict[str, pd.DataFrame]], pd.DataFrame]
|
||||
|
||||
|
||||
class FactorError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
_REGISTRY: dict[str, tuple[FactorDef, FactorFn]] = {}
|
||||
|
||||
|
||||
def register(defn: FactorDef) -> Callable[[FactorFn], FactorFn]:
|
||||
"""装饰器:注册自定义因子。"""
|
||||
|
||||
def deco(fn: FactorFn) -> FactorFn:
|
||||
if defn.name in _REGISTRY:
|
||||
raise FactorError(f"因子 {defn.name} 已注册")
|
||||
_REGISTRY[defn.name] = (defn, fn)
|
||||
return fn
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def get_factor(name: str) -> tuple[FactorDef, FactorFn]:
|
||||
if name not in _REGISTRY:
|
||||
raise FactorError(f"未知因子:{name}(可用:{', '.join(sorted(_REGISTRY))})")
|
||||
return _REGISTRY[name]
|
||||
|
||||
|
||||
def list_factors() -> list[FactorDef]:
|
||||
return [d for d, _fn in sorted(_REGISTRY.values(), key=lambda x: x[0].name)]
|
||||
|
||||
|
||||
def compute_factor(name: str, daily: pd.DataFrame) -> tuple[FactorDef, pd.DataFrame]:
|
||||
"""计算因子:从行情长表提取所需字段的面板后调用因子函数。"""
|
||||
defn, fn = get_factor(name)
|
||||
fields: dict[str, pd.DataFrame] = {}
|
||||
for col in defn.requires:
|
||||
panel = daily.pivot(index="trade_date", columns="symbol", values=col).sort_index()
|
||||
panel.index = pd.to_datetime(panel.index)
|
||||
fields[col] = panel
|
||||
return defn, fn(fields)
|
||||
|
||||
|
||||
# ---------- 内置因子 ----------
|
||||
|
||||
|
||||
def _rolling_return(prices: pd.DataFrame, lookback: int) -> pd.DataFrame:
|
||||
return prices / prices.shift(lookback) - 1.0
|
||||
|
||||
|
||||
def _rolling_vol(prices: pd.DataFrame, lookback: int) -> pd.DataFrame:
|
||||
return prices.pct_change().rolling(lookback).std()
|
||||
|
||||
|
||||
@register(
|
||||
FactorDef("momentum_20", "过去 20 个交易日收益率", "close / close.shift(20) - 1", lookback=20)
|
||||
)
|
||||
def _momentum_20(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
return _rolling_return(fields["close"], 20)
|
||||
|
||||
|
||||
@register(
|
||||
FactorDef("momentum_60", "过去 60 个交易日收益率", "close / close.shift(60) - 1", lookback=60)
|
||||
)
|
||||
def _momentum_60(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
return _rolling_return(fields["close"], 60)
|
||||
|
||||
|
||||
@register(
|
||||
FactorDef(
|
||||
"momentum_120", "过去 120 个交易日收益率", "close / close.shift(120) - 1", lookback=120
|
||||
)
|
||||
)
|
||||
def _momentum_120(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
return _rolling_return(fields["close"], 120)
|
||||
|
||||
|
||||
@register(
|
||||
FactorDef(
|
||||
"volatility_20",
|
||||
"过去 20 个交易日收益率波动率",
|
||||
"std(pct_change, 20)",
|
||||
lookback=20,
|
||||
direction="lower_is_better",
|
||||
)
|
||||
)
|
||||
def _volatility_20(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
return _rolling_vol(fields["close"], 20)
|
||||
|
||||
|
||||
@register(
|
||||
FactorDef(
|
||||
"volatility_60",
|
||||
"过去 60 个交易日收益率波动率",
|
||||
"std(pct_change, 60)",
|
||||
lookback=60,
|
||||
direction="lower_is_better",
|
||||
)
|
||||
)
|
||||
def _volatility_60(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
return _rolling_vol(fields["close"], 60)
|
||||
|
||||
|
||||
@register(
|
||||
FactorDef(
|
||||
"close_to_high_60",
|
||||
"收盘价相对 60 日最高价的接近程度",
|
||||
"close / rolling_max(high, 60)",
|
||||
lookback=60,
|
||||
requires=("close", "high"),
|
||||
)
|
||||
)
|
||||
def _close_to_high_60(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
high = fields["high"]
|
||||
return fields["close"] / high.rolling(60).max()
|
||||
|
||||
|
||||
@register(
|
||||
FactorDef(
|
||||
"volume_ratio_5_60",
|
||||
"量比:5 日均量 / 60 日均量",
|
||||
"mean(volume, 5) / mean(volume, 60)",
|
||||
lookback=60,
|
||||
requires=("volume",),
|
||||
)
|
||||
)
|
||||
def _volume_ratio_5_60(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
vol = fields["volume"]
|
||||
return vol.rolling(5).mean() / vol.rolling(60).mean()
|
||||
|
||||
|
||||
@register(
|
||||
FactorDef(
|
||||
"ma_bias_20",
|
||||
"20 日均线乖离率",
|
||||
"(close - ma(close, 20)) / ma(close, 20)",
|
||||
lookback=20,
|
||||
)
|
||||
)
|
||||
def _ma_bias_20(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
close = fields["close"]
|
||||
ma = close.rolling(20).mean()
|
||||
return (close - ma) / ma
|
||||
|
||||
|
||||
@register(
|
||||
FactorDef(
|
||||
"reversal_5",
|
||||
"短期反转:过去 5 日收益率取负(越低越接近超跌)",
|
||||
"-1 * (close / close.shift(5) - 1)",
|
||||
lookback=5,
|
||||
)
|
||||
)
|
||||
def _reversal_5(fields: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
return -1.0 * _rolling_return(fields["close"], 5)
|
||||
Reference in New Issue
Block a user