from __future__ import annotations import statistics from collections import defaultdict from datetime import datetime from typing import Any from backend.data.numbers import finite_number as _number from backend.features.screener.factors import FactorBuilder from backend.features.screener.formula import FormulaEvaluator from database import ReviewDatabase class BacktestRunner: def __init__( self, database: ReviewDatabase, factor_builder: FactorBuilder, formula_evaluator: FormulaEvaluator, ) -> None: self.database = database self.factor_builder = factor_builder self.formula_evaluator = formula_evaluator self._backtest_factor_cache: dict[tuple[str, int], list[dict[str, Any]]] = {} def build_factors( self, trade_date: str, history_days: int ) -> tuple[list[dict[str, Any]], str]: return self.factor_builder.build_factors( trade_date, history_days=history_days ) def apply_formula( self, rows: list[dict[str, Any]], formula: dict[str, Any], regime: str ) -> list[dict[str, Any]]: return self.formula_evaluator.apply_formula(rows, formula, regime) def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]: meta = formula.get("meta") or {} history_days = max(21, min(260, int(meta.get("history_days") or 80))) holding_days = max(1, min(30, int(meta.get("backtest_days") or 3))) take_profit = max(0.5, min(50.0, float(meta.get("take_profit") or 3))) stop_loss = min(-0.5, max(-50.0, float(meta.get("stop_loss") or -3))) dates = self.database.factor_dates(trade_date, history_days + holding_days + 20) eligible_dates = dates[:-holding_days] if len(dates) > holding_days else [] frequency = str(meta.get("frequency") or "每日") if "月" in frequency: grouped = {} for value in eligible_dates: grouped[value[:6]] = value evaluation_dates = list(grouped.values())[-8:] elif "双周" in frequency: weekly_dates = [] grouped = {} for value in eligible_dates: parsed = datetime.strptime(value, "%Y%m%d") grouped[parsed.strftime("%G-%V")] = value weekly_dates = list(grouped.values()) evaluation_dates = weekly_dates[-16::2][-8:] elif "周" in frequency: grouped = {} for value in eligible_dates: parsed = datetime.strptime(value, "%Y%m%d") grouped[parsed.strftime("%G-%V")] = value evaluation_dates = list(grouped.values())[-8:] else: evaluation_dates = eligible_dates[-8:] wins = 0 losses = 0 samples = 0 returns = [] drawdowns = [] all_data = self.database.load_factor_data( trade_date, history_days + holding_days + 20 ) bars_by_code: dict[str, list[dict[str, Any]]] = defaultdict(list) for row in all_data["bars"]: bars_by_code[row["ts_code"]].append(row) for bars in bars_by_code.values(): bars.sort(key=lambda item: item["trade_date"]) for current_date in evaluation_dates: try: cache_key = (current_date, history_days) factors = self._backtest_factor_cache.get(cache_key) if factors is None: factors, _ = self.build_factors( current_date, history_days=history_days ) if len(self._backtest_factor_cache) >= 64: self._backtest_factor_cache.pop( next(iter(self._backtest_factor_cache)) ) self._backtest_factor_cache[cache_key] = factors except ValueError: continue selected = self.apply_formula(factors, {**formula, "limit": min(10, formula["limit"])}, "backtest") for candidate in selected: bars = bars_by_code.get(candidate["ts_code"], []) index = next((i for i, row in enumerate(bars) if row["trade_date"] == current_date), -1) future = bars[index + 1:index + 1 + holding_days] if index >= 0 else [] if len(future) < holding_days: continue entry = candidate["price"] won = False lost = False for day in future: low_return = (_number(day["low"]) / entry - 1) * 100 high_return = (_number(day["high"]) / entry - 1) * 100 if low_return <= stop_loss: lost = True break if high_return >= take_profit: won = True break if won: wins += 1 elif lost: losses += 1 samples += 1 returns.append((_number(future[-1]["close"]) / entry - 1) * 100) drawdowns.append(min((_number(day["low"]) / entry - 1) * 100 for day in future)) return { "samples": samples, "wins": wins, "losses": losses, "win_rate": round(wins / samples * 100, 1) if samples else 0, "average_3d_return": round(statistics.fmean(returns), 2) if returns else 0, "average_holding_return": round(statistics.fmean(returns), 2) if returns else 0, "average_drawdown": round(statistics.fmean(drawdowns), 2) if drawdowns else 0, "evaluation_days": len(evaluation_dates), "frequency": frequency, "holding_days": holding_days, "take_profit": take_profit, "stop_loss": stop_loss, "definition": ( f"收盘后选股,未来{holding_days}日先触及+{take_profit:g}%且未先触及" f"{stop_loss:g}%计为成功;同日双触发按失败处理。" ), "approximate": True, }