139 lines
5.9 KiB
Python
139 lines
5.9 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from backend.bootstrap.config import display_compact_date as _display_date
|
|
from backend.features.screener.backtest import BacktestRunner
|
|
from backend.features.screener.catalog import REGIMES
|
|
from backend.features.screener.factors import FactorBuilder
|
|
from backend.features.screener.formula import FormulaEvaluator
|
|
from database import ReviewDatabase
|
|
|
|
|
|
class SelectionRunner:
|
|
def __init__(
|
|
self,
|
|
database: ReviewDatabase,
|
|
factor_builder: FactorBuilder,
|
|
formula_evaluator: FormulaEvaluator,
|
|
backtest_runner: BacktestRunner,
|
|
) -> None:
|
|
self.database = database
|
|
self.factor_builder = factor_builder
|
|
self.formula_evaluator = formula_evaluator
|
|
self.backtest_runner = backtest_runner
|
|
|
|
def validate_formula(self, formula: dict[str, Any]) -> dict[str, Any]:
|
|
return self.formula_evaluator.validate_formula(formula)
|
|
|
|
def build_factors(
|
|
self,
|
|
trade_date: str,
|
|
realtime_snapshot: dict[str, Any] | None,
|
|
history_days: int,
|
|
) -> tuple[list[dict[str, Any]], str]:
|
|
return self.factor_builder.build_factors(
|
|
trade_date, realtime_snapshot, 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]:
|
|
return self.backtest_runner.backtest(trade_date, formula)
|
|
|
|
def screen(
|
|
self, user_id: int, trade_date: str, formula: dict[str, Any], regime: str,
|
|
strategy_name: str, run_backtest: bool = True,
|
|
realtime_snapshot: dict[str, Any] | None = None,
|
|
mode: str = "smart",
|
|
prepared_factors: list[dict[str, Any]] | None = None,
|
|
prepared_date: str = "",
|
|
) -> dict[str, Any]:
|
|
mode = mode if mode in {"smart", "curated", "quant"} else "smart"
|
|
formula = self.validate_formula(formula)
|
|
if prepared_factors is None:
|
|
history_days = int((formula.get("meta") or {}).get("history_days") or 80)
|
|
factors, actual_date = self.build_factors(
|
|
trade_date, realtime_snapshot, history_days
|
|
)
|
|
else:
|
|
factors = prepared_factors
|
|
actual_date = prepared_date or trade_date
|
|
candidates = self.apply_formula(factors, formula, regime)
|
|
backtest = self.backtest(actual_date, formula) if run_backtest else None
|
|
required_fields = sorted({
|
|
str(item.get("field") or "")
|
|
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
|
|
if item.get("field")
|
|
})
|
|
complete_rows = sum(
|
|
1 for row in factors
|
|
if all(row.get(field) is not None for field in required_fields)
|
|
)
|
|
coverage = round(complete_rows / len(factors) * 100, 1) if factors else 0.0
|
|
health_status = "normal" if candidates else "no_signal"
|
|
if backtest and backtest["samples"] >= 20:
|
|
for candidate in candidates:
|
|
estimate = backtest["win_rate"] * 0.65 + candidate["score"] * 100 * 0.35
|
|
candidate["historical_probability"] = round(min(95, max(5, estimate)), 1)
|
|
candidate["probability_samples"] = backtest["samples"]
|
|
else:
|
|
for candidate in candidates:
|
|
candidate["historical_probability"] = None
|
|
candidate["probability_samples"] = backtest["samples"] if backtest else 0
|
|
result = {
|
|
"meta": {
|
|
"trade_date": _display_date(actual_date),
|
|
"regime": regime,
|
|
"regime_label": REGIMES.get(regime, regime),
|
|
"strategy_name": strategy_name,
|
|
"mode": mode,
|
|
"library_version": int(
|
|
(formula.get("meta") or {}).get("library_version") or 0
|
|
),
|
|
"universe_count": len(factors),
|
|
"candidate_count": len(candidates),
|
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
"health": {
|
|
"status": health_status,
|
|
"required_field_count": len(required_fields),
|
|
"complete_rows": complete_rows,
|
|
"universe_rows": len(factors),
|
|
"coverage": coverage,
|
|
"signal_count": len(candidates),
|
|
},
|
|
"selection_source": (
|
|
"tushare_rt_k+history" if realtime_snapshot else "historical_eod"
|
|
),
|
|
"realtime": bool(realtime_snapshot),
|
|
"history_cutoff": (
|
|
str(realtime_snapshot.get("previous_trade_date") or "")
|
|
if realtime_snapshot else actual_date
|
|
),
|
|
"factor_freshness": {
|
|
"realtime": [
|
|
"价格", "涨跌幅", "成交量", "成交额", "换手率",
|
|
"均线位置", "5/10日动量", "板块强度", "开盘竞价",
|
|
] if realtime_snapshot else [],
|
|
"historical": ["历史波动率", "流通市值", "资金流", "竞价因子", "回测"],
|
|
},
|
|
},
|
|
"formula": formula,
|
|
"candidates": candidates,
|
|
"backtest": backtest,
|
|
"disclaimer": (
|
|
"候选仅由策略条件与当日数据计算;历史统计不代表未来收益。"
|
|
if mode == "curated"
|
|
else "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。"
|
|
),
|
|
}
|
|
run_id = self.database.save_screener_run(
|
|
user_id, actual_date, regime, strategy_name, formula, result, mode
|
|
)
|
|
result["meta"]["run_id"] = run_id
|
|
return result
|