@@ -1,474 +0,0 @@
|
||||
"""回测 API — 信号回测 + 因子回测 + 策略回测。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from dataclasses import asdict
|
||||
from datetime import date, timedelta
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.services.backtest import (
|
||||
BacktestConfig,
|
||||
BacktestService,
|
||||
VectorbtUnavailable,
|
||||
is_available,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/backtest", tags=["backtest"])
|
||||
|
||||
FACTOR_DEFAULT_DAYS = 180
|
||||
STRATEGY_DEFAULT_DAYS = 365 * 3
|
||||
BACKTEST_MAX_SERVER_DAYS = 186
|
||||
FACTOR_MAX_SYMBOLS = 1000
|
||||
BACKTEST_SERVER_GUARD_MESSAGE = (
|
||||
"当前服务器内存约 1.8GB,回测区间最多支持 6 个月;"
|
||||
"更长周期容易触发 OOM,建议在 8GB 以上内存环境或本机运行。"
|
||||
)
|
||||
|
||||
|
||||
def _get_engine(request: Request):
|
||||
"""获取或创建 BacktestEngine (单例,PanelCache 跨请求生效)。"""
|
||||
from app.backtest.engine import BacktestEngine
|
||||
engine = getattr(request.app.state, "backtest_engine", None)
|
||||
if engine is None:
|
||||
engine = BacktestEngine(request.app.state.repo)
|
||||
request.app.state.backtest_engine = engine
|
||||
return engine
|
||||
|
||||
|
||||
def _resolve_start(req: BaseModel, end: date, default_days: int) -> date:
|
||||
"""未传 start 使用默认区间;显式传 null/空值表示全部历史。"""
|
||||
start = getattr(req, "start")
|
||||
if start is not None:
|
||||
return start
|
||||
if "start" in req.model_fields_set:
|
||||
return date(1900, 1, 1)
|
||||
return end - timedelta(days=default_days)
|
||||
|
||||
|
||||
def _guard_server_backtest_range(start: date, end: date):
|
||||
if not settings.backtest_range_guard:
|
||||
return
|
||||
days = (end - start).days + 1
|
||||
if days > BACKTEST_MAX_SERVER_DAYS:
|
||||
raise HTTPException(status_code=400, detail=BACKTEST_SERVER_GUARD_MESSAGE)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 状态
|
||||
# ================================================================
|
||||
|
||||
@router.get("/status")
|
||||
def status():
|
||||
"""前端可用此接口判断回测页是否要灰显。"""
|
||||
return {"available": True}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 信号回测 (现有接口,保持不变)
|
||||
# ================================================================
|
||||
|
||||
class BacktestRequest(BaseModel):
|
||||
symbols: list[str] = Field(..., min_length=1)
|
||||
start: date | None = None
|
||||
end: date | None = None
|
||||
entries: list[str] = []
|
||||
exits: list[str] = []
|
||||
stop_loss_pct: float | None = None
|
||||
max_hold_days: int | None = None
|
||||
fees_pct: float = 0.0002
|
||||
slippage_bps: float = 5
|
||||
matching: Literal["close_t", "open_t+1"] = "close_t"
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
def run(req: BacktestRequest, request: Request):
|
||||
"""信号回测 — 现有接口,向后兼容。"""
|
||||
repo = request.app.state.repo
|
||||
svc = BacktestService(repo)
|
||||
end = req.end or date.today()
|
||||
start = req.start or (end - timedelta(days=365 * 3))
|
||||
|
||||
cfg = BacktestConfig(
|
||||
symbols=req.symbols,
|
||||
start=start,
|
||||
end=end,
|
||||
entries=req.entries,
|
||||
exits=req.exits,
|
||||
stop_loss_pct=req.stop_loss_pct,
|
||||
max_hold_days=req.max_hold_days,
|
||||
fees_pct=req.fees_pct,
|
||||
slippage_bps=req.slippage_bps,
|
||||
matching=req.matching,
|
||||
)
|
||||
try:
|
||||
result = svc.run(cfg)
|
||||
except VectorbtUnavailable as e:
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
return asdict(result)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 因子回测
|
||||
# ================================================================
|
||||
|
||||
class FactorColumnsResponse(BaseModel):
|
||||
columns: list[dict]
|
||||
|
||||
|
||||
@router.get("/factor/columns")
|
||||
def factor_columns():
|
||||
"""返回可用的因子列列表。"""
|
||||
from app.backtest.factor import FACTOR_COLUMNS
|
||||
return {"columns": FACTOR_COLUMNS}
|
||||
|
||||
|
||||
class FactorBacktestRequest(BaseModel):
|
||||
factor_name: str
|
||||
symbols: list[str] | None = None
|
||||
start: date | None = None
|
||||
end: date | None = None
|
||||
n_groups: int = 5
|
||||
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
|
||||
weight: Literal["equal", "factor_weight"] = "equal"
|
||||
fees_pct: float = 0.0002
|
||||
slippage_bps: float = 5.0
|
||||
|
||||
|
||||
@router.post("/factor/run")
|
||||
def factor_run(req: FactorBacktestRequest, request: Request):
|
||||
"""因子回测 — IC/IR 分析 + 分层回测。"""
|
||||
from app.backtest.factor import FactorBacktestService, FactorConfig
|
||||
|
||||
engine = _get_engine(request)
|
||||
svc = FactorBacktestService(engine)
|
||||
|
||||
end = req.end or date.today()
|
||||
start = _resolve_start(req, end, STRATEGY_DEFAULT_DAYS)
|
||||
_guard_server_backtest_range(start, end)
|
||||
symbols = req.symbols if req.symbols else None
|
||||
if symbols is not None and len(symbols) > FACTOR_MAX_SYMBOLS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"指定标的最多支持 {FACTOR_MAX_SYMBOLS} 只,请缩小标的范围。",
|
||||
)
|
||||
|
||||
cfg = FactorConfig(
|
||||
factor_name=req.factor_name,
|
||||
symbols=symbols,
|
||||
start=start,
|
||||
end=end,
|
||||
n_groups=req.n_groups,
|
||||
rebalance=req.rebalance,
|
||||
weight=req.weight,
|
||||
fees_pct=req.fees_pct,
|
||||
slippage_bps=req.slippage_bps,
|
||||
)
|
||||
result = svc.run(cfg)
|
||||
return asdict(result)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 策略回测
|
||||
# ================================================================
|
||||
|
||||
class StrategyBacktestRequest(BaseModel):
|
||||
strategy_id: str
|
||||
symbols: list[str] | None = None
|
||||
start: date | None = None
|
||||
end: date | None = None
|
||||
params: dict | None = None
|
||||
overrides: dict | None = None
|
||||
# matching 向后兼容; 显式传 entry_fill/exit_fill 时以二者为准。
|
||||
matching: Literal["close_t", "open_t+1"] = "open_t+1"
|
||||
entry_fill: Literal["close_t", "open_t+1"] | None = None
|
||||
exit_fill: Literal["close_t", "open_t+1"] | None = None
|
||||
fees_pct: float = 0.0002
|
||||
slippage_bps: float = 5.0
|
||||
max_positions: int = 10
|
||||
max_exposure_pct: float = 1.0
|
||||
initial_capital: float = 1_000_000.0
|
||||
position_sizing: Literal["equal", "score_weight"] = "equal"
|
||||
mode: Literal["position", "full"] = "position"
|
||||
holding_days: int = 5
|
||||
|
||||
|
||||
@router.post("/strategy/run")
|
||||
def strategy_run(req: StrategyBacktestRequest, request: Request):
|
||||
"""策略回测 — 复用 StrategyDef 体系做全周期回测。"""
|
||||
from app.backtest.strategy import StrategyBacktestService, StrategyBacktestConfig
|
||||
|
||||
engine = _get_engine(request)
|
||||
strategy_engine = request.app.state.strategy_engine
|
||||
svc = StrategyBacktestService(engine, strategy_engine)
|
||||
|
||||
end = req.end or date.today()
|
||||
start = _resolve_start(req, end, FACTOR_DEFAULT_DAYS)
|
||||
_guard_server_backtest_range(start, end)
|
||||
|
||||
cfg = StrategyBacktestConfig(
|
||||
strategy_id=req.strategy_id,
|
||||
symbols=req.symbols if req.symbols else None,
|
||||
start=start,
|
||||
end=end,
|
||||
params=req.params,
|
||||
overrides=req.overrides,
|
||||
matching=req.matching,
|
||||
entry_fill=req.entry_fill,
|
||||
exit_fill=req.exit_fill,
|
||||
fees_pct=req.fees_pct,
|
||||
slippage_bps=req.slippage_bps,
|
||||
max_positions=req.max_positions,
|
||||
max_exposure_pct=req.max_exposure_pct,
|
||||
initial_capital=req.initial_capital,
|
||||
position_sizing=req.position_sizing,
|
||||
mode=req.mode,
|
||||
holding_days=req.holding_days,
|
||||
)
|
||||
result = svc.run(cfg)
|
||||
return asdict(result)
|
||||
|
||||
|
||||
# ── SSE 流式回测 (实时进度 + 可取消 + 支持重连) ───────────────────
|
||||
|
||||
import time
|
||||
import hashlib
|
||||
|
||||
|
||||
class _BacktestJob:
|
||||
"""单个回测任务的状态, 存模块级供重连使用。"""
|
||||
__slots__ = ("key", "cancel_event", "progress", "result", "error", "done", "finish_ts")
|
||||
|
||||
def __init__(self, key: str):
|
||||
self.key = key
|
||||
self.cancel_event = threading.Event()
|
||||
self.progress: list[dict] = [] # 进度历史 (新连接可回放)
|
||||
self.result = None # 完成后的结果
|
||||
self.error: str | None = None
|
||||
self.done = False
|
||||
self.finish_ts: float = 0.0
|
||||
|
||||
|
||||
# 模块级任务表: key -> _BacktestJob
|
||||
_running_jobs: dict[str, _BacktestJob] = {}
|
||||
_jobs_lock = threading.Lock()
|
||||
_JOB_TTL = 300 # 完成后保留 5 分钟
|
||||
|
||||
|
||||
def _cleanup_stale_jobs():
|
||||
"""清理过期任务 (完成超过 TTL 的)。"""
|
||||
now = time.time()
|
||||
stale = [k for k, j in _running_jobs.items() if j.done and now - j.finish_ts > _JOB_TTL]
|
||||
for k in stale:
|
||||
_running_jobs.pop(k, None)
|
||||
|
||||
|
||||
def _make_job_key(
|
||||
strategy_id: str, symbols: str | None, start: str | None, end: str | None,
|
||||
matching: str, entry_fill: str | None, exit_fill: str | None,
|
||||
fees_pct: float, slippage_bps: float,
|
||||
max_positions: int, max_exposure_pct: float, initial_capital: float, position_sizing: str,
|
||||
params: str | None, overrides: str | None,
|
||||
mode: str = "position", holding_days: int = 5,
|
||||
) -> str:
|
||||
raw = f"{strategy_id}|{symbols}|{start}|{end}|{matching}|{entry_fill}|{exit_fill}|{fees_pct}|{slippage_bps}|{max_positions}|{max_exposure_pct}|{initial_capital}|{position_sizing}|{params}|{overrides}|{mode}|{holding_days}"
|
||||
return hashlib.md5(raw.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
@router.get("/strategy/stream")
|
||||
async def strategy_stream(
|
||||
request: Request,
|
||||
strategy_id: str,
|
||||
symbols: str | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
matching: str = "open_t+1",
|
||||
entry_fill: str | None = None,
|
||||
exit_fill: str | None = None,
|
||||
fees_pct: float = 0.0002,
|
||||
slippage_bps: float = 5.0,
|
||||
max_positions: int = 10,
|
||||
max_exposure_pct: float = 1.0,
|
||||
initial_capital: float = 1_000_000.0,
|
||||
position_sizing: str = "equal",
|
||||
params: str | None = None,
|
||||
overrides: str | None = None,
|
||||
mode: str = "position",
|
||||
holding_days: int = 5,
|
||||
):
|
||||
"""SSE 流式策略回测: 实时推送进度, 完成后推送结果, 支持重连 (刷新/切页后恢复)。
|
||||
|
||||
- 相同参数的任务只启动一次, 多次连接订阅同一个任务
|
||||
- 断开连接不会取消任务 (除非显式调用 cancel)
|
||||
- 结果保留 5 分钟供重连
|
||||
|
||||
事件类型:
|
||||
- progress: {day, total, date, equity}
|
||||
- done: {result} (完整回测结果)
|
||||
- error: {message}
|
||||
"""
|
||||
from app.backtest.strategy import StrategyBacktestService, StrategyBacktestConfig
|
||||
|
||||
engine = _get_engine(request)
|
||||
strategy_engine = request.app.state.strategy_engine
|
||||
svc = StrategyBacktestService(engine, strategy_engine)
|
||||
|
||||
end_date = date.fromisoformat(end) if end else date.today()
|
||||
if start:
|
||||
start_date = date.fromisoformat(start)
|
||||
else:
|
||||
# 空 start = 全部历史: 用本地最早日K日期, 查不到再回退到默认窗口
|
||||
earliest = request.app.state.repo.earliest_daily_date()
|
||||
start_date = earliest or (end_date - timedelta(days=FACTOR_DEFAULT_DAYS))
|
||||
|
||||
# 服务端范围保护
|
||||
guard_violated = False
|
||||
if settings.backtest_range_guard:
|
||||
days = (end_date - start_date).days + 1
|
||||
if days > BACKTEST_MAX_SERVER_DAYS:
|
||||
guard_violated = True
|
||||
|
||||
job_key = _make_job_key(
|
||||
strategy_id, symbols, start, end,
|
||||
matching, entry_fill, exit_fill,
|
||||
fees_pct, slippage_bps, max_positions, max_exposure_pct, initial_capital, position_sizing,
|
||||
params, overrides,
|
||||
mode, holding_days,
|
||||
)
|
||||
|
||||
_cleanup_stale_jobs()
|
||||
|
||||
# 获取或创建任务
|
||||
with _jobs_lock:
|
||||
job = _running_jobs.get(job_key)
|
||||
if job is None:
|
||||
job = _BacktestJob(job_key)
|
||||
_running_jobs[job_key] = job
|
||||
is_new = True
|
||||
else:
|
||||
is_new = False
|
||||
|
||||
async def event_generator():
|
||||
# 范围保护: 直接报错
|
||||
if guard_violated:
|
||||
yield f"event: error\ndata: {json.dumps({'message': BACKTEST_SERVER_GUARD_MESSAGE}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# 如果是新任务, 启动回测线程
|
||||
if is_new and not job.done:
|
||||
cfg = StrategyBacktestConfig(
|
||||
strategy_id=strategy_id,
|
||||
symbols=[s.strip() for s in symbols.split(",") if s.strip()] if symbols else None,
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
params=json.loads(params) if params else None,
|
||||
overrides=json.loads(overrides) if overrides else None,
|
||||
matching=matching,
|
||||
entry_fill=entry_fill,
|
||||
exit_fill=exit_fill,
|
||||
fees_pct=fees_pct,
|
||||
slippage_bps=slippage_bps,
|
||||
max_positions=int(max_positions),
|
||||
max_exposure_pct=float(max_exposure_pct),
|
||||
initial_capital=float(initial_capital),
|
||||
position_sizing=position_sizing,
|
||||
mode=mode,
|
||||
holding_days=int(holding_days),
|
||||
)
|
||||
|
||||
def _run_backtest():
|
||||
try:
|
||||
result = svc.run(cfg, lambda d: job.progress.append(d), job.cancel_event)
|
||||
job.result = result
|
||||
job.done = True
|
||||
job.finish_ts = time.time()
|
||||
except Exception as e:
|
||||
job.error = str(e)
|
||||
job.done = True
|
||||
job.finish_ts = time.time()
|
||||
|
||||
# 启动后台线程 (不阻塞事件循环)
|
||||
threading.Thread(target=_run_backtest, daemon=True).start()
|
||||
|
||||
# 订阅进度: 用读指针读 job.progress 列表 (多连接互不干扰)
|
||||
cursor = 0
|
||||
tick = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
# 已完成: 推送最终结果/错误并退出
|
||||
if job.done:
|
||||
if job.error:
|
||||
yield f"event: error\ndata: {json.dumps({'message': job.error}, ensure_ascii=False)}\n\n"
|
||||
elif job.result is not None:
|
||||
r = job.result
|
||||
if hasattr(r, "error") and r.error == "cancelled":
|
||||
yield f"event: error\ndata: {json.dumps({'message': '回测已取消'}, ensure_ascii=False)}\n\n"
|
||||
elif hasattr(r, "error") and r.error:
|
||||
yield f"event: error\ndata: {json.dumps({'message': r.error}, ensure_ascii=False)}\n\n"
|
||||
else:
|
||||
yield f"event: done\ndata: {json.dumps(asdict(r), ensure_ascii=False, default=str)}\n\n"
|
||||
return
|
||||
|
||||
# 断开检测: 每 4 轮检查一次 (降低 GIL 抢占频率)
|
||||
tick += 1
|
||||
if tick % 4 == 0 and await request.is_disconnected():
|
||||
break
|
||||
|
||||
# 推送新进度 (从 cursor 开始读)
|
||||
prog_list = job.progress
|
||||
while cursor < len(prog_list):
|
||||
msg = prog_list[cursor]
|
||||
cursor += 1
|
||||
yield f"event: progress\ndata: {json.dumps(msg, ensure_ascii=False, default=str)}\n\n"
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/strategy/cancel")
|
||||
async def strategy_cancel(request: Request):
|
||||
"""取消正在运行的回测任务 (前端传 query string, 后端算 job_key)。"""
|
||||
body = await request.json()
|
||||
qs = body.get("qs", "")
|
||||
# 解析 qs 得到参数
|
||||
from urllib.parse import parse_qs
|
||||
p = parse_qs(qs)
|
||||
def _get(key: str, default: str = "") -> str:
|
||||
return p.get(key, [default])[0]
|
||||
job_key = _make_job_key(
|
||||
_get("strategy_id"),
|
||||
_get("symbols") or None,
|
||||
_get("start") or None,
|
||||
_get("end") or None,
|
||||
_get("matching", "open_t+1"),
|
||||
_get("entry_fill") or None,
|
||||
_get("exit_fill") or None,
|
||||
float(_get("fees_pct", "0.0002")),
|
||||
float(_get("slippage_bps", "5")),
|
||||
int(_get("max_positions", "10")),
|
||||
float(_get("max_exposure_pct", "1")),
|
||||
float(_get("initial_capital", "1000000")),
|
||||
_get("position_sizing", "equal"),
|
||||
_get("params") or None,
|
||||
_get("overrides") or None,
|
||||
_get("mode", "position"),
|
||||
int(_get("holding_days", "5")),
|
||||
)
|
||||
job = _running_jobs.get(job_key)
|
||||
if job and not job.done:
|
||||
job.cancel_event.set()
|
||||
return {"ok": True}
|
||||
return {"ok": False, "message": "任务不存在或已完成"}
|
||||
|
||||
@@ -505,7 +505,7 @@ def _compute_storage(data_dir: Path) -> dict:
|
||||
stats[f"{key}_size_mb"] = sz
|
||||
|
||||
# total: 再加上其他零散文件(pools, financials, capabilities.json 等)
|
||||
other_dirs = ["pools", "financials", "backtest_results", "screener_results", "ai_cache"]
|
||||
other_dirs = ["pools", "financials", "screener_results", "ai_cache"]
|
||||
for name in other_dirs:
|
||||
d = data_dir / name
|
||||
if d.exists():
|
||||
@@ -629,7 +629,7 @@ def clear_data(request: Request):
|
||||
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
|
||||
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute",
|
||||
"adj_factor", "adj_factor_etf", "instruments", "instruments_index", "instruments_etf", "pools", "financials",
|
||||
"backtest_results", "screener_results", "ai_cache",
|
||||
"screener_results", "ai_cache",
|
||||
):
|
||||
d = data_dir / sub
|
||||
if d.exists():
|
||||
|
||||
@@ -150,7 +150,7 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di
|
||||
"""按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。
|
||||
|
||||
key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。
|
||||
JOIN 逻辑参考 watchlist.watchlist_enriched;任何 ext 表/字段缺失都静默跳过。
|
||||
JOIN 逻辑;任何 ext 表/字段缺失都静默跳过。
|
||||
"""
|
||||
if not ext_columns or not ext_columns.strip():
|
||||
return resp
|
||||
@@ -380,7 +380,7 @@ async def sync_minute(request: Request):
|
||||
|
||||
try:
|
||||
progress("sync_minute", 5, "解析标的池…")
|
||||
universe = sorted(set(get_pool("watchlist")) | set(get_pool("CN_Equity_A")))
|
||||
universe = sorted(set(get_pool("CN_Equity_A")))
|
||||
# 补充 instruments 全量标的,覆盖北交所、新股等
|
||||
inst_path = repo.store.data_dir / "instruments" / "instruments.parquet"
|
||||
if inst_path.exists():
|
||||
|
||||
@@ -332,7 +332,6 @@ def get_preferences() -> dict:
|
||||
"instruments_schedule": preferences.get_instruments_schedule(),
|
||||
"enriched_batch_size": preferences.get_enriched_batch_size(),
|
||||
"index_daily_batch_size": preferences.get_index_daily_batch_size(),
|
||||
"watchlist_columns": preferences.get_watchlist_columns(),
|
||||
"screener_result_columns": preferences.get_screener_result_columns(),
|
||||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||||
"nav_order": preferences.get_nav_order(),
|
||||
@@ -343,14 +342,6 @@ def get_preferences() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/preferences/watchlist-columns")
|
||||
def get_watchlist_columns() -> dict:
|
||||
"""返回自选列表列配置。"""
|
||||
from app.services import preferences
|
||||
cols = preferences.get_watchlist_columns()
|
||||
return {"columns": cols}
|
||||
|
||||
|
||||
class NavOrderIn(BaseModel):
|
||||
nav_order: list[str]
|
||||
|
||||
@@ -375,15 +366,6 @@ def update_nav_hidden(req: NavHiddenIn) -> dict:
|
||||
return {"nav_hidden": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/watchlist-columns")
|
||||
def update_watchlist_columns(req: dict) -> dict:
|
||||
"""保存自选列表列配置。"""
|
||||
from app.services import preferences
|
||||
columns = req.get("columns", [])
|
||||
saved = preferences.set_watchlist_columns(columns)
|
||||
return {"columns": saved}
|
||||
|
||||
|
||||
@router.get("/preferences/screener-result-columns")
|
||||
def get_screener_result_columns() -> dict:
|
||||
"""返回策略结果列表列配置。"""
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
"""自选股 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import watchlist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
|
||||
|
||||
|
||||
class AddRequest(BaseModel):
|
||||
symbol: str
|
||||
note: str = ""
|
||||
|
||||
|
||||
class BatchAddRequest(BaseModel):
|
||||
symbols: list[str]
|
||||
note: str = ""
|
||||
|
||||
|
||||
def _with_names(rows: list[dict], request: Request) -> list[dict]:
|
||||
if not rows:
|
||||
return rows
|
||||
try:
|
||||
df_i = request.app.state.repo.get_instruments()
|
||||
if df_i.is_empty() or "symbol" not in df_i.columns or "name" not in df_i.columns:
|
||||
return rows
|
||||
name_by_symbol = dict(df_i.select(["symbol", "name"]).iter_rows())
|
||||
return [{**row, "name": name_by_symbol.get(row.get("symbol"))} for row in rows]
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("attach watchlist names failed: %s", e)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_all(request: Request):
|
||||
return {"symbols": _with_names(watchlist.list_symbols(), request)}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def add_one(req: AddRequest, request: Request):
|
||||
rows = watchlist.add(req.symbol, req.note)
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def add_batch(req: BatchAddRequest, request: Request):
|
||||
for sym in req.symbols:
|
||||
watchlist.add(sym, req.note)
|
||||
return {"symbols": _with_names(watchlist.list_symbols(), request), "added": len(req.symbols)}
|
||||
|
||||
|
||||
@router.post("/{symbol}/top")
|
||||
def move_one_to_top(symbol: str, request: Request):
|
||||
rows = watchlist.move_to_top(symbol)
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.delete("/{symbol}")
|
||||
def remove_one(symbol: str, request: Request):
|
||||
rows = watchlist.remove(symbol)
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.delete("")
|
||||
def clear_all():
|
||||
"""清空自选列表。"""
|
||||
count = watchlist.clear()
|
||||
return {"removed": count}
|
||||
|
||||
|
||||
# 自选页需要的列
|
||||
_WATCHLIST_COLS = [
|
||||
"symbol", "close", "change_pct", "change_amount", "amount",
|
||||
"turnover_rate",
|
||||
"amplitude", "annual_vol_20d",
|
||||
"vol_ratio_5d",
|
||||
"ma5", "ma10", "ma20", "ma60",
|
||||
"vol_ma5", "vol_ma10",
|
||||
"high_60d", "low_60d",
|
||||
"rsi_6", "rsi_14", "rsi_24",
|
||||
"macd_dif", "macd_dea", "macd_hist",
|
||||
"kdj_k", "kdj_d", "kdj_j",
|
||||
"boll_upper", "boll_lower",
|
||||
"atr_14",
|
||||
"momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
|
||||
"consecutive_limit_ups", "consecutive_limit_downs",
|
||||
"signal_limit_up", "signal_limit_down", "signal_volume_surge",
|
||||
"signal_ma_golden_5_20", "signal_macd_golden", "signal_n_day_high",
|
||||
"signal_boll_breakout_upper", "signal_ma20_breakout",
|
||||
"signal_ma_dead_5_20", "signal_macd_dead", "signal_n_day_low",
|
||||
"signal_boll_breakdown_lower", "signal_ma20_breakdown",
|
||||
]
|
||||
|
||||
|
||||
@router.get("/enriched")
|
||||
def watchlist_enriched(
|
||||
request: Request,
|
||||
ext_columns: str | None = Query(None, description="逗号分隔的 ext 列: config_id.field_name"),
|
||||
):
|
||||
"""自选股 enriched 数据 — 直接从 enriched 最新日读取, 无即时计算。
|
||||
|
||||
ext_columns 参数示例: "industry_rating.score,fund_flow.net_inflow"
|
||||
会动态 LEFT JOIN 对应的 ext_{config_id} DuckDB view。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
repo = request.app.state.repo
|
||||
symbols = [r["symbol"] for r in watchlist.list_symbols()]
|
||||
if not symbols:
|
||||
return {"rows": [], "as_of": None, "elapsed_ms": 0}
|
||||
|
||||
df_e, cache_date = repo.get_enriched_latest()
|
||||
if df_e.is_empty():
|
||||
return {"rows": [], "as_of": None, "elapsed_ms": 0}
|
||||
|
||||
# 按 symbol 过滤
|
||||
df = df_e.filter(pl.col("symbol").is_in(symbols))
|
||||
if df.is_empty():
|
||||
return {"rows": [], "as_of": str(cache_date) if cache_date else None, "elapsed_ms": 0}
|
||||
|
||||
# JOIN instruments 取 name + float_shares
|
||||
df_i = repo.get_instruments()
|
||||
if not df_i.is_empty() and "name" in df_i.columns:
|
||||
inst_cols = [c for c in ["symbol", "name", "float_shares"] if c in df_i.columns]
|
||||
df = df.join(df_i.select(inst_cols), on="symbol", how="left")
|
||||
|
||||
# 选择内置需要的列
|
||||
keep = [c for c in _WATCHLIST_COLS + ["name", "float_shares"] if c in df.columns]
|
||||
df = df.select(keep)
|
||||
|
||||
# 动态 JOIN 扩展数据表
|
||||
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
|
||||
if ext_specs:
|
||||
db = repo.store.db
|
||||
data_dir = repo.store.data_dir
|
||||
from app.services.ext_data import ExtConfigStore
|
||||
from app.api.ext_data import _read_ext_dataframe
|
||||
|
||||
ext_store = ExtConfigStore(data_dir)
|
||||
configs = {c.id: c for c in ext_store.load_all()}
|
||||
|
||||
for config_id, field_name in ext_specs:
|
||||
view_name = f"ext_{config_id}"
|
||||
ext_col_name = f"{config_id}__{field_name}"
|
||||
try:
|
||||
# 扩展时序数据必须只取最新分区;否则一个 symbol 会按历史分区数被 JOIN 放大。
|
||||
cfg = configs.get(config_id)
|
||||
if cfg:
|
||||
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
|
||||
else:
|
||||
ext_df = pl.from_arrow(db.query(
|
||||
f"SELECT symbol, \"{field_name}\" FROM {view_name}"
|
||||
).arrow())
|
||||
if not ext_df.is_empty() and "symbol" in ext_df.columns:
|
||||
ext_df = (
|
||||
ext_df
|
||||
.select(["symbol", field_name])
|
||||
.unique(subset=["symbol"], keep="last")
|
||||
.rename({field_name: ext_col_name})
|
||||
)
|
||||
df = df.join(ext_df.select(["symbol", ext_col_name]), on="symbol", how="left")
|
||||
except Exception:
|
||||
# view 不存在或字段不存在,尝试直接读 parquet
|
||||
cfg = configs.get(config_id)
|
||||
if cfg:
|
||||
try:
|
||||
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
|
||||
if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns:
|
||||
ext_df = (
|
||||
ext_df
|
||||
.select(["symbol", field_name])
|
||||
.unique(subset=["symbol"], keep="last")
|
||||
.rename({field_name: ext_col_name})
|
||||
)
|
||||
df = df.join(ext_df, on="symbol", how="left")
|
||||
except Exception as e2:
|
||||
logger.debug("ext join fallback failed for %s.%s: %s", config_id, field_name, e2)
|
||||
|
||||
# sanitize NaN / Inf
|
||||
float_cols = [c for c in df.columns if df[c].dtype.is_float()]
|
||||
if float_cols:
|
||||
df = df.with_columns([
|
||||
pl.when(pl.col(c).is_nan() | pl.col(c).is_infinite())
|
||||
.then(None)
|
||||
.otherwise(pl.col(c))
|
||||
.alias(c)
|
||||
for c in float_cols
|
||||
])
|
||||
|
||||
# 按自选添加顺序(新加的在前)重排行
|
||||
order_map = {s: i for i, s in enumerate(symbols)}
|
||||
df = df.with_columns(pl.col("symbol").map_elements(lambda s: order_map.get(s, len(symbols)), return_dtype=pl.Int32).alias("_sort_order"))
|
||||
df = df.sort("_sort_order").drop("_sort_order")
|
||||
|
||||
rows = df.to_dicts()
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
return {"rows": rows, "as_of": str(cache_date) if cache_date else None, "elapsed_ms": elapsed}
|
||||
|
||||
|
||||
def _parse_ext_columns(ext_columns: str) -> list[tuple[str, str]]:
|
||||
"""解析 'config_id1.field1,config_id2.field2' 为 [(config_id, field_name), ...]"""
|
||||
result = []
|
||||
for part in ext_columns.split(","):
|
||||
part = part.strip()
|
||||
if "." not in part:
|
||||
continue
|
||||
config_id, field_name = part.split(".", 1)
|
||||
config_id = config_id.strip()
|
||||
field_name = field_name.strip()
|
||||
if config_id and field_name:
|
||||
result.append((config_id, field_name))
|
||||
return result
|
||||
@@ -1,4 +0,0 @@
|
||||
"""回测模块 — 因子回测 + 策略回测 + 信号回测。
|
||||
|
||||
架构: BacktestEngine (共享) → FactorBacktestService / StrategyBacktestService
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,480 +0,0 @@
|
||||
"""因子回测服务 — IC/IR 分析 + 分层回测 + 多空组合。
|
||||
|
||||
纯 Polars 向量化实现,无 pandas 依赖。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
|
||||
from app.backtest.engine import BacktestEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 可用因子列 (从 ENRICHED_COLUMNS 过滤出数值型指标)
|
||||
FACTOR_COLUMNS: list[dict] = [
|
||||
{"id": "momentum_5d", "label": "5日动量", "group": "动量", "desc": "5日涨跌幅,正值表示上涨趋势"},
|
||||
{"id": "momentum_10d", "label": "10日动量", "group": "动量", "desc": "10日涨跌幅,中短期趋势指标"},
|
||||
{"id": "momentum_20d", "label": "20日动量", "group": "动量", "desc": "月度涨跌幅,常用因子"},
|
||||
{"id": "momentum_30d", "label": "30日动量", "group": "动量", "desc": "30日涨跌幅"},
|
||||
{"id": "momentum_60d", "label": "60日动量", "group": "动量", "desc": "季度涨跌幅,中期动量"},
|
||||
{"id": "rsi_6", "label": "RSI(6)", "group": "超买超卖", "desc": "6日相对强弱指标,敏感度高"},
|
||||
{"id": "rsi_14", "label": "RSI(14)", "group": "超买超卖", "desc": "14日相对强弱指标,经典周期"},
|
||||
{"id": "rsi_24", "label": "RSI(24)", "group": "超买超卖", "desc": "24日相对强弱指标"},
|
||||
{"id": "annual_vol_20d","label": "20日波动率", "group": "波动率", "desc": "20日年化波动率"},
|
||||
{"id": "atr_14", "label": "ATR(14)", "group": "波动率", "desc": "14日平均真实波幅"},
|
||||
{"id": "vol_ratio_5d", "label": "量比(5日)", "group": "量价", "desc": "当日成交量 / 5日均量"},
|
||||
{"id": "turnover_rate", "label": "换手率", "group": "量价", "desc": "当日换手率"},
|
||||
{"id": "macd_hist", "label": "MACD柱", "group": "趋势", "desc": "MACD柱状图值"},
|
||||
{"id": "kdj_k", "label": "KDJ-K", "group": "趋势", "desc": "KDJ指标K值"},
|
||||
{"id": "change_pct", "label": "日涨跌幅", "group": "基础", "desc": "当日涨跌幅"},
|
||||
{"id": "amplitude", "label": "日振幅", "group": "基础", "desc": "当日振幅 (最高-最低)/昨收"},
|
||||
]
|
||||
|
||||
FACTOR_WARMUP_DAYS = 120
|
||||
|
||||
|
||||
@dataclass
|
||||
class FactorConfig:
|
||||
factor_name: str
|
||||
symbols: list[str] | None
|
||||
start: date
|
||||
end: date
|
||||
n_groups: int = 5
|
||||
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
|
||||
weight: Literal["equal", "factor_weight"] = "equal"
|
||||
fees_pct: float = 0.0002
|
||||
slippage_bps: float = 5.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroupStats:
|
||||
group: int
|
||||
label: str
|
||||
total_return: float
|
||||
annual_return: float
|
||||
max_drawdown: float
|
||||
sharpe: float
|
||||
win_rate: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class FactorResult:
|
||||
run_id: str
|
||||
config: dict
|
||||
# IC 分析
|
||||
ic_mean: float | None = None
|
||||
ic_std: float | None = None
|
||||
ir: float | None = None
|
||||
ic_win_rate: float | None = None
|
||||
ic_series: list[dict] = field(default_factory=list)
|
||||
# 分层
|
||||
group_stats: list[dict] = field(default_factory=list)
|
||||
group_nav: list[dict] = field(default_factory=list)
|
||||
# 多空
|
||||
long_short_stats: dict = field(default_factory=dict)
|
||||
long_short_nav: list[dict] = field(default_factory=list)
|
||||
# 元信息
|
||||
elapsed_ms: float = 0.0
|
||||
n_symbols: int = 0
|
||||
n_dates: int = 0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class FactorBacktestService:
|
||||
def __init__(self, engine: BacktestEngine) -> None:
|
||||
self.engine = engine
|
||||
|
||||
def run(self, config: FactorConfig) -> FactorResult:
|
||||
t0 = time.perf_counter()
|
||||
run_id = uuid.uuid4().hex[:10]
|
||||
|
||||
def _err(msg: str) -> FactorResult:
|
||||
return FactorResult(
|
||||
run_id=run_id,
|
||||
config=self._config_to_dict(config),
|
||||
error=msg,
|
||||
elapsed_ms=(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
# 加载基础面板: 当前 enriched parquet 只持久化基础列, 指标因子可能需要运行时计算。
|
||||
panel_columns = ["symbol", "date", "open", "high", "low", "close", "volume", "turnover_rate"]
|
||||
if config.factor_name not in panel_columns:
|
||||
panel_columns.append(config.factor_name)
|
||||
load_start = config.start
|
||||
if config.factor_name not in {"turnover_rate"}:
|
||||
load_start = config.start - timedelta(days=FACTOR_WARMUP_DAYS)
|
||||
|
||||
panel = self.engine.load_panel(
|
||||
config.symbols,
|
||||
load_start,
|
||||
config.end,
|
||||
columns=panel_columns,
|
||||
)
|
||||
if panel.is_empty():
|
||||
return _err("无数据,请检查日期范围或先运行盘后管道")
|
||||
|
||||
factor_col = config.factor_name
|
||||
if factor_col not in panel.columns:
|
||||
panel = self._compute_missing_factor(panel, factor_col)
|
||||
if factor_col not in panel.columns:
|
||||
return _err(f"因子列 '{factor_col}' 不存在于 enriched 数据中, 且无法从基础行情计算")
|
||||
if "close" not in panel.columns:
|
||||
return _err("enriched 数据缺少收盘价 close")
|
||||
panel = panel.select(["symbol", "date", "close", factor_col])
|
||||
panel = panel.filter((pl.col("date") >= config.start) & (pl.col("date") <= config.end))
|
||||
|
||||
# 过滤有效行
|
||||
panel = panel.filter(
|
||||
pl.col(factor_col).is_not_null()
|
||||
& pl.col("close").is_not_null()
|
||||
& (pl.col("close") > 0)
|
||||
)
|
||||
if panel.is_empty():
|
||||
return _err("过滤后无有效数据")
|
||||
|
||||
n_symbols = panel["symbol"].n_unique()
|
||||
n_dates = panel["date"].n_unique()
|
||||
|
||||
# 计算下期收益
|
||||
# 根据调仓频率计算不同周期的 forward return
|
||||
if config.rebalance == "daily":
|
||||
panel = panel.with_columns(
|
||||
(pl.col("close").shift(-1).over("symbol") / pl.col("close") - 1)
|
||||
.alias("_next_return")
|
||||
)
|
||||
else:
|
||||
# weekly/monthly: 计算到下个调仓日的收益
|
||||
panel = self._calc_period_return(panel, config.rebalance)
|
||||
|
||||
# ── 1. IC 分析 ──
|
||||
ic_df = self._calc_ic(panel, factor_col)
|
||||
ic_series = [
|
||||
{"date": str(row["date"]), "ic": round(float(row["ic"]), 4)}
|
||||
for row in ic_df.iter_rows(named=True)
|
||||
if row["ic"] is not None and not np.isnan(float(row["ic"]))
|
||||
]
|
||||
ic_values = [r["ic"] for r in ic_series]
|
||||
ic_mean = float(np.mean(ic_values)) if ic_values else None
|
||||
ic_std = float(np.std(ic_values)) if ic_values else None
|
||||
ir = (ic_mean / ic_std) if (ic_mean is not None and ic_std and ic_std > 1e-8) else None
|
||||
ic_win_rate = (sum(1 for v in ic_values if v > 0) / len(ic_values)) if ic_values else None
|
||||
|
||||
# ── 2. 分层回测 ──
|
||||
panel = self._add_groups(panel, factor_col, config.n_groups)
|
||||
group_nav = self._calc_group_nav(panel, config)
|
||||
group_stats = self._calc_group_stats(group_nav, config.start, config.end)
|
||||
|
||||
# ── 3. 多空组合 ──
|
||||
long_short_nav, long_short_stats = self._calc_long_short(group_nav, config)
|
||||
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
return FactorResult(
|
||||
run_id=run_id,
|
||||
config=self._config_to_dict(config),
|
||||
ic_mean=round(ic_mean, 4) if ic_mean is not None else None,
|
||||
ic_std=round(ic_std, 4) if ic_std is not None else None,
|
||||
ir=round(ir, 4) if ir is not None else None,
|
||||
ic_win_rate=round(ic_win_rate, 4) if ic_win_rate is not None else None,
|
||||
ic_series=ic_series,
|
||||
group_stats=group_stats,
|
||||
group_nav=group_nav,
|
||||
long_short_stats=long_short_stats,
|
||||
long_short_nav=long_short_nav,
|
||||
elapsed_ms=round(elapsed, 1),
|
||||
n_symbols=n_symbols,
|
||||
n_dates=n_dates,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _compute_missing_factor(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame:
|
||||
required = {"symbol", "date", "open", "high", "low", "close", "volume"}
|
||||
if not required.issubset(panel.columns):
|
||||
missing = sorted(required - set(panel.columns))
|
||||
logger.warning("factor %s cannot be computed, missing columns: %s", factor_col, missing)
|
||||
return panel
|
||||
|
||||
from app.indicators.pipeline import compute_indicators
|
||||
|
||||
computed = compute_indicators(panel)
|
||||
if factor_col not in computed.columns:
|
||||
return panel
|
||||
return computed.select(["symbol", "date", "close", factor_col])
|
||||
|
||||
# ── IC 计算 ──
|
||||
|
||||
@staticmethod
|
||||
def _calc_ic(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame:
|
||||
"""计算截面 Rank IC (因子值 rank vs 下期收益 rank 的相关系数)。"""
|
||||
return (
|
||||
panel.filter(pl.col("_next_return").is_not_null())
|
||||
.group_by("date")
|
||||
.agg(
|
||||
pl.corr(
|
||||
pl.col(factor_col).rank(method="random"),
|
||||
pl.col("_next_return").rank(method="random"),
|
||||
).alias("ic")
|
||||
)
|
||||
.sort("date")
|
||||
)
|
||||
|
||||
# ── 调仓期收益 ──
|
||||
|
||||
@staticmethod
|
||||
def _calc_period_return(panel: pl.DataFrame, rebalance: str) -> pl.DataFrame:
|
||||
"""计算到下个调仓日的收益。
|
||||
|
||||
weekly: 下周一的 open / 今日 close - 1
|
||||
monthly: 下月首个交易日的 open / 今日 close - 1
|
||||
只在调仓日标记行有效,其他行为 null。
|
||||
"""
|
||||
import datetime as _dt
|
||||
|
||||
all_dates = sorted(panel["date"].unique().to_list())
|
||||
date_set = set(all_dates)
|
||||
|
||||
if rebalance == "weekly":
|
||||
# 调仓日 = 每周一
|
||||
rebalance_dates = set()
|
||||
for d in all_dates:
|
||||
if hasattr(d, "weekday"):
|
||||
wd = d.weekday()
|
||||
else:
|
||||
wd = _dt.date.fromisoformat(str(d)).weekday()
|
||||
if wd == 0: # Monday
|
||||
rebalance_dates.add(d)
|
||||
else: # monthly
|
||||
# 调仓日 = 每月首个交易日
|
||||
seen_months: set[str] = set()
|
||||
rebalance_dates = set()
|
||||
for d in sorted(all_dates):
|
||||
m = str(d)[:7] # "YYYY-MM"
|
||||
if m not in seen_months:
|
||||
seen_months.add(m)
|
||||
rebalance_dates.add(d)
|
||||
|
||||
if not rebalance_dates:
|
||||
panel = panel.with_columns(pl.lit(None).cast(pl.Float64).alias("_next_return"))
|
||||
return panel
|
||||
|
||||
# 对每个调仓日,找到下一个调仓日
|
||||
sorted_rebalance = sorted(rebalance_dates)
|
||||
next_rebalance_map: dict = {}
|
||||
for i, d in enumerate(sorted_rebalance):
|
||||
if i + 1 < len(sorted_rebalance):
|
||||
next_rebalance_map[d] = sorted_rebalance[i + 1]
|
||||
# 最后一个调仓日没有下一个,不计算收益
|
||||
|
||||
# 构建 (date, symbol) → next_rebalance_date 的 close 价格映射
|
||||
# 简化: 用下个调仓日的 close / 当前 close
|
||||
panel = panel.sort(["symbol", "date"])
|
||||
dates_col = panel["date"].to_list()
|
||||
close_col = panel["close"].to_list()
|
||||
symbol_col = panel["symbol"].to_list()
|
||||
|
||||
# 先找下个调仓日的 close
|
||||
# 建立 (date, symbol) → close 的快速查找
|
||||
price_map: dict[tuple, float] = {}
|
||||
for i in range(len(dates_col)):
|
||||
price_map[(str(dates_col[i]), symbol_col[i])] = close_col[i]
|
||||
|
||||
next_returns = [None] * len(panel)
|
||||
for i in range(len(panel)):
|
||||
d = dates_col[i]
|
||||
d_val = d if isinstance(d, _dt.date) else _dt.date.fromisoformat(str(d))
|
||||
if d not in rebalance_dates:
|
||||
continue
|
||||
next_d = next_rebalance_map.get(d)
|
||||
if next_d is None:
|
||||
continue
|
||||
next_d_str = str(next_d)[:10]
|
||||
d_str = str(d)[:10]
|
||||
sym = symbol_col[i]
|
||||
next_close = price_map.get((next_d_str, sym))
|
||||
cur_close = close_col[i]
|
||||
if next_close is not None and cur_close and cur_close > 0:
|
||||
next_returns[i] = (next_close / cur_close - 1.0)
|
||||
|
||||
panel = panel.with_columns(
|
||||
pl.Series("_next_return", next_returns, dtype=pl.Float64)
|
||||
)
|
||||
return panel
|
||||
|
||||
# ── 分组 ──
|
||||
|
||||
@staticmethod
|
||||
def _add_groups(panel: pl.DataFrame, factor_col: str, n_groups: int) -> pl.DataFrame:
|
||||
"""截面分位数分组。"""
|
||||
return panel.with_columns(
|
||||
pl.col(factor_col)
|
||||
.qcut(n_groups, labels=[f"Q{i+1}" for i in range(n_groups)])
|
||||
.over("date")
|
||||
.alias("_group")
|
||||
)
|
||||
|
||||
# ── 分组净值 ──
|
||||
|
||||
@staticmethod
|
||||
def _calc_group_nav(panel: pl.DataFrame, config: FactorConfig) -> list[dict]:
|
||||
"""计算分组净值曲线 — 只在调仓日更新净值。"""
|
||||
# 只保留有下期收益的行 (= 调仓日)
|
||||
group_ret = (
|
||||
panel.filter(pl.col("_next_return").is_not_null() & pl.col("_group").is_not_null())
|
||||
.group_by(["date", "_group"])
|
||||
.agg(pl.col("_next_return").mean().alias("group_return"))
|
||||
)
|
||||
|
||||
# pivot: date × group
|
||||
pivot = group_ret.pivot(index="date", columns="_group", values="group_return").sort("date")
|
||||
|
||||
if pivot.is_empty():
|
||||
return []
|
||||
|
||||
group_cols = [c for c in pivot.columns if c != "date"]
|
||||
|
||||
# 累乘净值曲线
|
||||
result: list[dict] = []
|
||||
nav_values: dict[str, float] = {c: 1.0 for c in group_cols}
|
||||
|
||||
for row in pivot.iter_rows(named=True):
|
||||
entry: dict = {"date": str(row["date"])[:10]}
|
||||
for c in group_cols:
|
||||
ret = float(row[c]) if row[c] is not None else 0.0
|
||||
nav_values[c] *= (1 + ret)
|
||||
entry[c] = round(nav_values[c], 4)
|
||||
result.append(entry)
|
||||
|
||||
return result
|
||||
|
||||
# ── 分组统计 ──
|
||||
|
||||
@staticmethod
|
||||
def _calc_group_stats(
|
||||
group_nav: list[dict], start: date, end: date,
|
||||
) -> list[dict]:
|
||||
if not group_nav:
|
||||
return []
|
||||
|
||||
group_cols = [k for k in group_nav[0] if k != "date"]
|
||||
n_days = max((end - start).days, 1)
|
||||
years = n_days / 365.25
|
||||
|
||||
stats = []
|
||||
for i, c in enumerate(sorted(group_cols)):
|
||||
values = [r[c] for r in group_nav if r.get(c) is not None]
|
||||
if not values:
|
||||
continue
|
||||
total_return = values[-1] - 1.0
|
||||
annual_return = (values[-1]) ** (1 / max(years, 0.01)) - 1 if values[-1] > 0 else 0.0
|
||||
|
||||
# 最大回撤
|
||||
peak = 1.0
|
||||
max_dd = 0.0
|
||||
for v in values:
|
||||
peak = max(peak, v)
|
||||
dd = (v - peak) / peak
|
||||
max_dd = min(max_dd, dd)
|
||||
|
||||
# 日收益序列
|
||||
daily_rets = []
|
||||
for j in range(1, len(values)):
|
||||
if values[j - 1] > 0:
|
||||
daily_rets.append(values[j] / values[j - 1] - 1)
|
||||
|
||||
# 夏普
|
||||
if daily_rets:
|
||||
arr = np.array(daily_rets)
|
||||
sharpe = float(np.mean(arr) / np.std(arr)) * np.sqrt(252) if np.std(arr) > 0 else 0.0
|
||||
win_rate = float(np.mean(arr > 0))
|
||||
else:
|
||||
sharpe = 0.0
|
||||
win_rate = 0.0
|
||||
|
||||
stats.append({
|
||||
"group": i + 1,
|
||||
"label": c,
|
||||
"total_return": round(total_return, 4),
|
||||
"annual_return": round(annual_return, 4),
|
||||
"max_drawdown": round(max_dd, 4),
|
||||
"sharpe": round(sharpe, 2),
|
||||
"win_rate": round(win_rate, 4),
|
||||
})
|
||||
|
||||
return stats
|
||||
|
||||
# ── 多空组合 ──
|
||||
|
||||
@staticmethod
|
||||
def _calc_long_short(
|
||||
group_nav: list[dict], config: FactorConfig,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""多空组合: 做多最高组 + 做空最低组。"""
|
||||
if not group_nav:
|
||||
return [], {}
|
||||
|
||||
group_cols = sorted([k for k in group_nav[0] if k != "date"])
|
||||
if len(group_cols) < 2:
|
||||
return [], {}
|
||||
|
||||
top_col = group_cols[-1] # Q5 (最高)
|
||||
bottom_col = group_cols[0] # Q1 (最低)
|
||||
|
||||
# 独立计算 top 和 bottom 的日收益,然后合成
|
||||
ls_value = 1.0
|
||||
prev_top = 1.0
|
||||
prev_bot = 1.0
|
||||
peak = 1.0
|
||||
max_dd = 0.0
|
||||
ls_nav: list[dict] = []
|
||||
|
||||
for row in group_nav:
|
||||
top_nav = float(row.get(top_col, 1.0)) if row.get(top_col) is not None else 1.0
|
||||
bot_nav = float(row.get(bottom_col, 1.0)) if row.get(bottom_col) is not None else 1.0
|
||||
|
||||
# top 组收益 (做多)
|
||||
top_ret = (top_nav / prev_top - 1) if prev_top > 0 else 0.0
|
||||
# bottom 组收益 (做空 = 取反)
|
||||
bot_ret = -(bot_nav / prev_bot - 1) if prev_bot > 0 else 0.0
|
||||
# 多空组合收益
|
||||
ls_ret = (top_ret + bot_ret) / 2 # 各分配 50% 资金
|
||||
ls_value *= (1 + ls_ret)
|
||||
|
||||
prev_top = top_nav
|
||||
prev_bot = bot_nav
|
||||
|
||||
peak = max(peak, ls_value)
|
||||
dd = (ls_value - peak) / peak if peak > 0 else 0.0
|
||||
max_dd = min(max_dd, dd)
|
||||
|
||||
ls_nav.append({"date": row["date"], "value": round(ls_value, 4)})
|
||||
|
||||
total_ret = ls_value - 1.0
|
||||
ls_stats = {
|
||||
"total_return": round(total_ret, 4),
|
||||
"max_drawdown": round(max_dd, 4),
|
||||
"top_group": top_col,
|
||||
"bottom_group": bottom_col,
|
||||
}
|
||||
|
||||
return ls_nav, ls_stats
|
||||
|
||||
@staticmethod
|
||||
def _config_to_dict(c: FactorConfig) -> dict:
|
||||
return {
|
||||
"factor_name": c.factor_name,
|
||||
"symbols": c.symbols,
|
||||
"start": str(c.start),
|
||||
"end": str(c.end),
|
||||
"n_groups": c.n_groups,
|
||||
"rebalance": c.rebalance,
|
||||
"weight": c.weight,
|
||||
"fees_pct": c.fees_pct,
|
||||
"slippage_bps": c.slippage_bps,
|
||||
}
|
||||
@@ -1,721 +0,0 @@
|
||||
"""策略回测服务 — 复用 StrategyDef 体系做全周期回测。
|
||||
|
||||
核心优化: 向量化 filter_fn,不逐日调用 StrategyEngine.run()。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from typing import Callable, Literal
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
|
||||
from app.backtest.engine import BacktestEngine, MatcherConfig, SimResult
|
||||
from app.strategy.engine import StrategyEngine, StrategyDef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BENCHMARK_SYMBOL = "000001.SH"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyBacktestConfig:
|
||||
strategy_id: str
|
||||
symbols: list[str] | None
|
||||
start: date
|
||||
end: date
|
||||
params: dict | None = None
|
||||
overrides: dict | None = None
|
||||
# matching 为向后兼容入口; 显式传 entry_fill/exit_fill 时以二者为准。
|
||||
matching: Literal["close_t", "open_t+1"] = "open_t+1"
|
||||
entry_fill: Literal["close_t", "open_t+1"] | None = None
|
||||
exit_fill: Literal["close_t", "open_t+1"] | None = None
|
||||
fees_pct: float = 0.0002
|
||||
slippage_bps: float = 5.0
|
||||
max_positions: int = 10
|
||||
max_exposure_pct: float = 1.0
|
||||
initial_capital: float = 1_000_000.0
|
||||
position_sizing: Literal["equal", "score_weight"] = "equal"
|
||||
mode: Literal["position", "full"] = "position"
|
||||
holding_days: int = 5
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.entry_fill is None:
|
||||
self.entry_fill = self.matching
|
||||
if self.exit_fill is None:
|
||||
self.exit_fill = self.matching
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyBacktestResult:
|
||||
run_id: str
|
||||
config: dict
|
||||
stats: dict = field(default_factory=dict)
|
||||
equity_curve: list[dict] = field(default_factory=list)
|
||||
drawdown_curve: list[dict] = field(default_factory=list)
|
||||
benchmark_curve: list[dict] = field(default_factory=list)
|
||||
trades: list[dict] = field(default_factory=list)
|
||||
per_symbol_stats: list[dict] = field(default_factory=list)
|
||||
strategy_info: dict = field(default_factory=dict)
|
||||
elapsed_ms: float = 0.0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class StrategyBacktestService:
|
||||
def __init__(
|
||||
self,
|
||||
engine: BacktestEngine,
|
||||
strategy_engine: StrategyEngine,
|
||||
) -> None:
|
||||
self.engine = engine
|
||||
self.strategy_engine = strategy_engine
|
||||
|
||||
def run(
|
||||
self,
|
||||
config: StrategyBacktestConfig,
|
||||
progress_cb: "Callable[[dict], None] | None" = None,
|
||||
cancel_event: "threading.Event | None" = None,
|
||||
) -> StrategyBacktestResult:
|
||||
t0 = time.perf_counter()
|
||||
run_id = uuid.uuid4().hex[:10]
|
||||
|
||||
def _err(msg: str) -> StrategyBacktestResult:
|
||||
return StrategyBacktestResult(
|
||||
run_id=run_id,
|
||||
config=self._config_to_dict(config),
|
||||
error=msg,
|
||||
elapsed_ms=(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
# 获取策略定义
|
||||
try:
|
||||
s = self.strategy_engine.get(config.strategy_id)
|
||||
except ValueError as e:
|
||||
return _err(str(e))
|
||||
|
||||
params = self._normalize_params(config.params or {}, s)
|
||||
overrides = config.overrides or {}
|
||||
basic_filter = self._effective_basic_filter(s, overrides)
|
||||
entry_signals = self._effective_signals(overrides, "entry_signals", s.entry_signals)
|
||||
exit_signals = self._effective_signals(overrides, "exit_signals", s.exit_signals)
|
||||
stop_loss = self._override_value(overrides, "stop_loss", s.stop_loss)
|
||||
take_profit = self._normalize_pct(
|
||||
self._override_value(overrides, "take_profit", getattr(s, "take_profit", None)),
|
||||
0.01,
|
||||
5.0,
|
||||
)
|
||||
trailing_stop = self._normalize_pct(
|
||||
self._override_value(overrides, "trailing_stop", getattr(s, "trailing_stop", None)),
|
||||
0.005,
|
||||
0.5,
|
||||
)
|
||||
trailing_take_profit_activate = self._normalize_pct(
|
||||
self._override_value(overrides, "trailing_take_profit_activate", getattr(s, "trailing_take_profit_activate", None)),
|
||||
0.01,
|
||||
2.0,
|
||||
)
|
||||
trailing_take_profit_drawdown = self._normalize_pct(
|
||||
self._override_value(overrides, "trailing_take_profit_drawdown", getattr(s, "trailing_take_profit_drawdown", None)),
|
||||
0.005,
|
||||
0.5,
|
||||
)
|
||||
if trailing_take_profit_activate is not None and trailing_take_profit_drawdown is not None:
|
||||
trailing_take_profit_drawdown = min(trailing_take_profit_drawdown, trailing_take_profit_activate)
|
||||
max_hold_days = self._override_value(overrides, "max_hold_days", s.max_hold_days)
|
||||
score_min, score_max = self._normalize_score_range(
|
||||
overrides.get("score_min"),
|
||||
overrides.get("score_max"),
|
||||
)
|
||||
|
||||
timing_ms: dict[str, float] = {}
|
||||
|
||||
# 加载面板 (含 warmup + 全量指标 + 信号)。warmup 只用于指标/形态计算, 不参与正式交易。
|
||||
warmup_days = max(120, int(max(s.lookback_days or 1, 1) * 1.5))
|
||||
load_start = config.start - timedelta(days=warmup_days)
|
||||
|
||||
# 全量模式: entries 只在正式区间触发, exits 需要 end 之后的尾部数据继续执行策略卖点。
|
||||
# 若策略有 max_hold_days, 用它决定尾部窗口;否则 holding_days 只作为兜底观察上限。
|
||||
full_horizon_days = int(max_hold_days or config.holding_days or 5)
|
||||
full_horizon_days = max(full_horizon_days, 1)
|
||||
load_end = config.end
|
||||
if config.mode == "full":
|
||||
fwd_buffer = full_horizon_days + 5 # 多取几天, 容错停牌缺口/open_t+1
|
||||
load_end = config.end + timedelta(days=fwd_buffer * 2) # 日历日放宽, 确保覆盖 N 个交易日
|
||||
|
||||
t_load = time.perf_counter()
|
||||
panel = self.engine.load_panel(config.symbols, load_start, load_end)
|
||||
timing_ms["load_panel"] = round((time.perf_counter() - t_load) * 1000, 1)
|
||||
if panel.is_empty():
|
||||
return _err("无数据,请检查日期范围或先运行盘后管道")
|
||||
|
||||
formal_range = self._date_range_mask(panel, config.start, config.end)
|
||||
if not formal_range.any():
|
||||
return _err("正式回测区间内无数据")
|
||||
|
||||
t_signal = time.perf_counter()
|
||||
|
||||
# basic_filter 只影响买入候选, 不能删除行情 panel, 否则持仓 mark / 卖出 / full forward return 都会失真。
|
||||
basic_mask = pl.Series("_basic", [True] * len(panel), dtype=pl.Boolean)
|
||||
if basic_filter and basic_filter.get("enabled", True):
|
||||
expr = StrategyEngine._basic_filter_expr(panel, basic_filter)
|
||||
if expr is not None:
|
||||
try:
|
||||
basic_mask = panel.select(expr.alias("_basic"))["_basic"].fill_null(False).cast(pl.Boolean)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("basic_filter mask failed: %s", e)
|
||||
return _err(f"基础过滤计算失败: {e}")
|
||||
|
||||
# 策略候选层用于评分归一化;entry_signals 只是买点层, 不参与 score universe。
|
||||
candidate_filter_mask = self._build_candidate_filter_mask(panel, s, params)
|
||||
candidate_mask = basic_mask & candidate_filter_mask
|
||||
panel = self._apply_score(panel, s, overrides, universe_mask=candidate_mask)
|
||||
|
||||
entry_mask = self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals)
|
||||
entry_mask = entry_mask & formal_range
|
||||
raw_exit_mask = self._build_signal_mask(panel, exit_signals, "_exit")
|
||||
exit_mask = raw_exit_mask & (self._date_range_mask(panel, config.start, load_end) if config.mode == "full" else formal_range)
|
||||
timing_ms["signals_score"] = round((time.perf_counter() - t_signal) * 1000, 1)
|
||||
|
||||
if not entry_mask.any():
|
||||
return _err("在指定区间内未产生买入信号")
|
||||
|
||||
# warmup 之后才交给撮合;full mode 保留 end 之后前瞻段用于 shift(-N)。
|
||||
sim_end = load_end if config.mode == "full" else config.end
|
||||
sim_range = self._date_range_mask(panel, config.start, sim_end)
|
||||
sim_panel = panel.filter(sim_range)
|
||||
sim_entry_mask = entry_mask.filter(sim_range)
|
||||
sim_exit_mask = exit_mask.filter(sim_range)
|
||||
if sim_panel.is_empty():
|
||||
return _err("正式回测区间内无数据")
|
||||
|
||||
t_sim = time.perf_counter()
|
||||
matcher_config = MatcherConfig(
|
||||
matching=config.matching,
|
||||
entry_fill=config.entry_fill,
|
||||
exit_fill=config.exit_fill,
|
||||
fees_pct=config.fees_pct,
|
||||
slippage_bps=config.slippage_bps,
|
||||
stop_loss_pct=stop_loss,
|
||||
take_profit_pct=take_profit,
|
||||
trailing_stop_pct=trailing_stop,
|
||||
trailing_take_profit_activate_pct=trailing_take_profit_activate,
|
||||
trailing_take_profit_drawdown_pct=trailing_take_profit_drawdown,
|
||||
max_hold_days=max_hold_days,
|
||||
max_positions=config.max_positions,
|
||||
max_exposure_pct=config.max_exposure_pct,
|
||||
score_min=score_min,
|
||||
score_max=score_max,
|
||||
initial_capital=config.initial_capital,
|
||||
position_sizing=config.position_sizing,
|
||||
)
|
||||
# 撮合 — full 为全候选独立执行;position 为账户级仓位模拟。
|
||||
if config.mode == "full":
|
||||
result = self.engine.simulate_independent_candidates(
|
||||
sim_panel,
|
||||
sim_entry_mask,
|
||||
sim_exit_mask,
|
||||
matcher_config,
|
||||
progress_cb,
|
||||
cancel_event,
|
||||
)
|
||||
else:
|
||||
result = self.engine.simulate_portfolio(sim_panel, sim_entry_mask, sim_exit_mask, matcher_config, progress_cb, cancel_event)
|
||||
timing_ms["simulate"] = round((time.perf_counter() - t_sim) * 1000, 1)
|
||||
|
||||
# 检查是否被取消
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
return StrategyBacktestResult(
|
||||
run_id=run_id,
|
||||
config=self._config_to_dict(config),
|
||||
error="cancelled",
|
||||
elapsed_ms=round((time.perf_counter() - t0) * 1000, 1),
|
||||
)
|
||||
|
||||
if result.stats.get("error"):
|
||||
return _err(result.stats["error"])
|
||||
|
||||
timing_ms["total"] = round((time.perf_counter() - t0) * 1000, 1)
|
||||
result.stats["timing_ms"] = timing_ms
|
||||
result.stats["panel_rows"] = int(sim_panel.height)
|
||||
|
||||
benchmark_curve = self._build_benchmark_curve(config.start, config.end)
|
||||
|
||||
# 构建策略信息
|
||||
strategy_info = {
|
||||
"id": s.meta.get("id", config.strategy_id),
|
||||
"name": s.meta.get("name", config.strategy_id),
|
||||
"description": s.meta.get("description", ""),
|
||||
"entry_signals": entry_signals,
|
||||
"exit_signals": exit_signals,
|
||||
"stop_loss": stop_loss,
|
||||
"take_profit": take_profit,
|
||||
"trailing_stop": trailing_stop,
|
||||
"trailing_take_profit_activate": trailing_take_profit_activate,
|
||||
"trailing_take_profit_drawdown": trailing_take_profit_drawdown,
|
||||
"max_hold_days": max_hold_days,
|
||||
"full_horizon_days": full_horizon_days,
|
||||
"score_min": score_min,
|
||||
"score_max": score_max,
|
||||
"source": s.source,
|
||||
}
|
||||
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
|
||||
return StrategyBacktestResult(
|
||||
run_id=run_id,
|
||||
config=self._config_to_dict(config),
|
||||
stats=result.stats,
|
||||
equity_curve=result.equity_curve,
|
||||
drawdown_curve=result.drawdown_curve,
|
||||
benchmark_curve=benchmark_curve,
|
||||
trades=[self._trade_to_dict(t) for t in result.trades],
|
||||
per_symbol_stats=result.per_symbol_stats,
|
||||
strategy_info=strategy_info,
|
||||
elapsed_ms=round(elapsed, 1),
|
||||
)
|
||||
|
||||
# ── 全量模拟 (选股能力统计, 不建组合不算净值) ──
|
||||
|
||||
def _run_full_simulation(
|
||||
self,
|
||||
panel: pl.DataFrame,
|
||||
entry_mask: pl.Series,
|
||||
holding_days: int,
|
||||
) -> SimResult:
|
||||
"""对 entry_mask 命中的全部候选, 算持有 N 天后的前瞻收益统计。
|
||||
|
||||
不受 max_positions/资金约束, 反映策略选股能力本身。
|
||||
equity_curve 复用为"累计日均超额收益曲线"(基准归零)。
|
||||
"""
|
||||
n = holding_days if holding_days and holding_days > 0 else 5
|
||||
|
||||
df = panel.with_columns([
|
||||
entry_mask.cast(pl.Boolean).alias("_is_candidate"),
|
||||
(pl.col("close").shift(-n).over("symbol") / pl.col("close") - 1).alias("_fwd_return"),
|
||||
]).filter(
|
||||
pl.col("_is_candidate")
|
||||
& pl.col("_fwd_return").is_not_null()
|
||||
& pl.col("_fwd_return").is_not_nan()
|
||||
)
|
||||
|
||||
if df.is_empty():
|
||||
return self.engine._empty_result()
|
||||
|
||||
fwd = df["_fwd_return"].to_numpy()
|
||||
wins = fwd[fwd > 0]
|
||||
losses = fwd[fwd <= 0]
|
||||
avg_win = float(wins.mean()) if wins.size else 0.0
|
||||
avg_loss = abs(float(losses.mean())) if losses.size else 0.0
|
||||
|
||||
# 按日聚合: 当日候选的平均前瞻收益
|
||||
daily = (
|
||||
df.group_by("date").agg(
|
||||
pl.col("_fwd_return").mean().alias("avg_ret"),
|
||||
pl.col("_fwd_return").count().alias("n_cand"),
|
||||
).sort("date")
|
||||
)
|
||||
|
||||
# 累计超额曲线: 每日复利平均收益 (基准归零, 故 equity 即累计策略收益)
|
||||
equity_curve: list[dict] = []
|
||||
equity = 1.0
|
||||
peak = 1.0
|
||||
drawdown_curve: list[dict] = []
|
||||
for row in daily.iter_rows(named=True):
|
||||
ret = float(row["avg_ret"] or 0.0)
|
||||
equity *= (1 + ret)
|
||||
peak = max(peak, equity)
|
||||
dd = (equity - peak) / peak if peak > 0 else 0.0
|
||||
d_str = str(row["date"])[:10]
|
||||
equity_curve.append({
|
||||
"date": d_str,
|
||||
"value": round(equity, 4),
|
||||
"positions": int(row["n_cand"]),
|
||||
})
|
||||
drawdown_curve.append({"date": d_str, "value": round(dd, 4)})
|
||||
|
||||
# 同期上证收益 (用 benchmark close 算)
|
||||
benchmark_curve = self._build_benchmark_curve(
|
||||
daily["date"].min(), daily["date"].max()
|
||||
)
|
||||
benchmark_return = 0.0
|
||||
if benchmark_curve:
|
||||
closes = [b["close"] for b in benchmark_curve if b.get("close")]
|
||||
if len(closes) >= 2 and closes[0] > 0:
|
||||
benchmark_return = closes[-1] / closes[0] - 1
|
||||
|
||||
total_return = equity - 1.0
|
||||
max_dd = min((d["value"] for d in drawdown_curve), default=0.0)
|
||||
|
||||
# 日收益序列算 Sharpe (年化)
|
||||
daily_rets = daily["avg_ret"].to_numpy()
|
||||
sharpe = (
|
||||
float(daily_rets.mean() / daily_rets.std() * np.sqrt(252))
|
||||
if daily_rets.size > 1 and daily_rets.std() > 0 else 0.0
|
||||
)
|
||||
|
||||
# 收益分布直方图: 按 [-20%, +20%] 分 21 档 (每档 2%), 超出归入首尾档
|
||||
lo, hi, nbins = -0.20, 0.20, 20
|
||||
clipped = np.clip(fwd, lo, hi)
|
||||
counts, edges = np.histogram(clipped, bins=nbins, range=(lo, hi))
|
||||
dist = [
|
||||
{
|
||||
"range": f"{(edges[i]*100):+.0f}~{(edges[i+1]*100):+.0f}%",
|
||||
"count": int(counts[i]),
|
||||
"ratio": round(float(counts[i] / fwd.size), 4) if fwd.size else 0.0,
|
||||
}
|
||||
for i in range(nbins)
|
||||
]
|
||||
|
||||
stats = {
|
||||
"mode": "full",
|
||||
"n_candidates": int(fwd.size),
|
||||
"n_days": int(daily.height),
|
||||
"avg_daily_candidates": round(float(daily["n_cand"].mean()), 1),
|
||||
"avg_return": round(float(fwd.mean()), 4),
|
||||
"median_return": round(float(np.median(fwd)), 4),
|
||||
"win_rate": round(float(wins.size / fwd.size), 4) if fwd.size else 0.0,
|
||||
"profit_factor": round(avg_win / avg_loss, 2) if avg_loss > 0 else None,
|
||||
"best": round(float(fwd.max()), 4),
|
||||
"worst": round(float(fwd.min()), 4),
|
||||
"total_return": round(float(total_return), 4),
|
||||
"max_drawdown": round(float(max_dd), 4),
|
||||
"sharpe": round(sharpe, 2),
|
||||
"benchmark_return": round(float(benchmark_return), 4),
|
||||
"excess": round(float(total_return - benchmark_return), 4),
|
||||
"return_distribution": dist,
|
||||
}
|
||||
|
||||
return SimResult(
|
||||
equity_curve=equity_curve,
|
||||
drawdown_curve=drawdown_curve,
|
||||
trades=[],
|
||||
per_symbol_stats=[],
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
# ── 向量化信号生成 ──
|
||||
|
||||
@staticmethod
|
||||
def _date_range_mask(panel: pl.DataFrame, start: date, end: date) -> pl.Series:
|
||||
return panel.select(
|
||||
((pl.col("date") >= start) & (pl.col("date") <= end)).alias("_range")
|
||||
)["_range"].fill_null(False).cast(pl.Boolean)
|
||||
|
||||
def _build_candidate_filter_mask(
|
||||
self,
|
||||
panel: pl.DataFrame,
|
||||
s: StrategyDef,
|
||||
params: dict,
|
||||
) -> pl.Series:
|
||||
"""生成策略候选层 mask。filter_history/filter 决定候选池, 不包含 entry_signals。"""
|
||||
false_mask = pl.Series("_candidate_filter", [False] * len(panel), dtype=pl.Boolean)
|
||||
true_mask = pl.Series("_candidate_filter", [True] * len(panel), dtype=pl.Boolean)
|
||||
|
||||
history_failed = False
|
||||
# 优先: filter_history_fn 策略 (涨停/反包等多日形态, 与选股路径共用同一逻辑)
|
||||
if s.filter_history_fn:
|
||||
try:
|
||||
hit_df = s.filter_history_fn(panel, params)
|
||||
if hit_df is None or hit_df.is_empty():
|
||||
return false_mask
|
||||
# 命中行 (symbol,date) → 转 panel 等长布尔 mask
|
||||
hits = hit_df.select(["symbol", "date"]).unique()
|
||||
marked = (
|
||||
panel.select(["symbol", "date"])
|
||||
.join(
|
||||
hits.with_columns(pl.lit(True).alias("_hit")),
|
||||
on=["symbol", "date"],
|
||||
how="left",
|
||||
)
|
||||
)
|
||||
return marked["_hit"].fill_null(False).cast(pl.Boolean)
|
||||
except Exception as e:
|
||||
history_failed = True
|
||||
logger.warning("strategy filter_history_fn failed: %s", e)
|
||||
# 失败则回退到 filter_fn (若存在)
|
||||
|
||||
# 策略 filter_fn: 候选层 (filter_history 不可用或失败时)
|
||||
if s.filter_fn:
|
||||
try:
|
||||
expr = s.filter_fn(panel, params)
|
||||
if expr is not None:
|
||||
result = panel.select(expr.alias("_candidate_filter"))
|
||||
if not result.is_empty():
|
||||
return result["_candidate_filter"].fill_null(False).cast(pl.Boolean)
|
||||
except Exception as e:
|
||||
logger.warning("strategy filter_fn failed: %s", e)
|
||||
return false_mask
|
||||
|
||||
if history_failed:
|
||||
return false_mask
|
||||
|
||||
# 没有策略候选层时, 由 entry_signals 直接决定买点。
|
||||
return true_mask
|
||||
|
||||
def _build_entry_mask_from_candidate(
|
||||
self,
|
||||
panel: pl.DataFrame,
|
||||
candidate_mask: pl.Series,
|
||||
s: StrategyDef,
|
||||
entry_signals: list[str],
|
||||
) -> pl.Series:
|
||||
"""向量化生成买入掩码:候选层 AND 买点层;无买点时只用策略候选层。"""
|
||||
signal_mask = self._build_signal_mask(panel, entry_signals, "_entry_signal")
|
||||
if entry_signals:
|
||||
return candidate_mask & signal_mask
|
||||
if s.filter_history_fn or s.filter_fn:
|
||||
return candidate_mask
|
||||
return pl.Series("_entry", [False] * len(panel), dtype=pl.Boolean)
|
||||
|
||||
def _build_entry_mask(
|
||||
self,
|
||||
panel: pl.DataFrame,
|
||||
s: StrategyDef,
|
||||
params: dict,
|
||||
entry_signals: list[str],
|
||||
) -> pl.Series:
|
||||
"""兼容旧调用: 候选层 AND 买点层。"""
|
||||
candidate_mask = self._build_candidate_filter_mask(panel, s, params)
|
||||
return self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals)
|
||||
|
||||
@staticmethod
|
||||
def _build_signal_mask(panel: pl.DataFrame, signals: list[str], name: str) -> pl.Series:
|
||||
"""向量化合并信号列,多个信号 OR。支持内置 signal_ 与自定义 csg_ 前缀。"""
|
||||
masks: list[pl.Series] = []
|
||||
for sig in signals:
|
||||
# csg_ (自定义信号) 直接用;否则按 signal_ 解析
|
||||
col = sig if (sig.startswith("signal_") or sig.startswith("csg_")) else f"signal_{sig}"
|
||||
if col in panel.columns:
|
||||
masks.append(panel[col].fill_null(False).cast(pl.Boolean))
|
||||
|
||||
if not masks:
|
||||
return pl.Series(name, [False] * len(panel), dtype=pl.Boolean)
|
||||
|
||||
combined = masks[0]
|
||||
for m in masks[1:]:
|
||||
combined = combined | m
|
||||
return combined
|
||||
|
||||
def _build_benchmark_curve(self, start: date, end: date) -> list[dict]:
|
||||
try:
|
||||
df = self.engine.repo.get_index_daily(BENCHMARK_SYMBOL, start, end, columns=["date", "close"])
|
||||
except Exception as e:
|
||||
logger.warning("load benchmark %s failed: %s", BENCHMARK_SYMBOL, e)
|
||||
return []
|
||||
|
||||
if df.is_empty() or "close" not in df.columns:
|
||||
return []
|
||||
|
||||
df = df.filter(pl.col("close").is_not_null() & (pl.col("close") > 0)).sort("date")
|
||||
if df.is_empty():
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"date": str(row["date"])[:10],
|
||||
"value": round(float(row["close"]), 4),
|
||||
"close": round(float(row["close"]), 4),
|
||||
"name": "上证指数",
|
||||
"symbol": BENCHMARK_SYMBOL,
|
||||
}
|
||||
for row in df.iter_rows(named=True)
|
||||
if row["close"] is not None
|
||||
]
|
||||
|
||||
# ── 工具 ──
|
||||
|
||||
@staticmethod
|
||||
def _effective_basic_filter(s: StrategyDef, overrides: dict) -> dict:
|
||||
basic_filter = dict(s.basic_filter or {})
|
||||
override_filter = overrides.get("basic_filter")
|
||||
if isinstance(override_filter, dict):
|
||||
basic_filter.update(override_filter)
|
||||
return basic_filter
|
||||
|
||||
@staticmethod
|
||||
def _effective_signals(overrides: dict, key: str, default: list[str]) -> list[str]:
|
||||
value = overrides.get(key)
|
||||
if isinstance(value, list):
|
||||
return [str(v) for v in value if v]
|
||||
return list(default or [])
|
||||
|
||||
@staticmethod
|
||||
def _override_value(overrides: dict, key: str, default):
|
||||
if key in overrides:
|
||||
return overrides.get(key)
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _normalize_pct(value, min_value: float, max_value: float) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
pct = abs(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return min(max(pct, min_value), max_value)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_score_range(min_value, max_value) -> tuple[float | None, float | None]:
|
||||
def _bound(value) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
score = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not np.isfinite(score):
|
||||
return None
|
||||
return min(max(score, 0.0), 100.0)
|
||||
|
||||
score_min = _bound(min_value)
|
||||
score_max = _bound(max_value)
|
||||
if score_min is not None and score_max is not None and score_min > score_max:
|
||||
score_min, score_max = score_max, score_min
|
||||
return score_min, score_max
|
||||
|
||||
@staticmethod
|
||||
def _normalize_params(params: dict, s: StrategyDef) -> dict:
|
||||
normalized = dict(params)
|
||||
for param in s.meta.get("params", []):
|
||||
pid = param.get("id")
|
||||
if not pid:
|
||||
continue
|
||||
value = normalized.get(pid, param.get("default"))
|
||||
p_type = param.get("type")
|
||||
if p_type in {"float", "int"}:
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
num = float(param.get("default", 0) or 0)
|
||||
if param.get("min") is not None:
|
||||
num = max(num, float(param["min"]))
|
||||
if param.get("max") is not None:
|
||||
num = min(num, float(param["max"]))
|
||||
normalized[pid] = int(num) if p_type == "int" else num
|
||||
elif p_type == "select" and param.get("options"):
|
||||
normalized[pid] = value if value in param["options"] else param.get("default")
|
||||
elif p_type == "bool":
|
||||
if isinstance(value, bool):
|
||||
normalized[pid] = value
|
||||
elif isinstance(value, str):
|
||||
normalized[pid] = value.lower() == "true"
|
||||
else:
|
||||
normalized[pid] = bool(param.get("default", False))
|
||||
else:
|
||||
normalized[pid] = value
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _trade_to_dict(t) -> dict:
|
||||
return {
|
||||
"symbol": t.symbol,
|
||||
"name": t.name,
|
||||
"entry_date": str(t.entry_date) if isinstance(t.entry_date, date) else str(t.entry_date),
|
||||
"exit_date": str(t.exit_date) if isinstance(t.exit_date, date) else str(t.exit_date),
|
||||
"entry_price": t.entry_price,
|
||||
"exit_price": t.exit_price,
|
||||
"pnl_pct": t.pnl_pct,
|
||||
"duration": t.duration,
|
||||
"exit_reason": t.exit_reason,
|
||||
"shares": t.shares,
|
||||
"lots": t.lots,
|
||||
"position_pct": t.position_pct,
|
||||
"entry_value": t.entry_value,
|
||||
"exit_value": t.exit_value,
|
||||
"pnl_amount": t.pnl_amount,
|
||||
"entry_score": getattr(t, "entry_score", None),
|
||||
"entry_signal_date": str(t.entry_signal_date) if getattr(t, "entry_signal_date", None) is not None else None,
|
||||
"exit_signal_date": str(t.exit_signal_date) if getattr(t, "exit_signal_date", None) is not None else None,
|
||||
"blocked_exit_days": getattr(t, "blocked_exit_days", 0),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _config_to_dict(c: StrategyBacktestConfig) -> dict:
|
||||
score_min, score_max = StrategyBacktestService._normalize_score_range(
|
||||
(c.overrides or {}).get("score_min"),
|
||||
(c.overrides or {}).get("score_max"),
|
||||
)
|
||||
return {
|
||||
"strategy_id": c.strategy_id,
|
||||
"symbols": c.symbols,
|
||||
"start": str(c.start),
|
||||
"end": str(c.end),
|
||||
"params": c.params,
|
||||
"overrides": c.overrides,
|
||||
"score_min": score_min,
|
||||
"score_max": score_max,
|
||||
"matching": c.matching,
|
||||
"entry_fill": c.entry_fill,
|
||||
"exit_fill": c.exit_fill,
|
||||
"fees_pct": c.fees_pct,
|
||||
"slippage_bps": c.slippage_bps,
|
||||
"max_positions": c.max_positions,
|
||||
"max_exposure_pct": c.max_exposure_pct,
|
||||
"initial_capital": c.initial_capital,
|
||||
"position_sizing": c.position_sizing,
|
||||
"mode": c.mode,
|
||||
"holding_days": c.holding_days,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _apply_score(
|
||||
panel: pl.DataFrame,
|
||||
s: StrategyDef,
|
||||
overrides: dict | None,
|
||||
universe_mask: pl.Series | None = None,
|
||||
) -> pl.DataFrame:
|
||||
scoring = s.meta.get("scoring", {})
|
||||
scoring_overrides = (overrides or {}).get("scoring")
|
||||
if scoring_overrides:
|
||||
scoring = {**scoring, **scoring_overrides}
|
||||
|
||||
work = panel
|
||||
has_universe = universe_mask is not None and len(universe_mask) == len(panel)
|
||||
if has_universe:
|
||||
work = work.with_columns(universe_mask.rename("_score_universe"))
|
||||
|
||||
def _value_in_universe(col: str) -> pl.Expr:
|
||||
if has_universe:
|
||||
return pl.when(pl.col("_score_universe")).then(pl.col(col)).otherwise(None)
|
||||
return pl.col(col)
|
||||
|
||||
def _finish(df: pl.DataFrame) -> pl.DataFrame:
|
||||
return df.drop("_score_universe") if "_score_universe" in df.columns else df
|
||||
|
||||
if scoring:
|
||||
total_weight = sum(scoring.values())
|
||||
if total_weight > 0:
|
||||
score_parts: list[pl.Expr] = []
|
||||
for col, weight in scoring.items():
|
||||
if col not in work.columns:
|
||||
continue
|
||||
w = weight / total_weight
|
||||
value = _value_in_universe(col)
|
||||
col_min = value.min().over("date")
|
||||
col_max = value.max().over("date")
|
||||
col_range = col_max - col_min
|
||||
normalized = pl.when(col_range > 0).then(
|
||||
(pl.col(col) - col_min) / col_range
|
||||
).otherwise(pl.lit(0.5))
|
||||
if has_universe:
|
||||
normalized = pl.when(pl.col("_score_universe")).then(normalized).otherwise(0.0)
|
||||
score_parts.append(normalized * w)
|
||||
if score_parts:
|
||||
score_expr = score_parts[0]
|
||||
for part in score_parts[1:]:
|
||||
score_expr = score_expr + part
|
||||
return _finish(work.with_columns((score_expr * 100).fill_null(0).alias("score")))
|
||||
|
||||
order_by = s.meta.get("order_by")
|
||||
if order_by and order_by != "score" and order_by in work.columns:
|
||||
direction = 1 if s.meta.get("descending", True) else -1
|
||||
score_expr = pl.col(order_by).fill_null(0) * direction
|
||||
if has_universe:
|
||||
score_expr = pl.when(pl.col("_score_universe")).then(score_expr).otherwise(0.0)
|
||||
return _finish(work.with_columns(score_expr.alias("score")))
|
||||
return _finish(work.with_columns(pl.lit(0.0).alias("score")))
|
||||
@@ -93,8 +93,6 @@ class Settings(BaseSettings):
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 3018
|
||||
log_level: str = "INFO"
|
||||
backtest_range_guard: bool = False
|
||||
|
||||
# Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env)
|
||||
# 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。
|
||||
auth_password: str = ""
|
||||
|
||||
@@ -44,7 +44,7 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
||||
"""解析标的池 — 以 CN_Equity_A (沪深京A股 ~5522只) 为主。
|
||||
|
||||
有 batch 能力 → 直接拉 CN_Equity_A universe
|
||||
其他用户 → 用 instruments parquet + watchlist 兜底
|
||||
其他用户 → 用 instruments parquet 兜底
|
||||
"""
|
||||
if capset.has(Cap.KLINE_DAILY_BATCH):
|
||||
try:
|
||||
@@ -54,9 +54,8 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("CN_Equity_A pool unavailable, fallback: %s", e)
|
||||
|
||||
# Free 用户兜底: instruments parquet + watchlist + demo
|
||||
# Free 用户兜底: instruments parquet + demo
|
||||
base: set[str] = set(DEMO_SYMBOLS)
|
||||
base.update(get_pool("watchlist"))
|
||||
d = Path(settings.data_dir)
|
||||
inst_path = d / "instruments" / "instruments.parquet"
|
||||
if inst_path.exists():
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import __version__
|
||||
from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, kline, market_recap, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
|
||||
from app.api import analysis, auth as auth_api, data, ext_data, financials, indices, kline, market_recap, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy
|
||||
from app.api.routes import router as core_router
|
||||
from app.config import settings
|
||||
from app.jobs import daily_pipeline
|
||||
@@ -182,9 +182,7 @@ async def auth_middleware(request: Request, call_next):
|
||||
app.include_router(core_router)
|
||||
app.include_router(auth_api.router)
|
||||
app.include_router(kline.router)
|
||||
app.include_router(watchlist.router)
|
||||
app.include_router(screener.router)
|
||||
app.include_router(backtest.router)
|
||||
app.include_router(indices.router)
|
||||
app.include_router(overview.router)
|
||||
app.include_router(analysis.router)
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
"""回测服务(§6.7)。
|
||||
|
||||
包 vectorbt — 全项目唯一一处出现 pandas。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import polars as pl
|
||||
|
||||
from app.config import settings
|
||||
from app.tickflow.repository import KlineRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# vectorbt 是 optional extras(见 pyproject.toml).未装时只有 backtest 不可用,其他功能正常.
|
||||
_vbt = None
|
||||
_vbt_unavailable_reason: str | None = None
|
||||
|
||||
|
||||
class VectorbtUnavailable(RuntimeError):
|
||||
"""vectorbt 未安装 — 提示用户 `uv sync --extra backtest`."""
|
||||
|
||||
|
||||
def _get_vbt():
|
||||
global _vbt, _vbt_unavailable_reason
|
||||
if _vbt is not None:
|
||||
return _vbt
|
||||
if _vbt_unavailable_reason is not None:
|
||||
raise VectorbtUnavailable(_vbt_unavailable_reason)
|
||||
try:
|
||||
import vectorbt as vbt
|
||||
_vbt = vbt
|
||||
return _vbt
|
||||
except ImportError as e:
|
||||
_vbt_unavailable_reason = (
|
||||
"vectorbt 未安装 — 它是回测的可选依赖.macOS Intel 用户先 `brew install cmake` "
|
||||
"然后 `uv sync --extra backtest`"
|
||||
)
|
||||
logger.warning("vectorbt unavailable: %s", e)
|
||||
raise VectorbtUnavailable(_vbt_unavailable_reason) from e
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""供 API 层快速检测."""
|
||||
try:
|
||||
_get_vbt()
|
||||
return True
|
||||
except VectorbtUnavailable:
|
||||
return False
|
||||
|
||||
|
||||
SignalKind = Literal[
|
||||
"macd_golden", "macd_dead",
|
||||
"ma_golden_5_20", "ma_dead_5_20",
|
||||
"ma_golden_20_60",
|
||||
"ma20_breakout", "ma20_breakdown",
|
||||
"n_day_high", "n_day_low",
|
||||
"boll_breakout_upper", "boll_breakdown_lower",
|
||||
"volume_surge",
|
||||
"rsi_oversold", "rsi_overbought",
|
||||
"stop_loss", "trailing_stop", "max_hold",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestConfig:
|
||||
symbols: list[str]
|
||||
start: date
|
||||
end: date
|
||||
# 买入信号(任一触发即买)
|
||||
entries: list[str] = field(default_factory=list)
|
||||
# 卖出信号(任一触发即卖)
|
||||
exits: list[str] = field(default_factory=list)
|
||||
# 其他参数
|
||||
stop_loss_pct: float | None = None # 例 -0.05 = -5%
|
||||
max_hold_days: int | None = None
|
||||
fees_pct: float = 0.0002 # 万二佣金
|
||||
slippage_bps: float = 5 # 5 bps
|
||||
# 撮合
|
||||
matching: Literal["close_t", "open_t+1"] = "close_t"
|
||||
rsi_oversold_threshold: float = 30
|
||||
rsi_overbought_threshold: float = 70
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestResult:
|
||||
run_id: str
|
||||
config: dict
|
||||
stats: dict
|
||||
equity_curve: list[dict] # [{date, value}]
|
||||
trades: list[dict] # [{symbol, entry_date, exit_date, pnl_pct, ...}]
|
||||
per_symbol_stats: list[dict] # 每只股票的统计
|
||||
|
||||
|
||||
# enriched 表里的信号列名映射
|
||||
_SIGNAL_COLS: dict[SignalKind, str] = {
|
||||
"macd_golden": "signal_macd_golden",
|
||||
"macd_dead": "signal_macd_dead",
|
||||
"ma_golden_5_20": "signal_ma_golden_5_20",
|
||||
"ma_dead_5_20": "signal_ma_dead_5_20",
|
||||
"ma_golden_20_60": "signal_ma_golden_20_60",
|
||||
"ma20_breakout": "signal_ma20_breakout",
|
||||
"ma20_breakdown": "signal_ma20_breakdown",
|
||||
"n_day_high": "signal_n_day_high",
|
||||
"n_day_low": "signal_n_day_low",
|
||||
"boll_breakout_upper": "signal_boll_breakout_upper",
|
||||
"boll_breakdown_lower": "signal_boll_breakdown_lower",
|
||||
"volume_surge": "signal_volume_surge",
|
||||
}
|
||||
|
||||
|
||||
class BacktestService:
|
||||
def __init__(self, repo: KlineRepository) -> None:
|
||||
self.repo = repo
|
||||
|
||||
def _load_panel(
|
||||
self,
|
||||
symbols: list[str],
|
||||
start: date,
|
||||
end: date,
|
||||
) -> pd.DataFrame:
|
||||
"""加载 [date × symbol] 价格面板 — Polars scan_parquet + 即时计算指标。
|
||||
|
||||
**全项目唯一从 Polars 转 pandas 的边界**(§7.4 / ADR-19)。
|
||||
"""
|
||||
try:
|
||||
enriched_glob = str(self.repo.store.data_dir / "kline_daily_enriched" / "**" / "*.parquet")
|
||||
df = (
|
||||
pl.scan_parquet(enriched_glob)
|
||||
.filter(
|
||||
(pl.col("symbol").is_in(symbols))
|
||||
& (pl.col("date") >= start)
|
||||
& (pl.col("date") <= end)
|
||||
)
|
||||
.sort(["date", "symbol"])
|
||||
.collect()
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("backtest load failed: %s", e)
|
||||
return pd.DataFrame()
|
||||
|
||||
if df.is_empty():
|
||||
return pd.DataFrame()
|
||||
|
||||
# 即时计算指标 + 信号
|
||||
from app.indicators.pipeline import compute_all
|
||||
df = compute_all(df)
|
||||
|
||||
# 选择需要的列
|
||||
needed_cols = [
|
||||
"date", "symbol", "open", "high", "low", "close", "volume",
|
||||
"rsi_14", "signal_macd_golden", "signal_macd_dead",
|
||||
"signal_ma_golden_5_20", "signal_ma_dead_5_20",
|
||||
"signal_ma_golden_20_60",
|
||||
"signal_ma20_breakout", "signal_ma20_breakdown",
|
||||
"signal_n_day_high", "signal_n_day_low",
|
||||
"signal_boll_breakout_upper", "signal_boll_breakdown_lower",
|
||||
"signal_volume_surge",
|
||||
]
|
||||
existing = [c for c in needed_cols if c in df.columns]
|
||||
df = df.select(existing)
|
||||
|
||||
# to_pandas 边界
|
||||
return df.to_pandas(use_pyarrow_extension_array=False)
|
||||
|
||||
def _build_signal_matrix(
|
||||
self,
|
||||
panel: pd.DataFrame,
|
||||
kinds: list[str],
|
||||
config: BacktestConfig,
|
||||
) -> pd.DataFrame:
|
||||
"""从面板构造 [date × symbol] 的布尔信号矩阵。"""
|
||||
if not kinds or panel.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# pivot 成 [date × symbol] 形式
|
||||
result = None
|
||||
for kind in kinds:
|
||||
mat = None
|
||||
if kind in _SIGNAL_COLS:
|
||||
col = _SIGNAL_COLS[kind]
|
||||
mat = panel.pivot(index="date", columns="symbol", values=col).fillna(False).astype(bool)
|
||||
elif kind == "rsi_oversold":
|
||||
mat = (panel.pivot(index="date", columns="symbol", values="rsi_14")
|
||||
< config.rsi_oversold_threshold)
|
||||
elif kind == "rsi_overbought":
|
||||
mat = (panel.pivot(index="date", columns="symbol", values="rsi_14")
|
||||
> config.rsi_overbought_threshold)
|
||||
# stop_loss / trailing / max_hold 通过 vectorbt 参数处理,不参与信号矩阵
|
||||
|
||||
if mat is not None:
|
||||
result = mat if result is None else (result | mat)
|
||||
return result if result is not None else pd.DataFrame()
|
||||
|
||||
def run(self, config: BacktestConfig) -> BacktestResult:
|
||||
vbt = _get_vbt()
|
||||
run_id = uuid.uuid4().hex[:10]
|
||||
|
||||
panel = self._load_panel(config.symbols, config.start, config.end)
|
||||
if panel.empty:
|
||||
return BacktestResult(
|
||||
run_id=run_id,
|
||||
config=_config_to_dict(config),
|
||||
stats={"error": "no data"},
|
||||
equity_curve=[],
|
||||
trades=[],
|
||||
per_symbol_stats=[],
|
||||
)
|
||||
|
||||
# 价格面板
|
||||
close = panel.pivot(index="date", columns="symbol", values="close")
|
||||
|
||||
# 信号矩阵
|
||||
entries = self._build_signal_matrix(panel, config.entries, config)
|
||||
exits = self._build_signal_matrix(panel, config.exits, config)
|
||||
|
||||
# 对齐 index/columns
|
||||
if not entries.empty:
|
||||
entries = entries.reindex_like(close).fillna(False).astype(bool)
|
||||
else:
|
||||
entries = pd.DataFrame(False, index=close.index, columns=close.columns)
|
||||
if not exits.empty:
|
||||
exits = exits.reindex_like(close).fillna(False).astype(bool)
|
||||
else:
|
||||
exits = pd.DataFrame(False, index=close.index, columns=close.columns)
|
||||
|
||||
if not entries.any().any():
|
||||
return BacktestResult(
|
||||
run_id=run_id,
|
||||
config=_config_to_dict(config),
|
||||
stats={"error": "no buy signals"},
|
||||
equity_curve=[],
|
||||
trades=[],
|
||||
per_symbol_stats=[],
|
||||
)
|
||||
|
||||
# T+1 适配:vectorbt 默认信号当根 K 撮合
|
||||
# close_t 撮合:维持默认
|
||||
# open_t+1 撮合:shift 信号 1 根 + 用 open 作为价
|
||||
if config.matching == "open_t+1":
|
||||
entries = entries.shift(1).fillna(False).astype(bool)
|
||||
exits = exits.shift(1).fillna(False).astype(bool)
|
||||
price = panel.pivot(index="date", columns="symbol", values="open")
|
||||
else:
|
||||
price = close
|
||||
|
||||
# 跑回测
|
||||
try:
|
||||
pf_kwargs = dict(
|
||||
close=close,
|
||||
entries=entries,
|
||||
exits=exits,
|
||||
price=price,
|
||||
fees=config.fees_pct,
|
||||
slippage=config.slippage_bps / 10000.0,
|
||||
freq="1D",
|
||||
)
|
||||
if config.stop_loss_pct is not None:
|
||||
pf_kwargs["sl_stop"] = abs(config.stop_loss_pct)
|
||||
if config.max_hold_days is not None:
|
||||
# vectorbt 没有内置 max-hold;用时间退出近似:
|
||||
# 在 max_hold_days 后强制 exit
|
||||
exits_idx = entries.copy()
|
||||
for col in entries.columns:
|
||||
entry_rows = np.where(entries[col].values)[0]
|
||||
for i in entry_rows:
|
||||
end_i = min(i + config.max_hold_days, len(entries) - 1)
|
||||
if end_i > i:
|
||||
exits_idx.iloc[end_i][col] = True
|
||||
pf_kwargs["exits"] = (exits | exits_idx).astype(bool)
|
||||
|
||||
pf = vbt.Portfolio.from_signals(**pf_kwargs)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("vectorbt backtest failed")
|
||||
return BacktestResult(
|
||||
run_id=run_id,
|
||||
config=_config_to_dict(config),
|
||||
stats={"error": str(e)},
|
||||
equity_curve=[],
|
||||
trades=[],
|
||||
per_symbol_stats=[],
|
||||
)
|
||||
|
||||
# 提取结果
|
||||
try:
|
||||
stats_series = pf.stats(silence_warnings=True)
|
||||
if isinstance(stats_series, pd.DataFrame):
|
||||
# 多列时取 agg
|
||||
stats_dict = stats_series.mean(numeric_only=True).to_dict()
|
||||
else:
|
||||
stats_dict = stats_series.to_dict()
|
||||
except Exception: # noqa: BLE001
|
||||
stats_dict = {}
|
||||
|
||||
# 净值曲线(组合平均)
|
||||
equity = pf.value().mean(axis=1) if isinstance(pf.value(), pd.DataFrame) else pf.value()
|
||||
equity_curve = [
|
||||
{"date": str(idx.date() if hasattr(idx, "date") else idx), "value": float(v)}
|
||||
for idx, v in equity.items() if pd.notna(v)
|
||||
]
|
||||
|
||||
# 交易记录
|
||||
try:
|
||||
trades_df = pf.trades.records_readable
|
||||
trades = trades_df.to_dict(orient="records") if not trades_df.empty else []
|
||||
# 字段名美化
|
||||
trades = [
|
||||
{
|
||||
"symbol": t.get("Column", t.get("Symbol", "")),
|
||||
"entry_date": str(t.get("Entry Timestamp", t.get("Entry Date", ""))),
|
||||
"exit_date": str(t.get("Exit Timestamp", t.get("Exit Date", ""))),
|
||||
"entry_price": float(t.get("Avg Entry Price", t.get("Avg. Entry Price", 0))),
|
||||
"exit_price": float(t.get("Avg Exit Price", t.get("Avg. Exit Price", 0))),
|
||||
"pnl_pct": float(t.get("Return", t.get("PnL %", 0))),
|
||||
"duration": str(t.get("Duration", "")),
|
||||
}
|
||||
for t in trades
|
||||
]
|
||||
except Exception: # noqa: BLE001
|
||||
trades = []
|
||||
|
||||
# 每标的统计
|
||||
per_symbol = []
|
||||
try:
|
||||
total_ret = pf.total_return()
|
||||
if isinstance(total_ret, pd.Series):
|
||||
for sym, ret in total_ret.items():
|
||||
if pd.notna(ret):
|
||||
per_symbol.append({"symbol": sym, "total_return": float(ret)})
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
result = BacktestResult(
|
||||
run_id=run_id,
|
||||
config=_config_to_dict(config),
|
||||
stats={k: _json_safe(v) for k, v in stats_dict.items()},
|
||||
equity_curve=equity_curve,
|
||||
trades=trades,
|
||||
per_symbol_stats=per_symbol,
|
||||
)
|
||||
|
||||
# 落盘
|
||||
self._persist(result)
|
||||
return result
|
||||
|
||||
def _persist(self, result: BacktestResult) -> None:
|
||||
out_dir = settings.data_dir / "backtest_results"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 用 polars 写一份汇总
|
||||
summary = pl.DataFrame({
|
||||
"run_id": [result.run_id],
|
||||
"stats_json": [str(result.stats)],
|
||||
"n_trades": [len(result.trades)],
|
||||
})
|
||||
summary.write_parquet(out_dir / f"run_id={result.run_id}.parquet")
|
||||
|
||||
def get_result(self, run_id: str) -> BacktestResult | None:
|
||||
# Phase 1:只保留近似落盘,完整结果保存在内存的近期 cache 中
|
||||
# 简化:重新 run 比缓存复杂结果代价小,暂不实现 get_result
|
||||
return None
|
||||
|
||||
|
||||
def _config_to_dict(c: BacktestConfig) -> dict:
|
||||
return {
|
||||
"symbols": c.symbols,
|
||||
"start": str(c.start),
|
||||
"end": str(c.end),
|
||||
"entries": c.entries,
|
||||
"exits": c.exits,
|
||||
"stop_loss_pct": c.stop_loss_pct,
|
||||
"max_hold_days": c.max_hold_days,
|
||||
"fees_pct": c.fees_pct,
|
||||
"slippage_bps": c.slippage_bps,
|
||||
"matching": c.matching,
|
||||
}
|
||||
|
||||
|
||||
def _json_safe(v):
|
||||
if isinstance(v, (int, float, str, bool)) or v is None:
|
||||
return v
|
||||
if isinstance(v, (np.floating, np.integer)):
|
||||
return float(v) if not np.isnan(float(v)) else None
|
||||
if hasattr(v, "isoformat"):
|
||||
return v.isoformat()
|
||||
return str(v)
|
||||
@@ -38,7 +38,7 @@ def _invalidate(table: str | None = None) -> None:
|
||||
|
||||
|
||||
def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
||||
"""解析标的池 — 与 daily_pipeline 独立的副本。"""
|
||||
"""解析标的池。"""
|
||||
if capset.has(Cap.KLINE_DAILY_BATCH):
|
||||
try:
|
||||
from app.tickflow.pools import get_pool
|
||||
@@ -48,12 +48,11 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
||||
except Exception as e:
|
||||
logger.warning("CN_Equity_A pool unavailable: %s", e)
|
||||
|
||||
from app.tickflow.pools import DEMO_SYMBOLS, get_pool as _get_pool
|
||||
from app.tickflow.pools import DEMO_SYMBOLS
|
||||
from app.config import settings
|
||||
from pathlib import Path
|
||||
import polars as pl
|
||||
base: set[str] = set(DEMO_SYMBOLS)
|
||||
base.update(_get_pool("watchlist"))
|
||||
d = Path(settings.data_dir)
|
||||
inst_path = d / "instruments" / "instruments.parquet"
|
||||
if inst_path.exists():
|
||||
|
||||
@@ -296,17 +296,6 @@ def set_nav_hidden(hidden: list[str]) -> list[str]:
|
||||
return get_nav_hidden()
|
||||
|
||||
|
||||
def get_watchlist_columns() -> list[dict] | None:
|
||||
"""返回自选列表列配置。"""
|
||||
return load().get("watchlist_columns")
|
||||
|
||||
|
||||
def set_watchlist_columns(columns: list[dict]) -> list[dict]:
|
||||
"""保存自选列表列配置。"""
|
||||
save({"watchlist_columns": columns})
|
||||
return columns
|
||||
|
||||
|
||||
def get_screener_result_columns() -> list[dict] | None:
|
||||
"""返回策略结果列表列配置。"""
|
||||
return load().get("screener_result_columns")
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
"""自选股服务(§6.1)。
|
||||
|
||||
存储:`data/user_data/watchlist.parquet`,字段 symbol + added_at + note。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.config import settings
|
||||
from app.tickflow.capabilities import Cap, CapabilitySet
|
||||
from app.tickflow.client import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
p = settings.data_dir / "user_data" / "watchlist.parquet"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def list_symbols() -> list[dict]:
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
df = pl.read_parquet(p)
|
||||
if df.is_empty():
|
||||
return []
|
||||
return df.to_dicts()
|
||||
|
||||
|
||||
def add(symbol: str, note: str = "") -> list[dict]:
|
||||
p = _path()
|
||||
if p.exists():
|
||||
df = pl.read_parquet(p)
|
||||
# 已存在则先移除,后面重新插入到最前面
|
||||
if symbol in df["symbol"].to_list():
|
||||
df = df.filter(pl.col("symbol") != symbol)
|
||||
else:
|
||||
df = pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8})
|
||||
|
||||
new_row = pl.DataFrame({
|
||||
"symbol": [symbol],
|
||||
"added_at": [datetime.utcnow().isoformat(timespec="seconds")],
|
||||
"note": [note],
|
||||
})
|
||||
out = pl.concat([new_row, df], how="diagonal_relaxed")
|
||||
out.write_parquet(p)
|
||||
return out.to_dicts()
|
||||
|
||||
|
||||
def remove(symbol: str) -> list[dict]:
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
df = pl.read_parquet(p)
|
||||
df = df.filter(pl.col("symbol") != symbol)
|
||||
df.write_parquet(p)
|
||||
return df.to_dicts()
|
||||
|
||||
|
||||
def move_to_top(symbol: str) -> list[dict]:
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
df = pl.read_parquet(p)
|
||||
if df.is_empty() or symbol not in df["symbol"].to_list():
|
||||
return df.to_dicts()
|
||||
target = df.filter(pl.col("symbol") == symbol)
|
||||
rest = df.filter(pl.col("symbol") != symbol)
|
||||
out = pl.concat([target, rest], how="diagonal_relaxed")
|
||||
out.write_parquet(p)
|
||||
return out.to_dicts()
|
||||
|
||||
|
||||
def clear() -> int:
|
||||
"""清空自选列表。返回移除的数量。"""
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return 0
|
||||
df = pl.read_parquet(p)
|
||||
count = df.height
|
||||
if count > 0:
|
||||
pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8}).write_parquet(p)
|
||||
return count
|
||||
|
||||
|
||||
def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8.0) -> list[dict]:
|
||||
"""拉取实时行情。
|
||||
|
||||
优先用 quote.batch;否则降级为 quote.by_symbol 单股请求。
|
||||
timeout_s: 单批次请求超时(秒),防止 API 卡死阻塞整个请求。
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
|
||||
if not symbols:
|
||||
return []
|
||||
|
||||
tf = get_client()
|
||||
quotes: list[dict] = []
|
||||
|
||||
# 走 batch
|
||||
batch_size = 5
|
||||
if capset.has(Cap.QUOTE_BATCH):
|
||||
lim = capset.limits(Cap.QUOTE_BATCH)
|
||||
batch_size = lim.batch if lim and lim.batch else 50
|
||||
elif capset.has(Cap.QUOTE_BY_SYMBOL):
|
||||
lim = capset.limits(Cap.QUOTE_BY_SYMBOL)
|
||||
batch_size = lim.batch if lim and lim.batch else 5
|
||||
else:
|
||||
# 无任何实时行情能力(none/free 档走 free-api 服务器,不提供实时行情)
|
||||
# 提前返回空,避免发起注定失败的请求
|
||||
return []
|
||||
|
||||
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
|
||||
|
||||
# 用线程池为每个批次加超时保护
|
||||
pool = ThreadPoolExecutor(max_workers=1)
|
||||
for chunk in chunks:
|
||||
try:
|
||||
future = pool.submit(tf.quotes.get, symbols=chunk, as_dataframe=True)
|
||||
raw = future.result(timeout=timeout_s)
|
||||
if raw is None or len(raw) == 0:
|
||||
continue
|
||||
df = pl.from_pandas(raw)
|
||||
rename_map = {
|
||||
"last_price": "price",
|
||||
"ext.change_pct": "pct",
|
||||
"ext.name": "name",
|
||||
}
|
||||
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
|
||||
quotes.extend(df.to_dicts())
|
||||
except FuturesTimeout:
|
||||
logger.warning("quote fetch timeout (%.1fs) for %d symbols", timeout_s, len(chunk))
|
||||
break # 超时后不再尝试后续批次
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("quote fetch failed for %d symbols: %s", len(chunk), e)
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
return quotes
|
||||
@@ -3,7 +3,6 @@
|
||||
Phase 1 实现:
|
||||
- 常用指数成份(沪深 300 / 中证 500 / 上证 50)用 TickFlow `quote.pool` 端点拉取并缓存
|
||||
- 全 A 通过 instruments.batch 获取
|
||||
- 自选池 = 用户的 watchlist
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -19,7 +18,7 @@ from app.tickflow.client import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PoolId = Literal["CSI300", "CSI500", "SSE50", "CN_Equity_A", "CN_Index", "watchlist"]
|
||||
PoolId = Literal["CSI300", "CSI500", "SSE50", "CN_Equity_A", "CN_Index"]
|
||||
|
||||
# TickFlow universe id 是它内部命名(见 tf.universes.list())。
|
||||
# 没有官方对照表,启动时按名称模糊匹配从 universes.list() 里找。
|
||||
@@ -54,9 +53,6 @@ def _pool_cache_path(pool_id: str) -> Path:
|
||||
|
||||
def get_pool(pool_id: PoolId, refresh: bool = False) -> list[str]:
|
||||
"""返回标的池里的 symbol 列表。"""
|
||||
if pool_id == "watchlist":
|
||||
return _load_watchlist()
|
||||
|
||||
cache = _pool_cache_path(pool_id)
|
||||
if cache.exists() and not refresh:
|
||||
df = pl.read_parquet(cache)
|
||||
@@ -132,17 +128,6 @@ def _fetch_pool(pool_id: PoolId) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _load_watchlist() -> list[str]:
|
||||
"""读取用户自选(由 watchlist service 维护)。"""
|
||||
path = settings.data_dir / "user_data" / "watchlist.parquet"
|
||||
if not path.exists():
|
||||
return []
|
||||
df = pl.read_parquet(path)
|
||||
if df.is_empty() or "symbol" not in df.columns:
|
||||
return []
|
||||
return df["symbol"].to_list()
|
||||
|
||||
|
||||
# 兜底:Free 用户/无 API 时给一个小型可用集合,让 UI 不至于空白
|
||||
DEMO_SYMBOLS = [
|
||||
"600000.SH", # 浦发银行
|
||||
|
||||
@@ -56,7 +56,6 @@ class DataStore:
|
||||
"instruments_ext",
|
||||
"kline_ext",
|
||||
"pools",
|
||||
"backtest_results",
|
||||
"screener_results",
|
||||
"ai_cache",
|
||||
"user_data",
|
||||
@@ -77,7 +76,7 @@ class DataStore:
|
||||
|
||||
背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录),
|
||||
新版改为 exe_dir / "data" (子目录)。老用户首次升级时旧数据在兄弟目录,
|
||||
若不迁移会导致历史行情/策略/回测/监控全部"丢失"(实际还在旧位置)。
|
||||
若不迁移会导致历史行情/策略/监控全部"丢失"(实际还在旧位置)。
|
||||
|
||||
策略 (仅打包桌面版触发, 开发/Docker 不受影响):
|
||||
1. 旧目录存在且新 data/ 还基本为空 → 整目录搬迁 (shutil.move, 跨盘符安全)。
|
||||
@@ -408,7 +407,7 @@ class KlineRepository:
|
||||
how="left",
|
||||
)
|
||||
|
||||
# 缓存完整历史 (含指标+必要基础信息) 供 filter_history/backtest 直接复用
|
||||
# 缓存完整历史 (含指标+必要基础信息)
|
||||
self._enriched_history_cache = df_full
|
||||
self._enriched_history_start = df_full["date"].min()
|
||||
logger.info("enriched 历史缓存: %d rows, %s ~ %s",
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.backtest.engine import BacktestEngine, MatcherConfig
|
||||
|
||||
|
||||
def _panel(symbols: list[str], days: int = 4, price: float = 10.0, overrides: dict[tuple[str, int], dict] | None = None) -> pl.DataFrame:
|
||||
overrides = overrides or {}
|
||||
start = date(2024, 1, 1)
|
||||
rows = []
|
||||
for sym in symbols:
|
||||
for i in range(days):
|
||||
patch = overrides.get((sym, i), {})
|
||||
rows.append({
|
||||
"symbol": sym,
|
||||
"name": sym,
|
||||
"date": start + timedelta(days=i),
|
||||
"open": patch.get("open", price),
|
||||
"high": patch.get("high", price),
|
||||
"low": patch.get("low", price),
|
||||
"close": patch.get("close", price),
|
||||
"volume": patch.get("volume", 100_000),
|
||||
"score": patch.get("score", {"A": 4, "B": 3, "C": 2, "D": 1}.get(sym, 0)),
|
||||
"signal_limit_up": patch.get("signal_limit_up", False),
|
||||
"signal_limit_down": patch.get("signal_limit_down", False),
|
||||
})
|
||||
return pl.DataFrame(rows).sort(["symbol", "date"])
|
||||
|
||||
|
||||
def _mask(panel: pl.DataFrame, marks: set[tuple[str, int]]) -> pl.Series:
|
||||
values = []
|
||||
base = date(2024, 1, 1)
|
||||
for row in panel.select(["symbol", "date"]).iter_rows(named=True):
|
||||
day = (row["date"] - base).days
|
||||
values.append((row["symbol"], day) in marks)
|
||||
return pl.Series(values, dtype=pl.Boolean)
|
||||
|
||||
|
||||
def _engine() -> BacktestEngine:
|
||||
return BacktestEngine(repo=None) # simulate_portfolio 不访问 repo
|
||||
|
||||
|
||||
def test_max_exposure_sets_target_position_and_caps_count():
|
||||
panel = _panel(["A", "B", "C", "D"], days=3)
|
||||
entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0), ("D", 0)})
|
||||
exits = _mask(panel, set())
|
||||
|
||||
result = _engine().simulate_portfolio(
|
||||
panel,
|
||||
entries,
|
||||
exits,
|
||||
MatcherConfig(
|
||||
matching="open_t+1",
|
||||
fees_pct=0,
|
||||
slippage_bps=0,
|
||||
max_positions=3,
|
||||
max_exposure_pct=0.6,
|
||||
initial_capital=100_000,
|
||||
),
|
||||
)
|
||||
|
||||
assert len(result.trades) == 3
|
||||
assert {t.symbol for t in result.trades} == {"A", "B", "C"}
|
||||
assert all(abs(t.position_pct - 0.2) < 0.001 for t in result.trades)
|
||||
assert result.stats["max_exposure"] <= 0.61
|
||||
|
||||
|
||||
def test_one_price_limit_up_blocks_buy():
|
||||
panel = _panel(
|
||||
["A"],
|
||||
days=3,
|
||||
overrides={
|
||||
("A", 1): {"open": 11, "high": 11, "low": 11, "close": 11, "signal_limit_up": True},
|
||||
},
|
||||
)
|
||||
entries = _mask(panel, {("A", 0)})
|
||||
exits = _mask(panel, set())
|
||||
|
||||
result = _engine().simulate_portfolio(
|
||||
panel,
|
||||
entries,
|
||||
exits,
|
||||
MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_positions=1, initial_capital=100_000),
|
||||
)
|
||||
|
||||
assert result.trades == []
|
||||
assert result.stats["execution"]["buy_limit_up"] == 1
|
||||
|
||||
|
||||
def test_failed_open_exit_keeps_slot_and_blocks_replacement_buy():
|
||||
panel = _panel(
|
||||
["A", "B", "C", "D"],
|
||||
days=4,
|
||||
overrides={
|
||||
("A", 2): {"open": 9, "high": 9, "low": 9, "close": 9, "signal_limit_down": True},
|
||||
},
|
||||
)
|
||||
entries = _mask(panel, {
|
||||
("A", 0), ("B", 0), ("C", 0),
|
||||
("D", 1),
|
||||
})
|
||||
exits = _mask(panel, {("A", 1)})
|
||||
|
||||
result = _engine().simulate_portfolio(
|
||||
panel,
|
||||
entries,
|
||||
exits,
|
||||
MatcherConfig(
|
||||
matching="open_t+1",
|
||||
fees_pct=0,
|
||||
slippage_bps=0,
|
||||
max_positions=3,
|
||||
max_exposure_pct=0.6,
|
||||
initial_capital=100_000,
|
||||
),
|
||||
)
|
||||
|
||||
assert "D" not in {t.symbol for t in result.trades}
|
||||
assert result.stats["execution"]["sell_limit_down"] == 1
|
||||
assert result.stats["execution"]["pending_exit"] == 1
|
||||
assert result.stats["execution"]["buy_no_slot"] >= 1
|
||||
a_trade = next(t for t in result.trades if t.symbol == "A")
|
||||
assert a_trade.blocked_exit_days == 1
|
||||
assert a_trade.exit_reason == "signal"
|
||||
|
||||
|
||||
def test_trailing_stop_uses_high_water_mark():
|
||||
panel = _panel(
|
||||
["A"],
|
||||
days=5,
|
||||
overrides={
|
||||
("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12},
|
||||
("A", 3): {"open": 12, "high": 12, "low": 11.3, "close": 11.3},
|
||||
},
|
||||
)
|
||||
entries = _mask(panel, {("A", 0)})
|
||||
exits = _mask(panel, set())
|
||||
|
||||
result = _engine().simulate_portfolio(
|
||||
panel,
|
||||
entries,
|
||||
exits,
|
||||
MatcherConfig(
|
||||
matching="open_t+1",
|
||||
fees_pct=0,
|
||||
slippage_bps=0,
|
||||
max_positions=1,
|
||||
initial_capital=100_000,
|
||||
trailing_stop_pct=0.05,
|
||||
),
|
||||
)
|
||||
|
||||
assert len(result.trades) == 1
|
||||
trade = result.trades[0]
|
||||
assert trade.exit_reason == "trailing_stop"
|
||||
assert trade.exit_price == 11.4
|
||||
|
||||
|
||||
def test_trailing_take_profit_requires_activation():
|
||||
panel = _panel(
|
||||
["A"],
|
||||
days=5,
|
||||
overrides={
|
||||
("A", 2): {"open": 10, "high": 10.8, "low": 10.4, "close": 10.8},
|
||||
("A", 3): {"open": 10.8, "high": 10.8, "low": 10.4, "close": 10.4},
|
||||
},
|
||||
)
|
||||
entries = _mask(panel, {("A", 0)})
|
||||
exits = _mask(panel, set())
|
||||
|
||||
result = _engine().simulate_portfolio(
|
||||
panel,
|
||||
entries,
|
||||
exits,
|
||||
MatcherConfig(
|
||||
matching="open_t+1",
|
||||
fees_pct=0,
|
||||
slippage_bps=0,
|
||||
max_positions=1,
|
||||
initial_capital=100_000,
|
||||
trailing_take_profit_activate_pct=0.10,
|
||||
trailing_take_profit_drawdown_pct=0.03,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.trades[0].exit_reason == "end"
|
||||
|
||||
|
||||
def test_trailing_take_profit_exits_after_activation():
|
||||
panel = _panel(
|
||||
["A"],
|
||||
days=5,
|
||||
overrides={
|
||||
("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12},
|
||||
("A", 3): {"open": 12, "high": 12, "low": 11.5, "close": 11.5},
|
||||
},
|
||||
)
|
||||
entries = _mask(panel, {("A", 0)})
|
||||
exits = _mask(panel, set())
|
||||
|
||||
result = _engine().simulate_portfolio(
|
||||
panel,
|
||||
entries,
|
||||
exits,
|
||||
MatcherConfig(
|
||||
matching="open_t+1",
|
||||
fees_pct=0,
|
||||
slippage_bps=0,
|
||||
max_positions=1,
|
||||
initial_capital=100_000,
|
||||
trailing_take_profit_activate_pct=0.10,
|
||||
trailing_take_profit_drawdown_pct=0.03,
|
||||
),
|
||||
)
|
||||
|
||||
assert len(result.trades) == 1
|
||||
trade = result.trades[0]
|
||||
assert trade.exit_reason == "trailing_take_profit"
|
||||
assert trade.exit_price == 11.7
|
||||
|
||||
|
||||
def test_score_filter_uses_signal_day_score_range():
|
||||
panel = _panel(
|
||||
["A", "B", "C"],
|
||||
days=3,
|
||||
overrides={
|
||||
("A", 0): {"score": 70},
|
||||
("B", 0): {"score": 80},
|
||||
("C", 0): {"score": 90},
|
||||
("A", 1): {"score": 100},
|
||||
("B", 1): {"score": 1},
|
||||
("C", 1): {"score": 1},
|
||||
},
|
||||
)
|
||||
entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0)})
|
||||
exits = _mask(panel, set())
|
||||
|
||||
result = _engine().simulate_portfolio(
|
||||
panel,
|
||||
entries,
|
||||
exits,
|
||||
MatcherConfig(
|
||||
matching="open_t+1",
|
||||
fees_pct=0,
|
||||
slippage_bps=0,
|
||||
max_positions=3,
|
||||
initial_capital=100_000,
|
||||
score_min=71,
|
||||
score_max=85,
|
||||
),
|
||||
)
|
||||
|
||||
assert {t.symbol for t in result.trades} == {"B"}
|
||||
assert result.trades[0].entry_score == 80
|
||||
assert result.stats["execution"]["buy_score_filter"] == 2
|
||||
|
||||
|
||||
def test_independent_candidates_allow_overlapping_same_symbol_trades():
|
||||
panel = _panel(
|
||||
["A"],
|
||||
days=5,
|
||||
overrides={
|
||||
("A", 0): {"close": 10},
|
||||
("A", 1): {"close": 11},
|
||||
("A", 2): {"close": 12},
|
||||
("A", 3): {"close": 13},
|
||||
("A", 4): {"close": 14},
|
||||
},
|
||||
)
|
||||
entries = _mask(panel, {("A", 0), ("A", 1)})
|
||||
exits = _mask(panel, set())
|
||||
|
||||
result = _engine().simulate_independent_candidates(
|
||||
panel,
|
||||
entries,
|
||||
exits,
|
||||
MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, max_hold_days=2),
|
||||
)
|
||||
|
||||
assert result.stats["full_kind"] == "candidate_execution"
|
||||
assert result.stats["n_candidates"] == 2
|
||||
assert len(result.trades) == 2
|
||||
assert [t.entry_date for t in result.trades] == ["2024-01-01", "2024-01-02"]
|
||||
assert [t.exit_date for t in result.trades] == ["2024-01-03", "2024-01-04"]
|
||||
assert all(t.exit_reason == "max_hold" for t in result.trades)
|
||||
|
||||
|
||||
def test_independent_candidates_apply_stop_loss():
|
||||
panel = _panel(
|
||||
["A"],
|
||||
days=4,
|
||||
overrides={
|
||||
("A", 0): {"close": 10, "low": 10},
|
||||
("A", 1): {"open": 10, "high": 10, "low": 8.9, "close": 9},
|
||||
},
|
||||
)
|
||||
entries = _mask(panel, {("A", 0)})
|
||||
exits = _mask(panel, set())
|
||||
|
||||
result = _engine().simulate_independent_candidates(
|
||||
panel,
|
||||
entries,
|
||||
exits,
|
||||
MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, stop_loss_pct=0.1),
|
||||
)
|
||||
|
||||
assert len(result.trades) == 1
|
||||
assert result.trades[0].exit_reason == "stop_loss"
|
||||
assert result.trades[0].exit_price == 9.0
|
||||
|
||||
|
||||
def test_signal_exit_takes_priority_over_max_hold():
|
||||
"""同一日既有卖点信号又到期 → 应按 signal 平仓 (卖点优先于 max_hold 兜底)。"""
|
||||
panel = _panel(
|
||||
["A"],
|
||||
days=4,
|
||||
overrides={
|
||||
# day1 次日开盘买入 (open_t+1), 价 10
|
||||
("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10},
|
||||
# day2 持有 (hold_days 计到 1)
|
||||
("A", 2): {"open": 11, "high": 11, "low": 11, "close": 11},
|
||||
# day3: 既到期 (hold_days=2 >= max_hold_days=2) 又有卖点信号 → signal 优先
|
||||
("A", 3): {"open": 12, "high": 12, "low": 12, "close": 12},
|
||||
},
|
||||
)
|
||||
entries = _mask(panel, {("A", 0)}) # day0 收盘确认 → day1 开盘买
|
||||
exits = _mask(panel, {("A", 2)}) # day2 收盘确认卖点 → day3 开盘卖
|
||||
|
||||
result = _engine().simulate_portfolio(
|
||||
panel,
|
||||
entries,
|
||||
exits,
|
||||
MatcherConfig(
|
||||
matching="open_t+1",
|
||||
fees_pct=0,
|
||||
slippage_bps=0,
|
||||
max_positions=1,
|
||||
max_hold_days=2,
|
||||
initial_capital=100_000,
|
||||
),
|
||||
)
|
||||
|
||||
assert len(result.trades) == 1
|
||||
trade = result.trades[0]
|
||||
assert trade.exit_reason == "signal"
|
||||
assert trade.exit_price == 12.0 # 卖点用 day3 开盘 (exit_fill 跟随 matching=open_t+1)
|
||||
|
||||
|
||||
def test_stop_loss_triggers_even_when_expired_in_open_mode():
|
||||
"""open_t+1 模式下仓位到期且当日破止损 → 应按 stop_loss 平仓 (风控优先于 max_hold)。"""
|
||||
panel = _panel(
|
||||
["A"],
|
||||
days=4,
|
||||
overrides={
|
||||
("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10},
|
||||
# day3 开盘跳空跌破止损 (-10%): open=8.9 < 9.0 止损线, low=8.5
|
||||
("A", 3): {"open": 8.9, "high": 8.9, "low": 8.5, "close": 8.7},
|
||||
},
|
||||
)
|
||||
entries = _mask(panel, {("A", 0)})
|
||||
exits = _mask(panel, set())
|
||||
|
||||
result = _engine().simulate_portfolio(
|
||||
panel,
|
||||
entries,
|
||||
exits,
|
||||
MatcherConfig(
|
||||
matching="open_t+1",
|
||||
fees_pct=0,
|
||||
slippage_bps=0,
|
||||
max_positions=1,
|
||||
max_hold_days=2,
|
||||
stop_loss_pct=0.1,
|
||||
initial_capital=100_000,
|
||||
),
|
||||
)
|
||||
|
||||
assert len(result.trades) == 1
|
||||
trade = result.trades[0]
|
||||
assert trade.exit_reason == "stop_loss"
|
||||
# 风控盘中触发: 开盘价 8.9 <= 止损线 9.0 → 按开盘价 8.9 成交
|
||||
assert trade.exit_price == 8.9
|
||||
|
||||
|
||||
def test_default_fill_is_buy_open_sell_close():
|
||||
"""拆分口径: 建仓=次日开盘, 清仓=收盘。entry_price 用次日 open, exit_price 用收盘价。"""
|
||||
panel = _panel(
|
||||
["A"],
|
||||
days=4,
|
||||
overrides={
|
||||
# day1: 次日开盘买入, 开盘 10
|
||||
("A", 1): {"open": 10, "high": 10.5, "low": 9.5, "close": 10.2},
|
||||
# day2: 到期 (max_hold_days=1), 收盘卖
|
||||
("A", 2): {"open": 11, "high": 11, "low": 10, "close": 10.8},
|
||||
},
|
||||
)
|
||||
entries = _mask(panel, {("A", 0)}) # day0 收盘确认
|
||||
exits = _mask(panel, set())
|
||||
|
||||
result = _engine().simulate_portfolio(
|
||||
panel,
|
||||
entries,
|
||||
exits,
|
||||
MatcherConfig(
|
||||
entry_fill="open_t+1",
|
||||
exit_fill="close_t",
|
||||
fees_pct=0,
|
||||
slippage_bps=0,
|
||||
max_positions=1,
|
||||
max_hold_days=1,
|
||||
initial_capital=100_000,
|
||||
),
|
||||
)
|
||||
|
||||
assert len(result.trades) == 1
|
||||
trade = result.trades[0]
|
||||
assert trade.entry_price == 10.0 # 次日开盘
|
||||
assert trade.exit_price == 10.8 # 到期日收盘
|
||||
assert trade.exit_reason == "max_hold"
|
||||
@@ -1,59 +0,0 @@
|
||||
"""全量模拟 (full mode) 尾部执行回归测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.backtest.engine import BacktestEngine, MatcherConfig
|
||||
|
||||
|
||||
def _panel_with_tail(symbols: list[str], n_data_days: int) -> pl.DataFrame:
|
||||
start = date(2024, 1, 1)
|
||||
rows = []
|
||||
for sym in symbols:
|
||||
for i in range(n_data_days):
|
||||
px = 10.0 + i
|
||||
rows.append({
|
||||
"symbol": sym,
|
||||
"date": start + timedelta(days=i),
|
||||
"open": px,
|
||||
"high": px,
|
||||
"low": px,
|
||||
"close": px,
|
||||
"volume": 100_000,
|
||||
"signal_limit_up": False,
|
||||
"signal_limit_down": False,
|
||||
})
|
||||
return pl.DataFrame(rows).sort(["symbol", "date"])
|
||||
|
||||
|
||||
def test_full_simulation_executes_signal_at_tail():
|
||||
"""信号集中在正式区间最后一天时, tail 数据应允许次日开盘买入并按策略退出。"""
|
||||
n_days = 6
|
||||
panel = _panel_with_tail(["A"], n_days + 3)
|
||||
|
||||
start = date(2024, 1, 1)
|
||||
end = start + timedelta(days=n_days - 1)
|
||||
entry_vals = []
|
||||
for row in panel.select(["symbol", "date"]).iter_rows(named=True):
|
||||
entry_vals.append(row["date"] == end)
|
||||
entry_mask = pl.Series(entry_vals, dtype=pl.Boolean)
|
||||
exit_mask = pl.Series([False] * len(panel), dtype=pl.Boolean)
|
||||
|
||||
result = BacktestEngine(repo=None).simulate_independent_candidates( # type: ignore[arg-type]
|
||||
panel,
|
||||
entry_mask,
|
||||
exit_mask,
|
||||
MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_hold_days=2),
|
||||
)
|
||||
|
||||
assert not result.stats.get("error"), f"unexpected error: {result.stats.get('error')}"
|
||||
assert result.stats.get("full_kind") == "candidate_execution"
|
||||
assert result.stats.get("n_candidates") == 1
|
||||
assert result.stats.get("n_trades") == 1
|
||||
assert len(result.trades) == 1
|
||||
trade = result.trades[0]
|
||||
assert trade.entry_signal_date == str(end)
|
||||
assert trade.entry_date == str(end + timedelta(days=1))
|
||||
assert trade.exit_reason == "max_hold"
|
||||
@@ -1,163 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.backtest.engine import BacktestEngine, SimResult
|
||||
from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService
|
||||
from app.strategy.engine import StrategyDef
|
||||
|
||||
|
||||
def _strategy(**kwargs) -> StrategyDef:
|
||||
defaults = dict(
|
||||
meta={"id": "test", "name": "test", "scoring": {}, "params": [], "limit": 100},
|
||||
basic_filter={"enabled": True, "amount_min": 100.0},
|
||||
entry_signals=[],
|
||||
exit_signals=[],
|
||||
stop_loss=None,
|
||||
trailing_stop=None,
|
||||
trailing_take_profit_activate=None,
|
||||
trailing_take_profit_drawdown=None,
|
||||
max_hold_days=None,
|
||||
alerts=[],
|
||||
filter_fn=lambda df, params: pl.lit(True),
|
||||
filter_history_fn=None,
|
||||
lookback_days=1,
|
||||
source="custom",
|
||||
file_path=None,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return StrategyDef(**defaults)
|
||||
|
||||
|
||||
class _StrategyEngineStub:
|
||||
def __init__(self, strategy: StrategyDef) -> None:
|
||||
self.strategy = strategy
|
||||
|
||||
def get(self, strategy_id: str) -> StrategyDef:
|
||||
return self.strategy
|
||||
|
||||
|
||||
class _RepoStub:
|
||||
def get_index_daily(self, *args, **kwargs) -> pl.DataFrame:
|
||||
return pl.DataFrame()
|
||||
|
||||
|
||||
class _EngineStub:
|
||||
def __init__(self, panel: pl.DataFrame) -> None:
|
||||
self.panel = panel
|
||||
self.repo = _RepoStub()
|
||||
self.load_args = None
|
||||
self.sim_panel: pl.DataFrame | None = None
|
||||
self.sim_entries: pl.Series | None = None
|
||||
|
||||
def load_panel(self, symbols, start: date, end: date) -> pl.DataFrame:
|
||||
self.load_args = (symbols, start, end)
|
||||
return self.panel
|
||||
|
||||
def simulate_portfolio(self, panel, entries, exits, config, progress_cb=None, cancel_event=None) -> SimResult:
|
||||
self.sim_panel = panel
|
||||
self.sim_entries = entries
|
||||
return SimResult(
|
||||
equity_curve=[{"date": "2024-01-01", "value": config.initial_capital}],
|
||||
drawdown_curve=[{"date": "2024-01-01", "value": 0.0}],
|
||||
trades=[],
|
||||
per_symbol_stats=[],
|
||||
stats={"total_return": 0.0, "n_trades": 0},
|
||||
)
|
||||
|
||||
|
||||
def test_basic_filter_only_limits_entries_not_panel_rows():
|
||||
start = date(2024, 1, 1)
|
||||
rows = []
|
||||
for i, amount in enumerate([1000.0, 0.0, 1000.0]):
|
||||
rows.append({
|
||||
"symbol": "A",
|
||||
"name": "A",
|
||||
"date": start + timedelta(days=i),
|
||||
"open": 10.0 + i,
|
||||
"high": 10.0 + i,
|
||||
"low": 10.0 + i,
|
||||
"close": 10.0 + i,
|
||||
"volume": 100_000,
|
||||
"amount": amount,
|
||||
"signal_limit_up": False,
|
||||
"signal_limit_down": False,
|
||||
})
|
||||
panel = pl.DataFrame(rows).sort(["symbol", "date"])
|
||||
engine = _EngineStub(panel)
|
||||
service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(_strategy()))
|
||||
|
||||
result = service.run(StrategyBacktestConfig(
|
||||
strategy_id="test",
|
||||
symbols=None,
|
||||
start=start,
|
||||
end=start + timedelta(days=2),
|
||||
matching="close_t",
|
||||
mode="position",
|
||||
))
|
||||
|
||||
assert result.error is None
|
||||
assert engine.sim_panel is not None
|
||||
assert engine.sim_panel.height == 3
|
||||
assert engine.sim_panel.filter(pl.col("amount") == 0.0).height == 1
|
||||
assert engine.sim_entries is not None
|
||||
assert engine.sim_entries.to_list() == [True, False, True]
|
||||
assert engine.load_args is not None
|
||||
assert engine.load_args[1] < start # warmup 只用于计算, 不参与正式交易
|
||||
|
||||
|
||||
def test_score_normalizes_inside_strategy_candidate_universe():
|
||||
panel = pl.DataFrame({
|
||||
"symbol": ["A", "B", "C"],
|
||||
"date": [date(2024, 1, 1)] * 3,
|
||||
"factor": [10.0, 20.0, 1000.0],
|
||||
})
|
||||
universe = pl.Series([True, True, False], dtype=pl.Boolean)
|
||||
strategy = SimpleNamespace(meta={"scoring": {"factor": 1.0}, "order_by": "score", "descending": True})
|
||||
|
||||
scored = StrategyBacktestService._apply_score(panel, strategy, None, universe_mask=universe)
|
||||
scores = dict(zip(scored["symbol"].to_list(), scored["score"].to_list()))
|
||||
|
||||
assert scores["A"] == 0.0
|
||||
assert scores["B"] == 100.0
|
||||
assert scores["C"] == 0.0
|
||||
|
||||
|
||||
def test_full_mode_executes_every_candidate_with_strategy_rules():
|
||||
start = date(2024, 1, 1)
|
||||
panel = pl.DataFrame([
|
||||
{"symbol": "A", "name": "A", "date": start, "open": 10.0, "high": 10.0, "low": 10.0, "close": 10.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False},
|
||||
{"symbol": "A", "name": "A", "date": start + timedelta(days=1), "open": 11.0, "high": 11.0, "low": 11.0, "close": 11.0, "volume": 1, "amount": 0.0, "signal_limit_up": False, "signal_limit_down": False},
|
||||
{"symbol": "A", "name": "A", "date": start + timedelta(days=2), "open": 20.0, "high": 20.0, "low": 20.0, "close": 20.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False},
|
||||
]).sort(["symbol", "date"])
|
||||
|
||||
engine = BacktestEngine(repo=None) # type: ignore[arg-type]
|
||||
engine.load_panel = lambda symbols, s, e: panel # type: ignore[method-assign]
|
||||
strategy = _strategy(
|
||||
filter_fn=lambda df, params: pl.col("date") == start,
|
||||
max_hold_days=1,
|
||||
)
|
||||
service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(strategy))
|
||||
|
||||
result = service.run(StrategyBacktestConfig(
|
||||
strategy_id="test",
|
||||
symbols=None,
|
||||
start=start,
|
||||
end=start,
|
||||
mode="full",
|
||||
matching="open_t+1",
|
||||
fees_pct=0,
|
||||
slippage_bps=0,
|
||||
holding_days=1,
|
||||
))
|
||||
|
||||
assert result.error is None
|
||||
assert result.stats["full_kind"] == "candidate_execution"
|
||||
assert result.stats["n_candidates"] == 1
|
||||
assert result.stats["n_trades"] == 1
|
||||
assert result.trades[0]["entry_date"] == str(start + timedelta(days=1))
|
||||
assert result.trades[0]["exit_reason"] == "max_hold"
|
||||
assert result.stats["avg_return"] == round(20 / 11 - 1, 4)
|
||||
Reference in New Issue
Block a user