- 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
94 lines
3.3 KiB
Python
94 lines
3.3 KiB
Python
"""研究服务:把 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)
|