refactor: establish standalone application boundary
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user