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,93 @@
|
||||
"""研究服务:把 Research Specification 编排为数据获取 + 引擎执行。
|
||||
|
||||
本层是业务入口:API / Agent 只能调用这里的用例(AGENT.md §16/§17),
|
||||
禁止直接拼接引擎配置。数据一律经 Repository 获取(防未来函数由查询层保证)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.domain.entities.market import Stock
|
||||
from app.domain.entities.research import (
|
||||
BacktestResult,
|
||||
FactorTestReport,
|
||||
ResearchSpec,
|
||||
UniverseSpec,
|
||||
)
|
||||
from app.domain.repositories.market import (
|
||||
DailyBarRepository,
|
||||
StockRepository,
|
||||
)
|
||||
from app.quant.engine import QuantEngine
|
||||
|
||||
|
||||
def filter_stocks(stocks: list[Stock], universe: UniverseSpec, as_of: date) -> list[Stock]:
|
||||
"""按股票池口径过滤(名称含 ST 判定 —— 名称快照为当日口径,属历史可追溯数据)。"""
|
||||
out: list[Stock] = []
|
||||
for s in stocks:
|
||||
if s.delist_date is not None and s.delist_date < as_of:
|
||||
continue
|
||||
if universe.exclude_st and s.name and "ST" in s.name.upper():
|
||||
continue
|
||||
if (
|
||||
universe.min_listing_days
|
||||
and s.list_date
|
||||
and (as_of - s.list_date).days < universe.min_listing_days
|
||||
):
|
||||
continue
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def bars_to_daily_df(bars) -> pd.DataFrame:
|
||||
"""DailyBar 列表 → 引擎长表 DataFrame(symbol/trade_date/ohlc/volume/amount)。
|
||||
|
||||
领域实体中的 Decimal 在此转 float,供 pandas 数值运算(保持 DataFrame 全数值列)。
|
||||
"""
|
||||
df = pd.DataFrame([b.model_dump() for b in bars])
|
||||
if not df.empty:
|
||||
for col in ("open", "high", "low", "close", "volume", "amount"):
|
||||
if col in df.columns:
|
||||
df[col] = df[col].astype(float)
|
||||
return df
|
||||
|
||||
|
||||
class ResearchService:
|
||||
"""研究用例入口(因子测试 / 回测)。依赖注入 Repository 与引擎。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stock_repo: StockRepository,
|
||||
daily_repo: DailyBarRepository,
|
||||
engine: QuantEngine,
|
||||
) -> None:
|
||||
self._stock_repo = stock_repo
|
||||
self._daily_repo = daily_repo
|
||||
self._engine = engine
|
||||
|
||||
def run_factor_test(self, spec: ResearchSpec, horizon_days: int = 21) -> FactorTestReport:
|
||||
if spec.type != "factor_test":
|
||||
raise ValueError("factor_test 用例需要 spec.type=factor_test")
|
||||
daily = self._load_daily(spec)
|
||||
return self._engine.run_factor_test(daily, spec, horizon_days=horizon_days)
|
||||
|
||||
def run_backtest(self, spec: ResearchSpec) -> BacktestResult:
|
||||
if spec.type != "backtest":
|
||||
raise ValueError("backtest 用例需要 spec.type=backtest")
|
||||
daily = self._load_daily(spec)
|
||||
return self._engine.run_backtest(daily, spec)
|
||||
|
||||
# ---- 数据装配 ----
|
||||
|
||||
def _load_daily(self, spec: ResearchSpec) -> pd.DataFrame:
|
||||
start, end = spec.period
|
||||
# 回测前预留因子 warmup(lookback≤120 交易日,取 300 自然日余量)
|
||||
data_start = start - timedelta(days=300)
|
||||
stocks = filter_stocks(self._stock_repo.list(), spec.universe, as_of=start)
|
||||
bars: list = []
|
||||
for s in stocks:
|
||||
bars.extend(self._daily_repo.get_range(s.symbol, data_start, end))
|
||||
return bars_to_daily_df(bars)
|
||||
Reference in New Issue
Block a user