"""研究服务:把 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) if not stocks: return pd.DataFrame() get_many = getattr(self._daily_repo, "get_range_many", None) if get_many is not None: bars = list(get_many([s.symbol for s in stocks], data_start, end)) else: # 兜底:逐只查询 bars = [] for s in stocks: bars.extend(self._daily_repo.get_range(s.symbol, data_start, end)) return bars_to_daily_df(bars)