142 lines
6.1 KiB
Python
142 lines
6.1 KiB
Python
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,
|
|
}
|