"""研究引擎抽象与默认实现(业务层依赖本接口,可替换引擎)。 切换引擎(如未来在支持平台启用 Qlib)只需注入不同实现 —— 业务代码不变。 """ from __future__ import annotations from typing import Protocol import pandas as pd from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec from app.quant.composite import build_factor_panels_full, composite_score from app.quant.factors import FactorError, get_factor from app.quant.local_engine import TopKBacktestRunner, run_spec_factor_test from app.quant.selection import condition_needed_columns # LocalEngine 路径恒需 close(TopK 收盘撮合 / 前瞻收益) _CLOSE = {"close"} def factor_required_columns(spec: ResearchSpec) -> set[str]: """spec 因子计算 + 选股条件 + 回测撮合所需的行情数值列(含 close)。""" needed = set(_CLOSE) for fs in spec.factors: try: defn, _fn = get_factor(fs.name) except FactorError: continue # 未知因子由执行期统一报错 needed.update(defn.requires) if spec.conditions: # 条件字段同样决定装配列(dv_ratio / volume / ma60 依赖列等); # 与选股路径共用 condition_needed_columns(v2 §25 一致性) needed.update(condition_needed_columns(spec)) return needed class QuantEngine(Protocol): """研究引擎端口:因子面板构建 / 因子测试 / 回测。""" name: str def required_columns(self, spec: ResearchSpec) -> set[str]: """执行该 spec 所需的行情数值列(数据装配按此裁剪,控制内存)。""" def run_factor_test( self, daily: pd.DataFrame, spec: ResearchSpec, horizon_days: int = 21 ) -> FactorTestReport: ... def run_backtest( self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None ) -> BacktestResult: """eligibility_fn(as_of: date) -> set[symbol] | None:选股条件过滤(可选)。 None 表示不过滤;由业务层注入(复用 selection.eligible_symbols), 保证「历史某日 Selection == 回测当日 Selection」(v3 §28)。 """ class LocalEngine: """默认引擎:纯 pandas 实现(无 Qlib 依赖),见 local_engine.py 的纪律说明。""" name = "local" def required_columns(self, spec: ResearchSpec) -> set[str]: return factor_required_columns(spec) def run_factor_test( self, daily: pd.DataFrame, spec: ResearchSpec, horizon_days: int = 21 ) -> FactorTestReport: report, _panels = run_spec_factor_test(daily, spec, horizon_days) return report def run_backtest( self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None ) -> BacktestResult: # 因子面板**只算一次**:复合分(选股)与原始值(买卖理由 / 因子曲线)同源。 # 若先 score_panel_for_factors 再单独算一遍原始面板,同一份行情会被算两遍, # 且两次结果理论上可能分叉 —— 打分用的面板与理由里引用的面板必须是同一张。 # 复合分构建口径不变(与 selection.score_panel_for_factors 同为 z-score 加权和, # v2 §25:回测与当前选股同引擎)。 panels = build_factor_panels_full(daily, spec.factors) # 未知因子在此抛 FactorError score = composite_score([(d.name, p, w, d.direction) for d, p, w in panels]) # 同名因子只留一份(spec 已禁止重复因子名,这里再兜一层) factor_panels = {d.name: (d, p) for d, p, _w in panels} close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index() return TopKBacktestRunner( spec, score, close, eligibility_fn=eligibility_fn, factor_panels=factor_panels ).run()