@@ -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
|
stats[f"{key}_size_mb"] = sz
|
||||||
|
|
||||||
# total: 再加上其他零散文件(pools, financials, capabilities.json 等)
|
# 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:
|
for name in other_dirs:
|
||||||
d = data_dir / name
|
d = data_dir / name
|
||||||
if d.exists():
|
if d.exists():
|
||||||
@@ -629,7 +629,7 @@ def clear_data(request: Request):
|
|||||||
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
|
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
|
||||||
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute",
|
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute",
|
||||||
"adj_factor", "adj_factor_etf", "instruments", "instruments_index", "instruments_etf", "pools", "financials",
|
"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
|
d = data_dir / sub
|
||||||
if d.exists():
|
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']。
|
"""按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。
|
||||||
|
|
||||||
key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。
|
key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。
|
||||||
JOIN 逻辑参考 watchlist.watchlist_enriched;任何 ext 表/字段缺失都静默跳过。
|
JOIN 逻辑;任何 ext 表/字段缺失都静默跳过。
|
||||||
"""
|
"""
|
||||||
if not ext_columns or not ext_columns.strip():
|
if not ext_columns or not ext_columns.strip():
|
||||||
return resp
|
return resp
|
||||||
@@ -380,7 +380,7 @@ async def sync_minute(request: Request):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
progress("sync_minute", 5, "解析标的池…")
|
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 全量标的,覆盖北交所、新股等
|
# 补充 instruments 全量标的,覆盖北交所、新股等
|
||||||
inst_path = repo.store.data_dir / "instruments" / "instruments.parquet"
|
inst_path = repo.store.data_dir / "instruments" / "instruments.parquet"
|
||||||
if inst_path.exists():
|
if inst_path.exists():
|
||||||
|
|||||||
@@ -332,7 +332,6 @@ def get_preferences() -> dict:
|
|||||||
"instruments_schedule": preferences.get_instruments_schedule(),
|
"instruments_schedule": preferences.get_instruments_schedule(),
|
||||||
"enriched_batch_size": preferences.get_enriched_batch_size(),
|
"enriched_batch_size": preferences.get_enriched_batch_size(),
|
||||||
"index_daily_batch_size": preferences.get_index_daily_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(),
|
"screener_result_columns": preferences.get_screener_result_columns(),
|
||||||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||||||
"nav_order": preferences.get_nav_order(),
|
"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):
|
class NavOrderIn(BaseModel):
|
||||||
nav_order: list[str]
|
nav_order: list[str]
|
||||||
|
|
||||||
@@ -375,15 +366,6 @@ def update_nav_hidden(req: NavHiddenIn) -> dict:
|
|||||||
return {"nav_hidden": saved}
|
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")
|
@router.get("/preferences/screener-result-columns")
|
||||||
def get_screener_result_columns() -> dict:
|
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"
|
host: str = "0.0.0.0"
|
||||||
port: int = 3018
|
port: int = 3018
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
backtest_range_guard: bool = False
|
|
||||||
|
|
||||||
# Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env)
|
# Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env)
|
||||||
# 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。
|
# 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。
|
||||||
auth_password: str = ""
|
auth_password: str = ""
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
|||||||
"""解析标的池 — 以 CN_Equity_A (沪深京A股 ~5522只) 为主。
|
"""解析标的池 — 以 CN_Equity_A (沪深京A股 ~5522只) 为主。
|
||||||
|
|
||||||
有 batch 能力 → 直接拉 CN_Equity_A universe
|
有 batch 能力 → 直接拉 CN_Equity_A universe
|
||||||
其他用户 → 用 instruments parquet + watchlist 兜底
|
其他用户 → 用 instruments parquet 兜底
|
||||||
"""
|
"""
|
||||||
if capset.has(Cap.KLINE_DAILY_BATCH):
|
if capset.has(Cap.KLINE_DAILY_BATCH):
|
||||||
try:
|
try:
|
||||||
@@ -54,9 +54,8 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
|||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("CN_Equity_A pool unavailable, fallback: %s", e)
|
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: set[str] = set(DEMO_SYMBOLS)
|
||||||
base.update(get_pool("watchlist"))
|
|
||||||
d = Path(settings.data_dir)
|
d = Path(settings.data_dir)
|
||||||
inst_path = d / "instruments" / "instruments.parquet"
|
inst_path = d / "instruments" / "instruments.parquet"
|
||||||
if inst_path.exists():
|
if inst_path.exists():
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app import __version__
|
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.api.routes import router as core_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.jobs import daily_pipeline
|
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(core_router)
|
||||||
app.include_router(auth_api.router)
|
app.include_router(auth_api.router)
|
||||||
app.include_router(kline.router)
|
app.include_router(kline.router)
|
||||||
app.include_router(watchlist.router)
|
|
||||||
app.include_router(screener.router)
|
app.include_router(screener.router)
|
||||||
app.include_router(backtest.router)
|
|
||||||
app.include_router(indices.router)
|
app.include_router(indices.router)
|
||||||
app.include_router(overview.router)
|
app.include_router(overview.router)
|
||||||
app.include_router(analysis.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]:
|
def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
||||||
"""解析标的池 — 与 daily_pipeline 独立的副本。"""
|
"""解析标的池。"""
|
||||||
if capset.has(Cap.KLINE_DAILY_BATCH):
|
if capset.has(Cap.KLINE_DAILY_BATCH):
|
||||||
try:
|
try:
|
||||||
from app.tickflow.pools import get_pool
|
from app.tickflow.pools import get_pool
|
||||||
@@ -48,12 +48,11 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("CN_Equity_A pool unavailable: %s", 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 app.config import settings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import polars as pl
|
import polars as pl
|
||||||
base: set[str] = set(DEMO_SYMBOLS)
|
base: set[str] = set(DEMO_SYMBOLS)
|
||||||
base.update(_get_pool("watchlist"))
|
|
||||||
d = Path(settings.data_dir)
|
d = Path(settings.data_dir)
|
||||||
inst_path = d / "instruments" / "instruments.parquet"
|
inst_path = d / "instruments" / "instruments.parquet"
|
||||||
if inst_path.exists():
|
if inst_path.exists():
|
||||||
|
|||||||
@@ -296,17 +296,6 @@ def set_nav_hidden(hidden: list[str]) -> list[str]:
|
|||||||
return get_nav_hidden()
|
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:
|
def get_screener_result_columns() -> list[dict] | None:
|
||||||
"""返回策略结果列表列配置。"""
|
"""返回策略结果列表列配置。"""
|
||||||
return load().get("screener_result_columns")
|
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 实现:
|
Phase 1 实现:
|
||||||
- 常用指数成份(沪深 300 / 中证 500 / 上证 50)用 TickFlow `quote.pool` 端点拉取并缓存
|
- 常用指数成份(沪深 300 / 中证 500 / 上证 50)用 TickFlow `quote.pool` 端点拉取并缓存
|
||||||
- 全 A 通过 instruments.batch 获取
|
- 全 A 通过 instruments.batch 获取
|
||||||
- 自选池 = 用户的 watchlist
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -19,7 +18,7 @@ from app.tickflow.client import get_client
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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())。
|
# TickFlow universe id 是它内部命名(见 tf.universes.list())。
|
||||||
# 没有官方对照表,启动时按名称模糊匹配从 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]:
|
def get_pool(pool_id: PoolId, refresh: bool = False) -> list[str]:
|
||||||
"""返回标的池里的 symbol 列表。"""
|
"""返回标的池里的 symbol 列表。"""
|
||||||
if pool_id == "watchlist":
|
|
||||||
return _load_watchlist()
|
|
||||||
|
|
||||||
cache = _pool_cache_path(pool_id)
|
cache = _pool_cache_path(pool_id)
|
||||||
if cache.exists() and not refresh:
|
if cache.exists() and not refresh:
|
||||||
df = pl.read_parquet(cache)
|
df = pl.read_parquet(cache)
|
||||||
@@ -132,17 +128,6 @@ def _fetch_pool(pool_id: PoolId) -> list[str]:
|
|||||||
return []
|
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 不至于空白
|
# 兜底:Free 用户/无 API 时给一个小型可用集合,让 UI 不至于空白
|
||||||
DEMO_SYMBOLS = [
|
DEMO_SYMBOLS = [
|
||||||
"600000.SH", # 浦发银行
|
"600000.SH", # 浦发银行
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ class DataStore:
|
|||||||
"instruments_ext",
|
"instruments_ext",
|
||||||
"kline_ext",
|
"kline_ext",
|
||||||
"pools",
|
"pools",
|
||||||
"backtest_results",
|
|
||||||
"screener_results",
|
"screener_results",
|
||||||
"ai_cache",
|
"ai_cache",
|
||||||
"user_data",
|
"user_data",
|
||||||
@@ -77,7 +76,7 @@ class DataStore:
|
|||||||
|
|
||||||
背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录),
|
背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录),
|
||||||
新版改为 exe_dir / "data" (子目录)。老用户首次升级时旧数据在兄弟目录,
|
新版改为 exe_dir / "data" (子目录)。老用户首次升级时旧数据在兄弟目录,
|
||||||
若不迁移会导致历史行情/策略/回测/监控全部"丢失"(实际还在旧位置)。
|
若不迁移会导致历史行情/策略/监控全部"丢失"(实际还在旧位置)。
|
||||||
|
|
||||||
策略 (仅打包桌面版触发, 开发/Docker 不受影响):
|
策略 (仅打包桌面版触发, 开发/Docker 不受影响):
|
||||||
1. 旧目录存在且新 data/ 还基本为空 → 整目录搬迁 (shutil.move, 跨盘符安全)。
|
1. 旧目录存在且新 data/ 还基本为空 → 整目录搬迁 (shutil.move, 跨盘符安全)。
|
||||||
@@ -408,7 +407,7 @@ class KlineRepository:
|
|||||||
how="left",
|
how="left",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 缓存完整历史 (含指标+必要基础信息) 供 filter_history/backtest 直接复用
|
# 缓存完整历史 (含指标+必要基础信息)
|
||||||
self._enriched_history_cache = df_full
|
self._enriched_history_cache = df_full
|
||||||
self._enriched_history_start = df_full["date"].min()
|
self._enriched_history_start = df_full["date"].min()
|
||||||
logger.info("enriched 历史缓存: %d rows, %s ~ %s",
|
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)
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
|
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
|
||||||
import { COLUMN_GROUPS, type ColumnConfig } from '@/lib/watchlist-columns'
|
import type { ColumnConfig } from '@/lib/list-columns'
|
||||||
|
|
||||||
interface ColumnCustomizerProps {
|
interface ColumnCustomizerProps {
|
||||||
columns: ColumnConfig[]
|
columns: ColumnConfig[]
|
||||||
@@ -12,7 +12,7 @@ export function ColumnCustomizer({ columns, onChange, open, onClose }: ColumnCus
|
|||||||
return (
|
return (
|
||||||
<ListColumnCustomizer
|
<ListColumnCustomizer
|
||||||
columns={columns}
|
columns={columns}
|
||||||
groups={COLUMN_GROUPS}
|
groups={[]}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ import {
|
|||||||
} from '@/lib/useSharedQueries'
|
} from '@/lib/useSharedQueries'
|
||||||
import { QK } from '@/lib/queryKeys'
|
import { QK } from '@/lib/queryKeys'
|
||||||
import {
|
import {
|
||||||
Star,
|
|
||||||
ScanSearch,
|
ScanSearch,
|
||||||
History,
|
|
||||||
FileText,
|
FileText,
|
||||||
Settings,
|
Settings,
|
||||||
Key,
|
Key,
|
||||||
@@ -47,9 +45,7 @@ const BRAND = '#8B5CF6'
|
|||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ to: '/', label: '看板', icon: LayoutDashboard },
|
{ to: '/', label: '看板', icon: LayoutDashboard },
|
||||||
{ to: '/watchlist', label: '自选', icon: Star },
|
|
||||||
{ to: '/screener', label: '策略', icon: ScanSearch },
|
{ to: '/screener', label: '策略', icon: ScanSearch },
|
||||||
{ to: '/backtest', label: '回测', icon: History },
|
|
||||||
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
|
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
|
||||||
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
|
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
|
||||||
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { X, RefreshCw, Clock } from 'lucide-react'
|
import { X, RefreshCw, Clock } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import { QK } from '@/lib/queryKeys'
|
|
||||||
import { cnSignal } from '@/lib/signals'
|
import { cnSignal } from '@/lib/signals'
|
||||||
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
|
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
|
||||||
import { DatePicker } from '@/components/DatePicker'
|
import { DatePicker } from '@/components/DatePicker'
|
||||||
@@ -44,21 +42,6 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
|||||||
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
|
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const watchlist = useQuery({
|
|
||||||
queryKey: QK.watchlist,
|
|
||||||
queryFn: api.watchlistList,
|
|
||||||
enabled: !!symbol,
|
|
||||||
})
|
|
||||||
const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol)
|
|
||||||
|
|
||||||
const toggleWatchlist = useMutation({
|
|
||||||
mutationFn: () => inWatchlist ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
|
||||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// ESC 关闭
|
// ESC 关闭
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!symbol) return
|
if (!symbol) return
|
||||||
@@ -239,8 +222,6 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
|||||||
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
|
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
onMonitor={() => setShowMonitorEditor(true)}
|
onMonitor={() => setShowMonitorEditor(true)}
|
||||||
inWatchlist={inWatchlist}
|
|
||||||
onToggleWatchlist={() => toggleWatchlist.mutate()}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* score、signals、candle、ext 列。其余纯数据列(价格/指标/财务…)交给共享原语。
|
* score、signals、candle、ext 列。其余纯数据列(价格/指标/财务…)交给共享原语。
|
||||||
*/
|
*/
|
||||||
import { useState, type CSSProperties, type ReactNode } from 'react'
|
import { useState, type CSSProperties, type ReactNode } from 'react'
|
||||||
import { Check, Plus, Eye, EyeOff } from 'lucide-react'
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
import type { KlineRow } from '@/lib/api'
|
import type { KlineRow } from '@/lib/api'
|
||||||
import { fmtPrice } from '@/lib/format'
|
import { fmtPrice } from '@/lib/format'
|
||||||
import type { ColumnConfig } from '@/lib/screener-columns'
|
import type { ColumnConfig } from '@/lib/screener-columns'
|
||||||
@@ -22,10 +22,7 @@ interface ScreenerTableProps {
|
|||||||
strategyIdToName: Record<string, string>
|
strategyIdToName: Record<string, string>
|
||||||
symbolStrategyMap: Map<string, string[]>
|
symbolStrategyMap: Map<string, string[]>
|
||||||
activeStrategy: string | null
|
activeStrategy: string | null
|
||||||
watchlistSet: Set<string>
|
|
||||||
onPreview: (symbol: string, name: string) => void
|
onPreview: (symbol: string, name: string) => void
|
||||||
onToggleWatchlist: (symbol: string, inList: boolean) => void
|
|
||||||
watchlistPending: boolean
|
|
||||||
/** symbol → 日k 数据,仅当启用日k列时传入 */
|
/** symbol → 日k 数据,仅当启用日k列时传入 */
|
||||||
klineData?: Record<string, KlineRow[]>
|
klineData?: Record<string, KlineRow[]>
|
||||||
/** 日k蜡烛图是否显示(表头眼睛开关) */
|
/** 日k蜡烛图是否显示(表头眼睛开关) */
|
||||||
@@ -114,7 +111,7 @@ function renderExtValue(
|
|||||||
|
|
||||||
export function ScreenerTable({
|
export function ScreenerTable({
|
||||||
rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy,
|
rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy,
|
||||||
watchlistSet, onPreview, onToggleWatchlist, watchlistPending, klineData = {},
|
onPreview, klineData = {},
|
||||||
dailyKChartVisible = true, onToggleDailyKChart,
|
dailyKChartVisible = true, onToggleDailyKChart,
|
||||||
sort, onSortToggle,
|
sort, onSortToggle,
|
||||||
}: ScreenerTableProps) {
|
}: ScreenerTableProps) {
|
||||||
@@ -164,7 +161,6 @@ export function ScreenerTable({
|
|||||||
switch (key) {
|
switch (key) {
|
||||||
case 'symbol': {
|
case 'symbol': {
|
||||||
const board = boardTag(r.symbol)
|
const board = boardTag(r.symbol)
|
||||||
const inWatchlist = watchlistSet.has(r.symbol)
|
|
||||||
return (
|
return (
|
||||||
<td key={col.id} className="px-4 py-2">
|
<td key={col.id} className="px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -189,25 +185,10 @@ export function ScreenerTable({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isExpired ? (
|
{isExpired && (
|
||||||
<span className="shrink-0 inline-flex items-center px-1.5 py-px rounded text-[9px] font-medium leading-tight bg-red-500/10 text-red-400/60 border border-red-500/15">
|
<span className="shrink-0 inline-flex items-center px-1.5 py-px rounded text-[9px] font-medium leading-tight bg-red-500/10 text-red-400/60 border border-red-500/15">
|
||||||
失效
|
失效
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onToggleWatchlist(r.symbol, inWatchlist)}
|
|
||||||
disabled={watchlistPending}
|
|
||||||
className={`shrink-0 inline-flex items-center justify-center w-5 h-5 rounded-full border transition-colors cursor-pointer
|
|
||||||
disabled:opacity-50
|
|
||||||
${inWatchlist
|
|
||||||
? 'border-accent/40 bg-accent/10 text-accent'
|
|
||||||
: 'border-border text-muted hover:border-accent/40 hover:text-accent'
|
|
||||||
}`}
|
|
||||||
title={inWatchlist ? '移出自选' : '加入自选'}
|
|
||||||
>
|
|
||||||
{inWatchlist ? <Check className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -2,25 +2,36 @@
|
|||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles } from 'lucide-react'
|
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles } from 'lucide-react'
|
||||||
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
|
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
|
||||||
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
|
|
||||||
import { color } from '@/lib/colors'
|
import { color } from '@/lib/colors'
|
||||||
import { SignalPicker } from './SignalPicker'
|
import { SignalPicker } from './SignalPicker'
|
||||||
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
|
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
|
||||||
|
|
||||||
// 内置列名 → 中文标签
|
// 字段名 → 中文标签
|
||||||
const FIELD_LABEL: Record<string, string> = {}
|
const FIELD_LABEL: Record<string, string> = {
|
||||||
for (const c of BUILTIN_COLUMNS) {
|
close: '收盘价', open: '开盘价', high: '最高价', low: '最低价',
|
||||||
if (c.source.type === 'builtin') FIELD_LABEL[c.source.key] = c.label
|
|
||||||
}
|
|
||||||
// enriched 列名别名
|
|
||||||
Object.assign(FIELD_LABEL, {
|
|
||||||
change_pct: '涨跌幅', consecutive_limit_ups: '连板',
|
change_pct: '涨跌幅', consecutive_limit_ups: '连板',
|
||||||
|
momentum_5d: '5D动量', momentum_10d: '10D动量',
|
||||||
|
momentum_20d: '20D动量', momentum_30d: '30D动量',
|
||||||
momentum_60d: '60D动量', turnover_rate: '换手率',
|
momentum_60d: '60D动量', turnover_rate: '换手率',
|
||||||
|
change_amount: '涨跌额', amplitude: '振幅',
|
||||||
|
volume: '成交量', amount: '成交额',
|
||||||
rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24',
|
rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24',
|
||||||
vol_ratio_5d: '量比', vol_ratio_20d: '20日量比',
|
vol_ratio_5d: '量比', vol_ratio_20d: '20日量比',
|
||||||
|
vol_ratio: '量比',
|
||||||
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
|
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
|
||||||
boll_upper: '布林上轨', boll_lower: '布林下轨',
|
boll_upper: '布林上轨', boll_lower: '布林下轨',
|
||||||
})
|
ma5: 'MA5', ma10: 'MA10', ma20: 'MA20', ma30: 'MA30', ma60: 'MA60',
|
||||||
|
kdj_k: 'KDJ-K', kdj_d: 'KDJ-D', kdj_j: 'KDJ-J',
|
||||||
|
atr14: 'ATR14', annual_vol: '年化波动',
|
||||||
|
high_60d: '60日高', low_60d: '60日低',
|
||||||
|
eps: 'EPS', bps: 'BPS', roe: 'ROE', pe_ttm: 'PE(TTM)', pb: 'PB',
|
||||||
|
gross_margin: '毛利率', net_margin: '净利率',
|
||||||
|
revenue_yoy: '营收增速', net_income_yoy: '净利增速',
|
||||||
|
debt_ratio: '负债率',
|
||||||
|
score: '评分',
|
||||||
|
limit_ups: '连板', limit_downs: '连跌',
|
||||||
|
float_val: '流通值', price: '现价', pct: '涨跌幅', turnover: '换手率',
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
strategyId: string | null
|
strategyId: string | null
|
||||||
|
|||||||
@@ -192,23 +192,6 @@ export interface KlineRow {
|
|||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Watchlist =====
|
|
||||||
export interface WatchlistEntry {
|
|
||||||
symbol: string
|
|
||||||
added_at: string
|
|
||||||
note?: string
|
|
||||||
name?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Quote {
|
|
||||||
symbol: string
|
|
||||||
price?: number
|
|
||||||
pct?: number
|
|
||||||
close?: number
|
|
||||||
change_pct?: number
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IndexInstrument {
|
export interface IndexInstrument {
|
||||||
symbol: string
|
symbol: string
|
||||||
name?: string | null
|
name?: string | null
|
||||||
@@ -505,111 +488,6 @@ export interface LimitLadderResult {
|
|||||||
sealed_counts_down?: { real: number; fake: number; pending: number }
|
sealed_counts_down?: { real: number; fake: number; pending: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Backtest =====
|
|
||||||
export interface BacktestResult {
|
|
||||||
run_id: string
|
|
||||||
config: any
|
|
||||||
stats: Record<string, any>
|
|
||||||
equity_curve: { date: string; value: number }[]
|
|
||||||
trades: any[]
|
|
||||||
per_symbol_stats: { symbol: string; total_return: number }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Factor Backtest =====
|
|
||||||
export interface FactorColumn {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
group: string
|
|
||||||
desc: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroupStat {
|
|
||||||
group: number
|
|
||||||
label: string
|
|
||||||
total_return: number
|
|
||||||
annual_return: number
|
|
||||||
max_drawdown: number
|
|
||||||
sharpe: number
|
|
||||||
win_rate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FactorBacktestResult {
|
|
||||||
run_id: string
|
|
||||||
config: Record<string, any>
|
|
||||||
ic_mean: number | null
|
|
||||||
ic_std: number | null
|
|
||||||
ir: number | null
|
|
||||||
ic_win_rate: number | null
|
|
||||||
ic_series: { date: string; ic: number }[]
|
|
||||||
group_stats: GroupStat[]
|
|
||||||
group_nav: Record<string, any>[]
|
|
||||||
long_short_stats: Record<string, any>
|
|
||||||
long_short_nav: { date: string; value: number }[]
|
|
||||||
elapsed_ms: number
|
|
||||||
n_symbols: number
|
|
||||||
n_dates: number
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Strategy Backtest =====
|
|
||||||
export interface StrategyBacktestTrade {
|
|
||||||
symbol: string
|
|
||||||
name?: string
|
|
||||||
entry_date: string
|
|
||||||
exit_date: string
|
|
||||||
entry_price: number
|
|
||||||
exit_price: number
|
|
||||||
pnl_pct: number
|
|
||||||
duration: number
|
|
||||||
exit_reason: string
|
|
||||||
shares?: number
|
|
||||||
lots?: number
|
|
||||||
position_pct?: number
|
|
||||||
entry_value?: number
|
|
||||||
exit_value?: number
|
|
||||||
pnl_amount?: number
|
|
||||||
entry_score?: number | null
|
|
||||||
entry_signal_date?: string | null
|
|
||||||
exit_signal_date?: string | null
|
|
||||||
blocked_exit_days?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StrategyBacktestResult {
|
|
||||||
run_id: string
|
|
||||||
config: Record<string, any>
|
|
||||||
stats: Record<string, any>
|
|
||||||
equity_curve: { date: string; value: number; cash?: number; positions?: number; exposure?: number }[]
|
|
||||||
drawdown_curve: { date: string; value: number }[]
|
|
||||||
benchmark_curve?: { date: string; value: number; close?: number; name?: string; symbol?: string }[]
|
|
||||||
trades: StrategyBacktestTrade[]
|
|
||||||
per_symbol_stats: {
|
|
||||||
symbol: string
|
|
||||||
n_trades: number
|
|
||||||
total_return: number
|
|
||||||
win_rate: number
|
|
||||||
best: number
|
|
||||||
worst: number
|
|
||||||
}[]
|
|
||||||
strategy_info: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
entry_signals: string[]
|
|
||||||
exit_signals: string[]
|
|
||||||
stop_loss: number | null
|
|
||||||
take_profit: number | null
|
|
||||||
trailing_stop: number | null
|
|
||||||
trailing_take_profit_activate: number | null
|
|
||||||
trailing_take_profit_drawdown: number | null
|
|
||||||
score_min: number | null
|
|
||||||
score_max: number | null
|
|
||||||
max_hold_days: number | null
|
|
||||||
source: string
|
|
||||||
}
|
|
||||||
elapsed_ms: number
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Settings =====
|
// ===== Settings =====
|
||||||
|
|
||||||
/** 端点发现清单 —— 对应 tickflow.org/endpoints.json */
|
/** 端点发现清单 —— 对应 tickflow.org/endpoints.json */
|
||||||
@@ -856,15 +734,6 @@ export const api = {
|
|||||||
body: JSON.stringify({ size }),
|
body: JSON.stringify({ size }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 自选列表列配置
|
|
||||||
watchlistColumns: () =>
|
|
||||||
request<{ columns: any[] | null }>('/api/settings/preferences/watchlist-columns'),
|
|
||||||
updateWatchlistColumns: (columns: any[]) =>
|
|
||||||
request<{ columns: any[] }>('/api/settings/preferences/watchlist-columns', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ columns }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 策略结果列表列配置
|
// 策略结果列表列配置
|
||||||
screenerResultColumns: () =>
|
screenerResultColumns: () =>
|
||||||
request<{ columns: any[] | null }>('/api/settings/preferences/screener-result-columns'),
|
request<{ columns: any[] | null }>('/api/settings/preferences/screener-result-columns'),
|
||||||
@@ -976,37 +845,6 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
watchlistList: () => request<{ symbols: WatchlistEntry[] }>('/api/watchlist'),
|
|
||||||
watchlistAdd: (symbol: string, note = '') =>
|
|
||||||
request<{ symbols: WatchlistEntry[] }>('/api/watchlist', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ symbol, note }),
|
|
||||||
}),
|
|
||||||
watchlistBatchAdd: (symbols: string[], note = '') =>
|
|
||||||
request<{ symbols: WatchlistEntry[]; added: number }>('/api/watchlist/batch', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ symbols, note }),
|
|
||||||
}),
|
|
||||||
watchlistRemove: (symbol: string) =>
|
|
||||||
request<{ symbols: WatchlistEntry[] }>(
|
|
||||||
`/api/watchlist/${encodeURIComponent(symbol)}`,
|
|
||||||
{ method: 'DELETE' },
|
|
||||||
),
|
|
||||||
watchlistMoveToTop: (symbol: string) =>
|
|
||||||
request<{ symbols: WatchlistEntry[] }>(
|
|
||||||
`/api/watchlist/${encodeURIComponent(symbol)}/top`,
|
|
||||||
{ method: 'POST' },
|
|
||||||
),
|
|
||||||
watchlistClear: () =>
|
|
||||||
request<{ removed: number }>('/api/watchlist', { method: 'DELETE' }),
|
|
||||||
watchlistQuotes: () => request<{ quotes: Quote[] }>('/api/watchlist/quotes'),
|
|
||||||
watchlistEnriched: (extColumns?: string) =>
|
|
||||||
request<{ rows: any[]; as_of: string | null; elapsed_ms: number }>(
|
|
||||||
extColumns
|
|
||||||
? `/api/watchlist/enriched?ext_columns=${encodeURIComponent(extColumns)}`
|
|
||||||
: '/api/watchlist/enriched',
|
|
||||||
),
|
|
||||||
|
|
||||||
screenerStrategies: () => request<{ presets: ScreenerStrategy[] }>('/api/screener/strategies'),
|
screenerStrategies: () => request<{ presets: ScreenerStrategy[] }>('/api/screener/strategies'),
|
||||||
screenerRunPreset: (strategy_id: string, pool?: string[], asOf?: string, extColumns?: string) =>
|
screenerRunPreset: (strategy_id: string, pool?: string[], asOf?: string, extColumns?: string) =>
|
||||||
request<ScreenerResult>('/api/screener/run_preset', {
|
request<ScreenerResult>('/api/screener/run_preset', {
|
||||||
@@ -1047,63 +885,6 @@ export const api = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
backtestStatus: () => request<{ available: boolean }>('/api/backtest/status'),
|
|
||||||
|
|
||||||
backtestRun: (payload: {
|
|
||||||
symbols: string[]
|
|
||||||
entries: string[]
|
|
||||||
exits: string[]
|
|
||||||
start?: string
|
|
||||||
end?: string
|
|
||||||
stop_loss_pct?: number
|
|
||||||
max_hold_days?: number
|
|
||||||
matching?: 'close_t' | 'open_t+1'
|
|
||||||
}) =>
|
|
||||||
request<BacktestResult>('/api/backtest/run', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}),
|
|
||||||
|
|
||||||
factorColumns: () =>
|
|
||||||
request<{ columns: FactorColumn[] }>('/api/backtest/factor/columns'),
|
|
||||||
|
|
||||||
factorRun: (payload: {
|
|
||||||
factor_name: string
|
|
||||||
symbols?: string[] | null
|
|
||||||
start?: string | null
|
|
||||||
end?: string | null
|
|
||||||
n_groups?: number
|
|
||||||
rebalance?: 'daily' | 'weekly' | 'monthly'
|
|
||||||
weight?: 'equal' | 'factor_weight'
|
|
||||||
fees_pct?: number
|
|
||||||
slippage_bps?: number
|
|
||||||
}) =>
|
|
||||||
request<FactorBacktestResult>('/api/backtest/factor/run', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}),
|
|
||||||
|
|
||||||
strategyBacktestRun: (payload: {
|
|
||||||
strategy_id: string
|
|
||||||
symbols?: string[] | null
|
|
||||||
start?: string | null
|
|
||||||
end?: string | null
|
|
||||||
params?: Record<string, any> | null
|
|
||||||
overrides?: Record<string, any> | null
|
|
||||||
matching?: 'close_t' | 'open_t+1'
|
|
||||||
entry_fill?: 'close_t' | 'open_t+1' | null
|
|
||||||
exit_fill?: 'close_t' | 'open_t+1' | null
|
|
||||||
fees_pct?: number
|
|
||||||
slippage_bps?: number
|
|
||||||
max_positions?: number
|
|
||||||
initial_capital?: number
|
|
||||||
position_sizing?: 'equal' | 'score_weight'
|
|
||||||
}) =>
|
|
||||||
request<StrategyBacktestResult>('/api/backtest/strategy/run', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}),
|
|
||||||
|
|
||||||
pipelineRun: () => request<{ job_id: string; reused: boolean }>(
|
pipelineRun: () => request<{ job_id: string; reused: boolean }>(
|
||||||
'/api/pipeline/run', { method: 'POST' },
|
'/api/pipeline/run', { method: 'POST' },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
import { useSyncExternalStore } from 'react'
|
|
||||||
import type { StrategyBacktestResult } from './api'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局回测任务管理 (SSE 模式 + 任务缓存 + 重连支持)。
|
|
||||||
*
|
|
||||||
* 特性:
|
|
||||||
* - 实时进度: EventSource 监听后端 SSE, 推送 day/total/equity
|
|
||||||
* - 可取消: POST /strategy/cancel/{job_key}, 后端 cancel_event
|
|
||||||
* - 切页/刷新保持: 后端按参数 hash 缓存任务, 重连不重启
|
|
||||||
* - 切页: 模块级 store 保持, EventSource 随组件卸载断开, 回来后重连
|
|
||||||
* - 刷新: localStorage 存 job 参数, 刷新后重新连接到同一任务
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface BacktestProgress {
|
|
||||||
day: number
|
|
||||||
total: number
|
|
||||||
date: string
|
|
||||||
equity: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BacktestTask {
|
|
||||||
id: number
|
|
||||||
isPending: boolean
|
|
||||||
result: StrategyBacktestResult | null
|
|
||||||
progress: BacktestProgress | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
let current: BacktestTask | null = null
|
|
||||||
const listeners = new Set<() => void>()
|
|
||||||
let taskSeq = 0
|
|
||||||
let eventSource: EventSource | null = null
|
|
||||||
|
|
||||||
const RECONNECT_KEY = 'backtest_reconnect'
|
|
||||||
|
|
||||||
function emit() {
|
|
||||||
listeners.forEach(fn => fn())
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribe(fn: () => void) {
|
|
||||||
listeners.add(fn)
|
|
||||||
return () => listeners.delete(fn)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSnapshot() {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerSnapshot() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 查询字符串构建 */
|
|
||||||
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
|
|
||||||
const sp = new URLSearchParams()
|
|
||||||
for (const [k, v] of Object.entries(params)) {
|
|
||||||
if (v != null && v !== '') sp.set(k, String(v))
|
|
||||||
}
|
|
||||||
return sp.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 连接 SSE (新建或重连都用这个) */
|
|
||||||
function connectSSE(url: string): void {
|
|
||||||
const id = current?.id ?? ++taskSeq
|
|
||||||
|
|
||||||
// 关闭旧连接
|
|
||||||
if (eventSource) {
|
|
||||||
eventSource.close()
|
|
||||||
eventSource = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const es = new EventSource(url)
|
|
||||||
eventSource = es
|
|
||||||
|
|
||||||
es.addEventListener('progress', (e: MessageEvent) => {
|
|
||||||
if (current?.id !== id) return
|
|
||||||
try {
|
|
||||||
const prog = JSON.parse(e.data) as BacktestProgress
|
|
||||||
current = { ...current, progress: prog }
|
|
||||||
emit()
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
})
|
|
||||||
|
|
||||||
es.addEventListener('done', (e: MessageEvent) => {
|
|
||||||
if (current?.id !== id) return
|
|
||||||
try {
|
|
||||||
const result = JSON.parse(e.data) as StrategyBacktestResult
|
|
||||||
current = { ...current, isPending: false, result, error: null }
|
|
||||||
emit()
|
|
||||||
} catch {
|
|
||||||
current = { ...current, isPending: false, error: '结果解析失败' }
|
|
||||||
emit()
|
|
||||||
}
|
|
||||||
es.close()
|
|
||||||
eventSource = null
|
|
||||||
localStorage.removeItem(RECONNECT_KEY)
|
|
||||||
})
|
|
||||||
|
|
||||||
es.addEventListener('error', (e: MessageEvent) => {
|
|
||||||
if (current?.id !== id) return
|
|
||||||
// SSE error 事件: 有 data 说明是后端主动推送的错误/取消; 无 data 说明是连接断开
|
|
||||||
if (e.data) {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(e.data)?.message ?? '回测出错'
|
|
||||||
current = { ...current, isPending: false, error: msg }
|
|
||||||
emit()
|
|
||||||
} catch {
|
|
||||||
current = { ...current, isPending: false, error: '回测出错' }
|
|
||||||
emit()
|
|
||||||
}
|
|
||||||
es.close()
|
|
||||||
eventSource = null
|
|
||||||
localStorage.removeItem(RECONNECT_KEY)
|
|
||||||
}
|
|
||||||
// 无 data: 连接异常断开, EventSource 会自动重连, 不改变状态
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 启动一次 SSE 回测任务 */
|
|
||||||
export function startBacktest(params: {
|
|
||||||
strategy_id: string
|
|
||||||
symbols?: string[] | null
|
|
||||||
start?: string | null
|
|
||||||
end?: string | null
|
|
||||||
matching?: string
|
|
||||||
entry_fill?: string
|
|
||||||
exit_fill?: string
|
|
||||||
fees_pct?: number
|
|
||||||
slippage_bps?: number
|
|
||||||
max_positions?: number
|
|
||||||
max_exposure_pct?: number
|
|
||||||
initial_capital?: number
|
|
||||||
position_sizing?: string
|
|
||||||
params?: Record<string, any> | null
|
|
||||||
overrides?: Record<string, any> | null
|
|
||||||
mode?: 'position' | 'full'
|
|
||||||
holding_days?: number
|
|
||||||
}): void {
|
|
||||||
// 取消之前的任务状态
|
|
||||||
if (eventSource) {
|
|
||||||
eventSource.close()
|
|
||||||
eventSource = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = ++taskSeq
|
|
||||||
current = { id, isPending: true, result: null, progress: null, error: null }
|
|
||||||
emit()
|
|
||||||
|
|
||||||
const qs = buildQuery({
|
|
||||||
strategy_id: params.strategy_id,
|
|
||||||
symbols: params.symbols?.join(','),
|
|
||||||
start: params.start ?? undefined,
|
|
||||||
end: params.end ?? undefined,
|
|
||||||
matching: params.matching,
|
|
||||||
entry_fill: params.entry_fill,
|
|
||||||
exit_fill: params.exit_fill,
|
|
||||||
fees_pct: params.fees_pct,
|
|
||||||
slippage_bps: params.slippage_bps,
|
|
||||||
max_positions: params.max_positions,
|
|
||||||
max_exposure_pct: params.max_exposure_pct,
|
|
||||||
initial_capital: params.initial_capital,
|
|
||||||
position_sizing: params.position_sizing,
|
|
||||||
params: params.params ? JSON.stringify(params.params) : undefined,
|
|
||||||
overrides: params.overrides ? JSON.stringify(params.overrides) : undefined,
|
|
||||||
mode: params.mode,
|
|
||||||
holding_days: params.holding_days,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 存 reconnect 信息 (刷新后用)
|
|
||||||
localStorage.setItem(RECONNECT_KEY, qs)
|
|
||||||
|
|
||||||
connectSSE(`/api/backtest/strategy/stream?${qs}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 停止当前回测任务 (调后端 cancel, 后端 cancel_event → 停止计算) */
|
|
||||||
export async function stopBacktest(): Promise<void> {
|
|
||||||
// 从 reconnect key 提取 job_key (后端按参数 hash 算 job_key)
|
|
||||||
const qs = localStorage.getItem(RECONNECT_KEY)
|
|
||||||
if (qs) {
|
|
||||||
// 解析出参数, 用 fetch 调 cancel
|
|
||||||
try {
|
|
||||||
// job_key 是后端算的 md5, 前端不知道。用 reconnect URL 里的参数重新请求 stream,
|
|
||||||
// 后端会找到同一个 job 并返回它的 job_key? 不行。
|
|
||||||
// 替代: 前端直接关闭 SSE 连接 + 调一个带参数的 cancel 接口。
|
|
||||||
// 简化: 关闭连接即可, 后端检测断开后 (不取消)。需要 cancel 用 POST。
|
|
||||||
// 这里用 cancel 接口: POST /strategy/cancel, body 带 qs 的参数。
|
|
||||||
await fetch('/api/backtest/strategy/cancel', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ qs }),
|
|
||||||
}).catch(() => {})
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
if (eventSource) {
|
|
||||||
eventSource.close()
|
|
||||||
eventSource = null
|
|
||||||
}
|
|
||||||
if (current?.isPending) {
|
|
||||||
current = { ...current, isPending: false, error: '已取消' }
|
|
||||||
emit()
|
|
||||||
}
|
|
||||||
localStorage.removeItem(RECONNECT_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除任务状态 (隐藏提示) */
|
|
||||||
export function clearBacktest(): void {
|
|
||||||
current = null
|
|
||||||
emit()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 恢复: 从 localStorage 读取 reconnect 信息, 重新连接 (刷新后调用) */
|
|
||||||
export function tryReconnect(): boolean {
|
|
||||||
const qs = localStorage.getItem(RECONNECT_KEY)
|
|
||||||
if (!qs) return false
|
|
||||||
// 有未完成的任务, 重连
|
|
||||||
const id = ++taskSeq
|
|
||||||
current = { id, isPending: true, result: null, progress: null, error: null }
|
|
||||||
emit()
|
|
||||||
connectSSE(`/api/backtest/strategy/stream?${qs}`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** React hook: 读取当前全局回测任务状态 */
|
|
||||||
export function useBacktestTask(): BacktestTask | null {
|
|
||||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
||||||
}
|
|
||||||
@@ -16,11 +16,7 @@ export const QK = {
|
|||||||
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
|
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
|
||||||
indexList: ['index-list'] as const,
|
indexList: ['index-list'] as const,
|
||||||
|
|
||||||
// Watchlist
|
// Search
|
||||||
watchlist: ['watchlist'] as const,
|
|
||||||
watchlistQuotes: ['watchlist-quotes'] as const,
|
|
||||||
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
|
|
||||||
watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const,
|
|
||||||
instrumentSearch: (q: string) => ['instrument-search', q] as const,
|
instrumentSearch: (q: string) => ['instrument-search', q] as const,
|
||||||
|
|
||||||
// Screener
|
// Screener
|
||||||
@@ -31,9 +27,6 @@ export const QK = {
|
|||||||
marketSnapshot: ['market-snapshot'] as const,
|
marketSnapshot: ['market-snapshot'] as const,
|
||||||
limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const,
|
limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const,
|
||||||
|
|
||||||
// Backtest
|
|
||||||
backtestStatus: ['backtest-status'] as const,
|
|
||||||
|
|
||||||
// Data / Pipeline
|
// Data / Pipeline
|
||||||
dataStatus: ['data-status'] as const,
|
dataStatus: ['data-status'] as const,
|
||||||
pipelineJobs: ['pipeline-jobs'] as const,
|
pipelineJobs: ['pipeline-jobs'] as const,
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 策略结果列表自定义列配置。
|
* 策略结果列表自定义列配置。
|
||||||
*
|
|
||||||
* 内置列与分组与自选页 (watchlist-columns) 保持一致,额外保留策略特有的
|
|
||||||
* 「策略」「评分」两列。通用列模型/合并/扩展列参数来自 list-columns。
|
|
||||||
*/
|
*/
|
||||||
import { storage } from '@/lib/storage'
|
import { storage } from '@/lib/storage'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 买卖触发器信号定义 — 选股页弹窗 / 回测页共用。
|
* 买卖触发器信号定义 — 选股页弹窗共用。
|
||||||
*
|
*
|
||||||
* 信号 ID 必须与后端 backtest/strategy.py:_build_signal_mask 对齐
|
* 信号 ID 必须与后端对齐
|
||||||
* (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。
|
* (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -27,27 +27,15 @@ export const storage = {
|
|||||||
/** 策略池 (screener) */
|
/** 策略池 (screener) */
|
||||||
strategyPool: kv<string[]>('strategy-pool'),
|
strategyPool: kv<string[]>('strategy-pool'),
|
||||||
|
|
||||||
/** 自选列表列配置 */
|
|
||||||
watchlistColumns: kv<unknown[]>('watchlist_columns'),
|
|
||||||
|
|
||||||
/** 个股日K信息条指标配置 */
|
/** 个股日K信息条指标配置 */
|
||||||
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
|
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
|
||||||
|
|
||||||
/** 策略结果列表列配置 */
|
/** 策略结果列表列配置 */
|
||||||
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
|
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
|
||||||
|
|
||||||
/** 自选列表视图模式 table | card */
|
|
||||||
watchlistView: kv<string>('watchlist_view'),
|
|
||||||
|
|
||||||
/** 自选列表日K蜡烛图显示状态 */
|
|
||||||
watchlistCandle: kv<boolean>('watchlist_showCandle'),
|
|
||||||
|
|
||||||
/** 策略结果列表日K蜡烛图显示状态 */
|
/** 策略结果列表日K蜡烛图显示状态 */
|
||||||
screenerCandle: kv<boolean>('screener_showCandle'),
|
screenerCandle: kv<boolean>('screener_showCandle'),
|
||||||
|
|
||||||
/** 自选列表板块筛选 */
|
|
||||||
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
|
|
||||||
|
|
||||||
/** Screener 卡片尺寸 */
|
/** Screener 卡片尺寸 */
|
||||||
screenerCardSize: kv<string>('screener-card-size'),
|
screenerCardSize: kv<string>('screener-card-size'),
|
||||||
|
|
||||||
@@ -78,31 +66,6 @@ export const storage = {
|
|||||||
/** 已保存策略的原始规则(策略ID → 规则文本) */
|
/** 已保存策略的原始规则(策略ID → 规则文本) */
|
||||||
strategyRules: kv<Record<string, string>>('strategy-rules'),
|
strategyRules: kv<Record<string, string>>('strategy-rules'),
|
||||||
|
|
||||||
/** 策略回测快捷区间按钮配置 */
|
|
||||||
strategyBacktestQuickRanges: kv<unknown>('strategy-backtest-quick-ranges'),
|
|
||||||
|
|
||||||
/** 策略回测最后一次成功结果和参数 */
|
|
||||||
strategyBacktestLast: kv<{
|
|
||||||
selectedStrategy: string | null
|
|
||||||
symbols: string
|
|
||||||
start: string
|
|
||||||
end: string
|
|
||||||
matching: 'close_t' | 'open_t+1'
|
|
||||||
entryFill: 'close_t' | 'open_t+1'
|
|
||||||
exitFill: 'close_t' | 'open_t+1'
|
|
||||||
fees: string
|
|
||||||
slippage: string
|
|
||||||
maxPositions: string
|
|
||||||
maxExposure: string
|
|
||||||
initialCapital: string
|
|
||||||
positionSizing: 'equal' | 'score_weight'
|
|
||||||
mode: 'position' | 'full'
|
|
||||||
holdingDays: string
|
|
||||||
params?: Record<string, any>
|
|
||||||
overrides?: Record<string, any>
|
|
||||||
result: any
|
|
||||||
} | null>('strategy-backtest-last'),
|
|
||||||
|
|
||||||
/** 概念分析页面字段配置 */
|
/** 概念分析页面字段配置 */
|
||||||
conceptAnalysisConfig: kv<Record<string, any>>('concept-analysis-config'),
|
conceptAnalysisConfig: kv<Record<string, any>>('concept-analysis-config'),
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,3 @@
|
|||||||
/**
|
/**
|
||||||
* 共享 mutation hooks — 消除多页面重复的 useMutation 调用。
|
* 共享 mutation hooks — 消除多页面重复的 useMutation 调用。
|
||||||
*/
|
*/
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { api } from './api'
|
|
||||||
import { QK } from './queryKeys'
|
|
||||||
|
|
||||||
/** 批量添加自选 — Screener / Intraday 共用 */
|
|
||||||
export function useWatchlistBatchAdd() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
|
||||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* 自选列表自定义列配置。
|
|
||||||
*
|
|
||||||
* 自选页只保留业务内置列、分组和偏好持久化;通用列模型/合并/扩展列参数
|
|
||||||
* 来自 list-columns,策略页等其它股票列表可复用同一底座。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { storage } from '@/lib/storage'
|
|
||||||
import {
|
|
||||||
buildExtColumnsParam as buildExtColumnsParamBase,
|
|
||||||
createExtColumn as createExtColumnBase,
|
|
||||||
mergeColumns as mergeColumnsBase,
|
|
||||||
serializeColumns as serializeColumnsBase,
|
|
||||||
type ColumnConfig,
|
|
||||||
type ColumnGroup,
|
|
||||||
type ColumnSource,
|
|
||||||
type ExtColumnDisplayConfig,
|
|
||||||
type CandleColumnConfig,
|
|
||||||
} from '@/lib/list-columns'
|
|
||||||
|
|
||||||
export type { ColumnConfig, ColumnGroup, ColumnSource, ExtColumnDisplayConfig, CandleColumnConfig }
|
|
||||||
|
|
||||||
// ===== 内置列注册表(与当前硬编码一一对应) =====
|
|
||||||
|
|
||||||
export const BUILTIN_COLUMNS: ColumnConfig[] = [
|
|
||||||
// 固定列
|
|
||||||
{ id: 'builtin:symbol', source: { type: 'builtin', key: 'symbol' }, label: '代码/名称', visible: true, pinned: true, align: 'left' },
|
|
||||||
// 价格
|
|
||||||
{ id: 'builtin:price', source: { type: 'builtin', key: 'price' }, label: '现价', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'center' },
|
|
||||||
// 成交
|
|
||||||
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:annual_vol', source: { type: 'builtin', key: 'annual_vol' }, label: '年化波动', visible: false, align: 'center' },
|
|
||||||
// 均线
|
|
||||||
{ id: 'builtin:ma5', source: { type: 'builtin', key: 'ma5' }, label: 'MA5', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:ma10', source: { type: 'builtin', key: 'ma10' }, label: 'MA10', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:ma20', source: { type: 'builtin', key: 'ma20' }, label: 'MA20', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:ma60', source: { type: 'builtin', key: 'ma60' }, label: 'MA60', visible: false, align: 'center' },
|
|
||||||
// 区间
|
|
||||||
{ id: 'builtin:high_60d', source: { type: 'builtin', key: 'high_60d' }, label: '60日高', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:low_60d', source: { type: 'builtin', key: 'low_60d' }, label: '60日低', visible: false, align: 'center' },
|
|
||||||
// 技术指标
|
|
||||||
{ id: 'builtin:rsi6', source: { type: 'builtin', key: 'rsi6' }, label: 'RSI6', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:rsi14', source: { type: 'builtin', key: 'rsi14' }, label: 'RSI14', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:rsi24', source: { type: 'builtin', key: 'rsi24' }, label: 'RSI24', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:macd_dif', source: { type: 'builtin', key: 'macd_dif' }, label: 'MACD-DIF', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:macd_dea', source: { type: 'builtin', key: 'macd_dea' }, label: 'MACD-DEA', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:macd_hist', source: { type: 'builtin', key: 'macd_hist' }, label: 'MACD柱', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:kdj_k', source: { type: 'builtin', key: 'kdj_k' }, label: 'KDJ-K', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:kdj_d', source: { type: 'builtin', key: 'kdj_d' }, label: 'KDJ-D', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:kdj_j', source: { type: 'builtin', key: 'kdj_j' }, label: 'KDJ-J', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:boll_upper', source: { type: 'builtin', key: 'boll_upper' }, label: '布林上轨', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:boll_lower', source: { type: 'builtin', key: 'boll_lower' }, label: '布林下轨', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:atr14', source: { type: 'builtin', key: 'atr14' }, label: 'ATR14', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:vol_ma5', source: { type: 'builtin', key: 'vol_ma5' }, label: '量MA5', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:vol_ma10', source: { type: 'builtin', key: 'vol_ma10' }, label: '量MA10', visible: false, align: 'center' },
|
|
||||||
// 动量
|
|
||||||
{ id: 'builtin:momentum_5d', source: { type: 'builtin', key: 'momentum_5d' }, label: '5D 动量', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:momentum_10d', source: { type: 'builtin', key: 'momentum_10d' }, label: '10D 动量', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum' }, label: '60D 动量', visible: true, align: 'center' },
|
|
||||||
// 连板
|
|
||||||
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
|
|
||||||
// 信号 & 图表
|
|
||||||
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
|
|
||||||
// 财务指标 (需 Expert 套餐 financial capability, 列默认隐藏)
|
|
||||||
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE(TTM)', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'center' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const COLUMN_GROUPS: ColumnGroup[] = [
|
|
||||||
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
|
|
||||||
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
|
|
||||||
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
|
|
||||||
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
|
|
||||||
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
|
|
||||||
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
|
|
||||||
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
|
|
||||||
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
|
|
||||||
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 操作列(始终显示,不参与自定义)
|
|
||||||
export const ACTION_COLUMN_ID = 'builtin:action'
|
|
||||||
|
|
||||||
// ===== localStorage 持久化 =====
|
|
||||||
|
|
||||||
/** 序列化列配置(只保存用户可自定义的列,排除 pinned 和 action) */
|
|
||||||
export function serializeColumns(columns: ColumnConfig[]): ColumnConfig[] {
|
|
||||||
return serializeColumnsBase(columns, ACTION_COLUMN_ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 序列化并保存到后端 + localStorage */
|
|
||||||
export async function saveColumnConfig(columns: ColumnConfig[]): Promise<void> {
|
|
||||||
const saveable = serializeColumns(columns)
|
|
||||||
// 同时写 localStorage(即时)和后端(持久化)
|
|
||||||
storage.watchlistColumns.set(saveable)
|
|
||||||
try {
|
|
||||||
const { api } = await import('@/lib/api')
|
|
||||||
await api.updateWatchlistColumns(saveable)
|
|
||||||
} catch {
|
|
||||||
// 后端不可用时 localStorage 仍有效
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 加载列配置:优先后端,回退 localStorage,最终用默认值 */
|
|
||||||
export async function loadColumnConfig(): Promise<ColumnConfig[]> {
|
|
||||||
// 1. 尝试从后端加载
|
|
||||||
try {
|
|
||||||
const { api } = await import('@/lib/api')
|
|
||||||
const res = await api.watchlistColumns()
|
|
||||||
if (res.columns && res.columns.length > 0) {
|
|
||||||
const merged = mergeColumns(res.columns, BUILTIN_COLUMNS)
|
|
||||||
// 同步到 localStorage
|
|
||||||
storage.watchlistColumns.set(serializeColumns(merged))
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 后端不可用,继续尝试 localStorage
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 尝试从 localStorage 加载
|
|
||||||
const saved = storage.watchlistColumns.get([]) as ColumnConfig[]
|
|
||||||
if (saved.length > 0) {
|
|
||||||
return mergeColumns(saved, BUILTIN_COLUMNS)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 默认值
|
|
||||||
return [...BUILTIN_COLUMNS]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 合并用户保存的列与默认列 */
|
|
||||||
function mergeColumns(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
|
|
||||||
return mergeColumnsBase(saved, defaults, { actionColumnId: ACTION_COLUMN_ID })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从列配置中提取 ext 列参数,用于后端 enriched 接口 */
|
|
||||||
export function buildExtColumnsParam(columns: ColumnConfig[]): string {
|
|
||||||
return buildExtColumnsParamBase(columns)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据 ext schema 数据创建 ext 列配置 */
|
|
||||||
export function createExtColumn(
|
|
||||||
configId: string,
|
|
||||||
configLabel: string,
|
|
||||||
fieldName: string,
|
|
||||||
fieldLabel?: string,
|
|
||||||
): ColumnConfig {
|
|
||||||
return createExtColumnBase(configId, configLabel, fieldName, fieldLabel)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
|
||||||
import { FactorBacktest } from './backtest/FactorBacktest'
|
|
||||||
import { StrategyBacktest } from './backtest/StrategyBacktest'
|
|
||||||
import { BarChart3, FlaskConical } from 'lucide-react'
|
|
||||||
|
|
||||||
type Tab = 'factor' | 'strategy'
|
|
||||||
|
|
||||||
const MODES: Record<Tab, { title: string; subtitle: string; hint: string }> = {
|
|
||||||
factor: {
|
|
||||||
title: '因子回测',
|
|
||||||
subtitle: '验证单个因子是否有预测能力',
|
|
||||||
hint: '看 IC / IR、分层收益和多空组合,适合先筛掉无效指标。',
|
|
||||||
},
|
|
||||||
strategy: {
|
|
||||||
title: '策略回测',
|
|
||||||
subtitle: '验证完整选股和交易规则',
|
|
||||||
hint: '看净值曲线、回撤、胜率和交易明细,适合判断策略是否可执行。',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Backtest() {
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('strategy')
|
|
||||||
|
|
||||||
const modeSwitch = (
|
|
||||||
<div className="inline-flex rounded-btn border border-border bg-surface/80 p-0.5 shadow-sm">
|
|
||||||
{(['factor', 'strategy'] as const).map(tab => {
|
|
||||||
const Icon = tab === 'factor' ? BarChart3 : FlaskConical
|
|
||||||
const active = activeTab === tab
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
|
|
||||||
active
|
|
||||||
? 'bg-accent text-white shadow-sm'
|
|
||||||
: 'text-secondary hover:bg-elevated hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="h-3.5 w-3.5" />
|
|
||||||
{MODES[tab].title}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-full bg-base flex flex-col">
|
|
||||||
<PageHeader
|
|
||||||
title="回测工作台"
|
|
||||||
subtitle={`${MODES[activeTab].title} · ${MODES[activeTab].hint}`}
|
|
||||||
right={modeSwitch}
|
|
||||||
className="shrink-0 bg-base/95"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="flex-1 min-h-0 px-3 pb-3 pt-3 lg:px-4 lg:pb-4">
|
|
||||||
{activeTab === 'factor' && <FactorBacktest />}
|
|
||||||
{activeTab === 'strategy' && <StrategyBacktest />}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import { PageHeader } from '@/components/PageHeader'
|
|||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
|
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
|
||||||
import { SealedBadge } from '@/components/SealedBadge'
|
import { SealedBadge } from '@/components/SealedBadge'
|
||||||
import type { ExtColumnDisplayConfig } from '@/lib/watchlist-columns'
|
import type { ExtColumnDisplayConfig } from '@/lib/list-columns'
|
||||||
|
|
||||||
// ===== Ext 字段配置 =====
|
// ===== Ext 字段配置 =====
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store } from 'lucide-react'
|
import { ScanSearch, Clock, TrendingUp, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store } from 'lucide-react'
|
||||||
import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api'
|
import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api'
|
||||||
import { useDataStatus, usePreferences } from '@/lib/useSharedQueries'
|
import { useDataStatus, usePreferences } from '@/lib/useSharedQueries'
|
||||||
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
|
|
||||||
import { QK } from '@/lib/queryKeys'
|
import { QK } from '@/lib/queryKeys'
|
||||||
import { storage } from '@/lib/storage'
|
import { storage } from '@/lib/storage'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
@@ -35,7 +34,6 @@ export function Screener() {
|
|||||||
const [activeStrategy, setActiveStrategy] = useState<string | null>(null)
|
const [activeStrategy, setActiveStrategy] = useState<string | null>(null)
|
||||||
const [result, setResult] = useState<ScreenerResult | null>(null)
|
const [result, setResult] = useState<ScreenerResult | null>(null)
|
||||||
const [asOf, setAsOf] = useState<string>('')
|
const [asOf, setAsOf] = useState<string>('')
|
||||||
const [batchMsg, setBatchMsg] = useState<string>('')
|
|
||||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||||
const [previewName, setPreviewName] = useState<string>('')
|
const [previewName, setPreviewName] = useState<string>('')
|
||||||
const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, [])
|
const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, [])
|
||||||
@@ -402,28 +400,6 @@ export function Screener() {
|
|||||||
const minDate = dataStatus.data?.enriched?.earliest_date ?? ''
|
const minDate = dataStatus.data?.enriched?.earliest_date ?? ''
|
||||||
const maxDate = dataStatus.data?.enriched?.latest_date ?? ''
|
const maxDate = dataStatus.data?.enriched?.latest_date ?? ''
|
||||||
|
|
||||||
const batchAdd = useWatchlistBatchAdd()
|
|
||||||
|
|
||||||
// 自选股列表 (用于判断是否在自选中)
|
|
||||||
const watchlist = useQuery({
|
|
||||||
queryKey: QK.watchlist,
|
|
||||||
queryFn: api.watchlistList,
|
|
||||||
})
|
|
||||||
const watchlistSet = useMemo(() => {
|
|
||||||
const symbols = watchlist.data?.symbols ?? []
|
|
||||||
return new Set(symbols.map((s: any) => s.symbol))
|
|
||||||
}, [watchlist.data])
|
|
||||||
|
|
||||||
// 单只股票加入/移出自选
|
|
||||||
const toggleWatchlist = useMutation({
|
|
||||||
mutationFn: ({ symbol, inList }: { symbol: string; inList: boolean }) =>
|
|
||||||
inList ? api.watchlistRemove(symbol) : api.watchlistAdd(symbol),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
|
||||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 重新运行策略:重载策略文件 + 重跑全部策略,刷新符合条件的个股
|
// 重新运行策略:重载策略文件 + 重跑全部策略,刷新符合条件的个股
|
||||||
const reloadStrategies = useMutation({
|
const reloadStrategies = useMutation({
|
||||||
mutationFn: api.strategyReload,
|
mutationFn: api.strategyReload,
|
||||||
@@ -473,22 +449,6 @@ export function Screener() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBatchAdd = () => {
|
|
||||||
if (!displayRows.length) return
|
|
||||||
const symbols = displayRows.map((r: any) => r.symbol)
|
|
||||||
batchAdd.mutate(symbols, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setBatchMsg(`已添加 ${data.added} 只到自选`)
|
|
||||||
setTimeout(() => setBatchMsg(''), 3000)
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setBatchMsg('添加失败')
|
|
||||||
setTimeout(() => setBatchMsg(''), 3000)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -688,18 +648,6 @@ export function Screener() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{displayRows.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={handleBatchAdd}
|
|
||||||
disabled={batchAdd.isPending}
|
|
||||||
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-btn
|
|
||||||
border border-accent/40 bg-accent/10 text-accent text-xs font-medium
|
|
||||||
hover:bg-accent/20 disabled:opacity-50 transition-colors duration-150 cursor-pointer"
|
|
||||||
>
|
|
||||||
<Star className="h-3 w-3" />
|
|
||||||
{batchAdd.isPending ? '添加中…' : '批量加自选'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setCustomizerOpen(true)}
|
onClick={() => setCustomizerOpen(true)}
|
||||||
title="列表配置"
|
title="列表配置"
|
||||||
@@ -711,9 +659,6 @@ export function Screener() {
|
|||||||
>
|
>
|
||||||
<Settings2 className="h-3 w-3" />
|
<Settings2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
{batchMsg && (
|
|
||||||
<span className="text-xs text-accent animate-pulse">{batchMsg}</span>
|
|
||||||
)}
|
|
||||||
{!showAll && result && result.elapsed_ms > 0 && (
|
{!showAll && result && result.elapsed_ms > 0 && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted">
|
<div className="flex items-center gap-2 text-xs text-muted">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
@@ -749,10 +694,7 @@ export function Screener() {
|
|||||||
strategyIdToName={strategyIdToName}
|
strategyIdToName={strategyIdToName}
|
||||||
symbolStrategyMap={symbolStrategyMap}
|
symbolStrategyMap={symbolStrategyMap}
|
||||||
activeStrategy={activeStrategy}
|
activeStrategy={activeStrategy}
|
||||||
watchlistSet={watchlistSet}
|
|
||||||
onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name) }}
|
onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name) }}
|
||||||
onToggleWatchlist={(symbol, inList) => toggleWatchlist.mutate({ symbol, inList })}
|
|
||||||
watchlistPending={toggleWatchlist.isPending}
|
|
||||||
klineData={klineData}
|
klineData={klineData}
|
||||||
dailyKChartVisible={dailyKChartVisible}
|
dailyKChartVisible={dailyKChartVisible}
|
||||||
onToggleDailyKChart={toggleDailyKChart}
|
onToggleDailyKChart={toggleDailyKChart}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,447 +0,0 @@
|
|||||||
import { useState, useMemo } from 'react'
|
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import { Play, BarChart3, Clock } from 'lucide-react'
|
|
||||||
import { api, type FactorColumn, type FactorBacktestResult, type GroupStat } from '@/lib/api'
|
|
||||||
import { fmtPct, priceColorClass } from '@/lib/format'
|
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
|
||||||
import { DatePicker } from '@/components/DatePicker'
|
|
||||||
import { FactorICChart } from './charts/FactorICChart'
|
|
||||||
import { FactorGroupNavChart } from './charts/FactorGroupNavChart'
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
|
|
||||||
const monthsAgo = (months: number) => {
|
|
||||||
const date = new Date()
|
|
||||||
date.setMonth(date.getMonth() - months)
|
|
||||||
return formatDate(date)
|
|
||||||
}
|
|
||||||
const TODAY = formatDate(new Date())
|
|
||||||
const THREE_MONTHS_AGO = monthsAgo(3)
|
|
||||||
|
|
||||||
const INPUT_CLS = `w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs
|
|
||||||
focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`
|
|
||||||
|
|
||||||
function StatCard({ label, value, highlight }: {
|
|
||||||
label: string
|
|
||||||
value: string | null | undefined
|
|
||||||
highlight?: 'bull' | 'bear' | 'neutral'
|
|
||||||
}) {
|
|
||||||
const colorCls = highlight === 'bull'
|
|
||||||
? 'text-bull' : highlight === 'bear' ? 'text-bear' : ''
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="text-[11px] text-muted">{label}</div>
|
|
||||||
<div className={`mt-1 text-lg font-mono font-semibold tracking-tight num ${colorCls}`}>
|
|
||||||
{value ?? '—'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingPanel({ symbolsText }: { symbolsText: string }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-card border border-accent/25 bg-accent/[0.04] p-4">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-foreground">正在计算因子分析</div>
|
|
||||||
<div className="mt-1 text-xs text-muted">{symbolsText} · 完成后会一次性刷新 IC、分层收益和净值曲线。</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-8 rounded-full border-2 border-accent/25 border-t-accent animate-spin" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-base">
|
|
||||||
<div className="h-full w-1/2 rounded-full bg-accent/70 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
{['读取因子', '计算 IC', '分层回测', '汇总指标'].map(item => (
|
|
||||||
<div key={item} className="rounded-btn border border-border bg-surface p-3">
|
|
||||||
<div className="h-2 w-10 rounded bg-accent/30 animate-pulse" />
|
|
||||||
<div className="mt-3 text-xs text-secondary">{item}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-card border border-border bg-surface p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="text-xs font-medium text-secondary">分层净值预览</div>
|
|
||||||
<div className="text-[11px] text-muted">等待后端返回完整结果</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 h-[260px] rounded-btn border border-border bg-base/60 p-4">
|
|
||||||
<div className="flex h-full items-end gap-2 opacity-70">
|
|
||||||
{[46, 38, 54, 50, 64, 58, 74, 68, 84, 78, 90, 86].map((h, i) => (
|
|
||||||
<div key={i} className="flex-1 rounded-t bg-accent/20 animate-pulse" style={{ height: `${h}%` }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FactorBacktest() {
|
|
||||||
const [factorName, setFactorName] = useState('momentum_20d')
|
|
||||||
const [symbols, setSymbols] = useState('')
|
|
||||||
const [start, setStart] = useState(THREE_MONTHS_AGO)
|
|
||||||
const [end, setEnd] = useState(TODAY)
|
|
||||||
const [nGroups, setNGroups] = useState(5)
|
|
||||||
const [weight, setWeight] = useState<'equal' | 'factor_weight'>('equal')
|
|
||||||
const [fees, setFees] = useState('2')
|
|
||||||
const [result, setResult] = useState<FactorBacktestResult | null>(null)
|
|
||||||
|
|
||||||
const columns = useQuery({
|
|
||||||
queryKey: ['backtest-factor-columns'],
|
|
||||||
queryFn: api.factorColumns,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 按 group 分类的因子
|
|
||||||
const factorGroups = useMemo(() => {
|
|
||||||
const cols = columns.data?.columns ?? []
|
|
||||||
const groups: Record<string, FactorColumn[]> = {}
|
|
||||||
for (const c of cols) {
|
|
||||||
;(groups[c.group] ??= []).push(c)
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}, [columns.data])
|
|
||||||
|
|
||||||
// 当前因子描述
|
|
||||||
const factorDesc = useMemo(() => {
|
|
||||||
return columns.data?.columns.find(c => c.id === factorName)?.desc ?? ''
|
|
||||||
}, [columns.data, factorName])
|
|
||||||
|
|
||||||
const run = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
api.factorRun({
|
|
||||||
factor_name: factorName,
|
|
||||||
symbols: symbols ? symbols.split(',').map(s => s.trim()).filter(Boolean) : null,
|
|
||||||
start: start || null,
|
|
||||||
end: end || undefined,
|
|
||||||
n_groups: nGroups,
|
|
||||||
rebalance: 'daily',
|
|
||||||
weight,
|
|
||||||
fees_pct: Number(fees) / 10000,
|
|
||||||
}),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data.error) {
|
|
||||||
setResult(data)
|
|
||||||
} else {
|
|
||||||
setResult(data)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const applyRange = (months: number) => {
|
|
||||||
setStart(monthsAgo(months))
|
|
||||||
setEnd(formatDate(new Date()))
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyAllRange = () => {
|
|
||||||
setStart('')
|
|
||||||
setEnd(formatDate(new Date()))
|
|
||||||
}
|
|
||||||
|
|
||||||
const rangeKey = end === TODAY && start === THREE_MONTHS_AGO
|
|
||||||
? '3m'
|
|
||||||
: end === TODAY && start === monthsAgo(6)
|
|
||||||
? '6m'
|
|
||||||
: end === TODAY && start === monthsAgo(12)
|
|
||||||
? '1y'
|
|
||||||
: end === TODAY && start === ''
|
|
||||||
? 'all'
|
|
||||||
: 'custom'
|
|
||||||
const rangeTitle = rangeKey === '3m'
|
|
||||||
? '近 3 个月'
|
|
||||||
: rangeKey === '6m'
|
|
||||||
? '近 6 个月'
|
|
||||||
: rangeKey === '1y'
|
|
||||||
? '近 1 年'
|
|
||||||
: rangeKey === 'all'
|
|
||||||
? '全部历史'
|
|
||||||
: '自定义区间'
|
|
||||||
const rangeButtonCls = (key: string) => `rounded-btn px-2 py-1 text-[11px] font-medium transition-colors ${rangeKey === key
|
|
||||||
? 'bg-accent/15 text-accent'
|
|
||||||
: 'text-muted hover:bg-elevated/70 hover:text-secondary'
|
|
||||||
}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full min-h-0 overflow-hidden rounded-card border border-border bg-surface/80 grid grid-cols-1 xl:grid-cols-[18rem_minmax(0,1fr)]">
|
|
||||||
{/* 配置面板 */}
|
|
||||||
<section className="space-y-3 border-b xl:border-b-0 xl:border-r border-border bg-base/25 px-3 py-3 xl:overflow-y-auto">
|
|
||||||
<div className="border-b border-border/70 pb-2">
|
|
||||||
<div className="text-xs font-semibold text-foreground">因子配置</div>
|
|
||||||
<div className="mt-0.5 text-[10px] leading-4 text-muted">选择因子、区间和分组方式。默认最近 3 个月。</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-secondary block mb-1.5">因子</label>
|
|
||||||
<select
|
|
||||||
value={factorName}
|
|
||||||
onChange={e => setFactorName(e.target.value)}
|
|
||||||
className={INPUT_CLS}
|
|
||||||
>
|
|
||||||
{Object.entries(factorGroups).map(([group, cols]) => (
|
|
||||||
<optgroup key={group} label={group}>
|
|
||||||
{cols.map(c => (
|
|
||||||
<option key={c.id} value={c.id}>{c.label}</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{factorDesc && (
|
|
||||||
<p className="mt-1 text-[11px] text-muted">{factorDesc}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-secondary block mb-1.5">
|
|
||||||
标的(逗号分隔,留空=全市场)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={symbols}
|
|
||||||
onChange={e => setSymbols(e.target.value)}
|
|
||||||
placeholder="留空则使用全市场,建议最近3个月"
|
|
||||||
className={`w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs font-mono
|
|
||||||
focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-btn border border-border bg-surface p-2.5">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="text-xs font-medium text-foreground">回测区间</div>
|
|
||||||
<span className="shrink-0 rounded-full border border-accent/25 bg-accent/10 px-2 py-0.5 text-[10px] font-medium text-accent">
|
|
||||||
{rangeTitle}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<label className="text-[11px] text-secondary block mb-1">开始</label>
|
|
||||||
<DatePicker
|
|
||||||
value={start}
|
|
||||||
onChange={setStart}
|
|
||||||
max={end || undefined}
|
|
||||||
placeholder="全部历史"
|
|
||||||
className="w-full"
|
|
||||||
buttonClassName="w-full justify-start"
|
|
||||||
align="left"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[11px] text-secondary block mb-1">结束</label>
|
|
||||||
<DatePicker
|
|
||||||
value={end}
|
|
||||||
onChange={setEnd}
|
|
||||||
min={start || undefined}
|
|
||||||
className="w-full"
|
|
||||||
buttonClassName="w-full justify-start"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 flex rounded-input bg-base/60 p-0.5">
|
|
||||||
<button type="button" onClick={() => applyRange(3)} className={`${rangeButtonCls('3m')} flex-1`}>3个月</button>
|
|
||||||
<button type="button" onClick={() => applyRange(6)} className={`${rangeButtonCls('6m')} flex-1`}>6个月</button>
|
|
||||||
<button type="button" onClick={() => applyRange(12)} className={`${rangeButtonCls('1y')} flex-1`}>1年</button>
|
|
||||||
<button type="button" onClick={applyAllRange} className={`${rangeButtonCls('all')} flex-1`}>全部</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-secondary block mb-1.5">分组数</label>
|
|
||||||
<select value={nGroups} onChange={e => setNGroups(Number(e.target.value))} className={INPUT_CLS}>
|
|
||||||
<option value={3}>3组</option>
|
|
||||||
<option value={5}>5组</option>
|
|
||||||
<option value={10}>10组</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-secondary block mb-1.5">权重</label>
|
|
||||||
<select value={weight} onChange={e => setWeight(e.target.value as any)} className={INPUT_CLS}>
|
|
||||||
<option value="equal">等权</option>
|
|
||||||
<option value="factor_weight">因子加权</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-secondary block mb-1.5">佣金(万分之)</label>
|
|
||||||
<input type="number" value={fees} onChange={e => setFees(e.target.value)}
|
|
||||||
className={INPUT_CLS} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => run.mutate()}
|
|
||||||
disabled={run.isPending}
|
|
||||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-btn
|
|
||||||
bg-accent text-sm font-medium hover:bg-accent/90
|
|
||||||
transition-colors duration-150 ease-smooth disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Play className="h-3.5 w-3.5" />
|
|
||||||
{run.isPending ? '分析中…' : '开始因子分析'}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 结果面板 */}
|
|
||||||
<section className="min-w-0 space-y-3 bg-base/15 px-3 py-3 xl:overflow-y-auto">
|
|
||||||
{result?.error && !result.ic_mean && (
|
|
||||||
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
|
|
||||||
{result.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{run.isError && (
|
|
||||||
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
|
|
||||||
{String((run.error as any).message)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!result && !run.isPending && (
|
|
||||||
<EmptyState
|
|
||||||
icon={BarChart3}
|
|
||||||
title="选择因子并开始分析"
|
|
||||||
hint="因子回测分析因子的预测能力 ( IC/IR ) 和分层收益差异。服务器建议优先使用最近3个月;长周期建议本机或 8GB 以上内存环境运行。"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{run.isPending && result && (
|
|
||||||
<div className="rounded-card border border-accent/25 bg-accent/[0.04] px-4 py-3 text-xs text-secondary">
|
|
||||||
正在重新计算,当前暂时展示上一次因子分析结果,完成后会自动替换。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{run.isPending && !result && (
|
|
||||||
<LoadingPanel symbolsText={symbols ? `${symbols.split(',').length} 只标的` : '全市场 · 当前区间'} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && result.ic_mean != null && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
{/* IC/IR 指标 */}
|
|
||||||
<div className="rounded-card border border-border bg-surface p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-sm font-medium text-foreground">因子预测能力</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[11px] text-muted">
|
|
||||||
Rank IC · 日度调仓
|
|
||||||
</span>
|
|
||||||
{result.elapsed_ms > 0 && (
|
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
<span className="num">{result.elapsed_ms.toFixed(0)} ms</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
<StatCard
|
|
||||||
label="IC 均值"
|
|
||||||
value={result.ic_mean != null ? fmtPct(result.ic_mean) : null}
|
|
||||||
highlight={result.ic_mean != null
|
|
||||||
? result.ic_mean > 0.03 ? 'bull' : result.ic_mean < -0.03 ? 'bear' : 'neutral'
|
|
||||||
: undefined}
|
|
||||||
/>
|
|
||||||
<StatCard label="IC 标准差" value={result.ic_std != null ? fmtPct(result.ic_std) : null} />
|
|
||||||
<StatCard
|
|
||||||
label="ICIR"
|
|
||||||
value={result.ir != null ? result.ir.toFixed(2) : null}
|
|
||||||
highlight={result.ir != null
|
|
||||||
? Math.abs(result.ir) > 0.5 ? (result.ir > 0 ? 'bull' : 'bear') : 'neutral'
|
|
||||||
: undefined}
|
|
||||||
/>
|
|
||||||
<StatCard label="IC 胜率" value={result.ic_win_rate != null ? fmtPct(result.ic_win_rate) : null} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* IC 时序图 */}
|
|
||||||
{result.ic_series.length > 0 && (
|
|
||||||
<div className="rounded-card border border-border overflow-hidden">
|
|
||||||
<div className="bg-elevated px-4 py-2">
|
|
||||||
<span className="text-xs font-medium text-secondary">IC 时序</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-2">
|
|
||||||
<FactorICChart result={result} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 分层净值 */}
|
|
||||||
{result.group_nav.length > 0 && (
|
|
||||||
<div className="rounded-card border border-border overflow-hidden">
|
|
||||||
<div className="bg-elevated px-4 py-2">
|
|
||||||
<span className="text-xs font-medium text-secondary">分层净值曲线</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-2">
|
|
||||||
<FactorGroupNavChart result={result} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 分层统计表 */}
|
|
||||||
{result.group_stats.length > 0 && (
|
|
||||||
<div className="rounded-card border border-border overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-elevated">
|
|
||||||
<tr className="text-left text-secondary">
|
|
||||||
<th className="px-4 py-2.5 font-medium">分组</th>
|
|
||||||
<th className="px-4 py-2.5 font-medium text-right">总收益</th>
|
|
||||||
<th className="px-4 py-2.5 font-medium text-right">年化</th>
|
|
||||||
<th className="px-4 py-2.5 font-medium text-right">最大回撤</th>
|
|
||||||
<th className="px-4 py-2.5 font-medium text-right">夏普</th>
|
|
||||||
<th className="px-4 py-2.5 font-medium text-right">胜率</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{result.group_stats.map((g: GroupStat) => (
|
|
||||||
<tr key={g.group} className="border-t border-border hover:bg-elevated/50 transition-colors">
|
|
||||||
<td className="px-4 py-2 text-sm font-medium">{g.label}</td>
|
|
||||||
<td className={`px-4 py-2 text-right num ${priceColorClass(g.total_return)}`}>
|
|
||||||
{fmtPct(g.total_return)}
|
|
||||||
</td>
|
|
||||||
<td className={`px-4 py-2 text-right num ${priceColorClass(g.annual_return)}`}>
|
|
||||||
{fmtPct(g.annual_return)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right num text-bear">{fmtPct(g.max_drawdown)}</td>
|
|
||||||
<td className="px-4 py-2 text-right num">{g.sharpe?.toFixed(2)}</td>
|
|
||||||
<td className="px-4 py-2 text-right num">{fmtPct(g.win_rate)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{/* 多空行 */}
|
|
||||||
{result.long_short_stats?.total_return != null && (
|
|
||||||
<tr className="border-t-2 border-accent/30 bg-accent/[0.03]">
|
|
||||||
<td className="px-4 py-2 text-sm font-medium text-accent">
|
|
||||||
多空({result.long_short_stats.top_group ?? ''}-{result.long_short_stats.bottom_group ?? ''})
|
|
||||||
</td>
|
|
||||||
<td className={`px-4 py-2 text-right num font-medium ${priceColorClass(result.long_short_stats.total_return)}`}>
|
|
||||||
{fmtPct(result.long_short_stats.total_return as number)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right num">—</td>
|
|
||||||
<td className="px-4 py-2 text-right num text-bear">
|
|
||||||
{fmtPct(result.long_short_stats.max_drawdown as number)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right num">—</td>
|
|
||||||
<td className="px-4 py-2 text-right num">—</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 数据概要 */}
|
|
||||||
<div className="flex items-center gap-4 text-[11px] text-muted">
|
|
||||||
<span>{result.n_symbols} 只标的</span>
|
|
||||||
<span>{result.n_dates} 个交易日</span>
|
|
||||||
<span>run_id: {result.run_id}</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import { useECharts } from './useECharts'
|
|
||||||
import type { FactorBacktestResult } from '@/lib/api'
|
|
||||||
|
|
||||||
const GROUP_COLORS = [
|
|
||||||
'#6366f1', // Q1 indigo
|
|
||||||
'#8b5cf6', // Q2 violet
|
|
||||||
'#f59e0b', // Q3 amber
|
|
||||||
'#f97316', // Q4 orange
|
|
||||||
'#ef4444', // Q5 red
|
|
||||||
'#ec4899', // Q6
|
|
||||||
'#14b8a6', // Q7
|
|
||||||
'#06b6d4', // Q8
|
|
||||||
'#84cc16', // Q9
|
|
||||||
'#a855f7', // Q10
|
|
||||||
]
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
result: FactorBacktestResult
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FactorGroupNavChart({ result }: Props) {
|
|
||||||
const option = useMemo(() => {
|
|
||||||
if (!result.group_nav.length) return null
|
|
||||||
|
|
||||||
const dates = result.group_nav.map(r => (r.date as string).slice(0, 10))
|
|
||||||
const groupCols = Object.keys(result.group_nav[0]).filter(k => k !== 'date').sort()
|
|
||||||
|
|
||||||
// 多空净值
|
|
||||||
const lsNav = result.long_short_nav
|
|
||||||
const hasLS = lsNav && lsNav.length > 0
|
|
||||||
|
|
||||||
const series = groupCols.map((col, i) => ({
|
|
||||||
name: col,
|
|
||||||
type: 'line',
|
|
||||||
data: result.group_nav.map(r => r[col]),
|
|
||||||
symbol: 'none',
|
|
||||||
lineStyle: { color: GROUP_COLORS[i % GROUP_COLORS.length], width: 1.5 } as any,
|
|
||||||
itemStyle: { color: GROUP_COLORS[i % GROUP_COLORS.length] } as any,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (hasLS) {
|
|
||||||
series.push({
|
|
||||||
name: '多空',
|
|
||||||
type: 'line',
|
|
||||||
data: lsNav.map(r => r.value),
|
|
||||||
symbol: 'none',
|
|
||||||
lineStyle: { color: '#fbbf24', width: 2, type: 'dashed' },
|
|
||||||
itemStyle: { color: '#fbbf24' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
animation: false,
|
|
||||||
legend: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
grid: { left: 56, right: 16, top: 12, bottom: 28 },
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
backgroundColor: 'rgba(15,23,42,0.95)',
|
|
||||||
borderColor: 'rgba(148,163,184,0.2)',
|
|
||||||
textStyle: { color: '#e2e8f0', fontSize: 12 },
|
|
||||||
formatter: (params: any) => {
|
|
||||||
const date = params[0]?.axisValue ?? ''
|
|
||||||
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
|
|
||||||
for (const p of params) {
|
|
||||||
if (p.value == null) continue
|
|
||||||
html += `<div style="display:flex;justify-content:space-between;gap:16px">
|
|
||||||
<span style="display:flex;align-items:center;gap:4px">
|
|
||||||
<span style="width:8px;height:3px;border-radius:1px;background:${p.color};display:inline-block"></span>
|
|
||||||
${p.seriesName}
|
|
||||||
</span>
|
|
||||||
<span style="font-family:monospace">${(p.value as number).toFixed(4)}</span>
|
|
||||||
</div>`
|
|
||||||
}
|
|
||||||
return html
|
|
||||||
},
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: dates,
|
|
||||||
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
|
|
||||||
axisLine: { lineStyle: { color: '#334155' } },
|
|
||||||
axisTick: { show: false },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
scale: true,
|
|
||||||
axisLabel: { color: '#64748b', fontSize: 10 },
|
|
||||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
|
||||||
axisLine: { show: false },
|
|
||||||
},
|
|
||||||
series,
|
|
||||||
} as any
|
|
||||||
}, [result.group_nav, result.long_short_nav, result.run_id])
|
|
||||||
|
|
||||||
const chartRef = useECharts(option, [result.run_id])
|
|
||||||
|
|
||||||
// 图例
|
|
||||||
const groupCols = result.group_nav.length > 0
|
|
||||||
? Object.keys(result.group_nav[0]).filter(k => k !== 'date').sort()
|
|
||||||
: []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 px-4 pb-2">
|
|
||||||
{groupCols.map((col, i) => (
|
|
||||||
<span key={col} className="flex items-center gap-1 text-[10px] text-secondary">
|
|
||||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: GROUP_COLORS[i % GROUP_COLORS.length] }} />
|
|
||||||
{col}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{result.long_short_nav?.length > 0 && (
|
|
||||||
<span className="flex items-center gap-1 text-[10px] text-secondary">
|
|
||||||
<span className="w-2 h-0.5 rounded bg-yellow-400" style={{ borderTop: '2px dashed #fbbf24' }} />
|
|
||||||
多空
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div ref={chartRef} className="h-[280px]" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import { useECharts } from './useECharts'
|
|
||||||
import type { FactorBacktestResult } from '@/lib/api'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
result: FactorBacktestResult
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FactorICChart({ result }: Props) {
|
|
||||||
const option = useMemo(() => {
|
|
||||||
if (!result.ic_series.length) return null
|
|
||||||
|
|
||||||
const dates = result.ic_series.map(r => r.date.slice(0, 10))
|
|
||||||
const values = result.ic_series.map(r => r.ic)
|
|
||||||
|
|
||||||
// 12期移动平均
|
|
||||||
const maWindow = 12
|
|
||||||
const ma: (number | null)[] = values.map((_, i) => {
|
|
||||||
if (i < maWindow - 1) return null
|
|
||||||
const slice = values.slice(i - maWindow + 1, i + 1)
|
|
||||||
return slice.reduce((a, b) => a + b, 0) / slice.length
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
animation: false,
|
|
||||||
grid: { left: 50, right: 16, top: 16, bottom: 28 },
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
backgroundColor: 'rgba(15,23,42,0.95)',
|
|
||||||
borderColor: 'rgba(148,163,184,0.2)',
|
|
||||||
textStyle: { color: '#e2e8f0', fontSize: 12 },
|
|
||||||
formatter: (params: any) => {
|
|
||||||
const date = params[0]?.axisValue ?? ''
|
|
||||||
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
|
|
||||||
for (const p of params) {
|
|
||||||
if (p.value == null) continue
|
|
||||||
html += `<div style="display:flex;justify-content:space-between;gap:16px">
|
|
||||||
<span style="color:${p.color}">${p.seriesName}</span>
|
|
||||||
<span style="font-family:monospace">${(p.value * 100).toFixed(2)}%</span>
|
|
||||||
</div>`
|
|
||||||
}
|
|
||||||
return html
|
|
||||||
},
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: dates,
|
|
||||||
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
|
|
||||||
axisLine: { lineStyle: { color: '#334155' } },
|
|
||||||
axisTick: { show: false },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
axisLabel: { color: '#64748b', fontSize: 10, formatter: (v: number) => `${(v * 100).toFixed(0)}%` },
|
|
||||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
|
||||||
axisLine: { show: false },
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'IC',
|
|
||||||
type: 'bar',
|
|
||||||
data: values.map(v => ({
|
|
||||||
value: v,
|
|
||||||
itemStyle: {
|
|
||||||
color: v >= 0
|
|
||||||
? 'rgba(240,68,56,0.6)'
|
|
||||||
: 'rgba(18,183,106,0.6)',
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
barMaxWidth: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: `MA${maWindow}`,
|
|
||||||
type: 'line',
|
|
||||||
data: ma,
|
|
||||||
smooth: true,
|
|
||||||
symbol: 'none',
|
|
||||||
lineStyle: { color: '#f59e0b', width: 1.5 },
|
|
||||||
z: 10,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any
|
|
||||||
}, [result.ic_series])
|
|
||||||
|
|
||||||
const chartRef = useECharts(option, [result.run_id])
|
|
||||||
|
|
||||||
return <div ref={chartRef} className="h-[200px]" />
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import { useECharts } from './useECharts'
|
|
||||||
import type { EChartsOption } from 'echarts'
|
|
||||||
|
|
||||||
interface DistBin {
|
|
||||||
range: string
|
|
||||||
count: number
|
|
||||||
ratio: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 收益分布直方图 — 全量模拟专用的候选标的收益分布。
|
|
||||||
* 柱子颜色按收益正负区分(正红负绿),零轴居中。
|
|
||||||
*/
|
|
||||||
export function ReturnDistributionChart({ distribution }: { distribution: DistBin[] }) {
|
|
||||||
const option = useMemo<EChartsOption>(() => {
|
|
||||||
const cats = distribution.map(d => d.range)
|
|
||||||
const vals = distribution.map(d => d.count)
|
|
||||||
// 判断每档是正还是负(按 range 字符串首字符 +/~)
|
|
||||||
const colors = distribution.map(d => {
|
|
||||||
const lo = parseFloat(d.range)
|
|
||||||
// 中心档(跨 0) 用中性色
|
|
||||||
if (lo < 0 && parseFloat(d.range.split('~')[1]) > 0) return '#a1a1aa'
|
|
||||||
return lo >= 0 ? '#ef4444' : '#22c55e'
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
grid: { left: 48, right: 16, top: 24, bottom: 56 },
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: { type: 'shadow' },
|
|
||||||
formatter: (params: any) => {
|
|
||||||
const p = Array.isArray(params) ? params[0] : params
|
|
||||||
const bin = distribution[p.dataIndex]
|
|
||||||
if (!bin) return ''
|
|
||||||
return `${bin.range}<br/>数量: ${bin.count}<br/>占比: ${(bin.ratio * 100).toFixed(1)}%`
|
|
||||||
},
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: cats,
|
|
||||||
axisLabel: { color: '#a1a1aa', fontSize: 10, rotate: 45, interval: 1 },
|
|
||||||
axisLine: { lineStyle: { color: '#3f3f46' } },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
axisLabel: { color: '#a1a1aa', fontSize: 10 },
|
|
||||||
splitLine: { lineStyle: { color: '#27272a' } },
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'bar',
|
|
||||||
data: vals.map((v, i) => ({ value: v, itemStyle: { color: colors[i] } })),
|
|
||||||
barWidth: '90%',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}, [distribution])
|
|
||||||
|
|
||||||
const chartRef = useECharts(option, [distribution])
|
|
||||||
|
|
||||||
return <div ref={chartRef} className="h-48 w-full" />
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import { useECharts } from './useECharts'
|
|
||||||
import type { StrategyBacktestResult } from '@/lib/api'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
result: StrategyBacktestResult
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StrategyNavChart({ result }: Props) {
|
|
||||||
const option = useMemo(() => {
|
|
||||||
if (!result.equity_curve.length) return null
|
|
||||||
|
|
||||||
const moneyFmt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
|
||||||
const valueFmt = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
||||||
const axisMoneyFmt = (v: number) => {
|
|
||||||
if (Math.abs(v) >= 100_000_000) return `${(v / 100_000_000).toFixed(1)}亿`
|
|
||||||
if (Math.abs(v) >= 10_000) return `${(v / 10_000).toFixed(0)}万`
|
|
||||||
return moneyFmt.format(v)
|
|
||||||
}
|
|
||||||
const dates = result.equity_curve.map(r => r.date.slice(0, 10))
|
|
||||||
const navValues = result.equity_curve.map(r => r.value)
|
|
||||||
const benchmarkByDate = new Map((result.benchmark_curve ?? []).map(r => [r.date.slice(0, 10), r.close ?? r.value]))
|
|
||||||
const benchmarkValues = dates.map(d => benchmarkByDate.get(d) ?? null)
|
|
||||||
const hasBenchmark = benchmarkValues.some(v => v != null)
|
|
||||||
const ddValues = result.drawdown_curve.map(r => r.value * 100)
|
|
||||||
|
|
||||||
return {
|
|
||||||
animation: false,
|
|
||||||
axisPointer: {
|
|
||||||
link: [{ xAxisIndex: 'all' }],
|
|
||||||
label: { backgroundColor: '#334155' },
|
|
||||||
},
|
|
||||||
grid: [
|
|
||||||
{ left: 64, right: hasBenchmark ? 64 : 16, top: 14, bottom: '40%' },
|
|
||||||
{ left: 64, right: hasBenchmark ? 64 : 16, top: '68%', bottom: 46 },
|
|
||||||
],
|
|
||||||
xAxis: [
|
|
||||||
{
|
|
||||||
type: 'category', data: dates, gridIndex: 0,
|
|
||||||
axisLabel: { show: false }, axisTick: { show: false },
|
|
||||||
axisPointer: { show: true, type: 'line' },
|
|
||||||
axisLine: { lineStyle: { color: '#334155' } },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'category', data: dates, gridIndex: 1,
|
|
||||||
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
|
|
||||||
axisTick: { show: false },
|
|
||||||
axisPointer: { show: true, type: 'line' },
|
|
||||||
axisLine: { lineStyle: { color: '#334155' } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value', gridIndex: 0,
|
|
||||||
scale: true,
|
|
||||||
name: hasBenchmark ? '上证点位' : '策略资金',
|
|
||||||
nameTextStyle: { color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
|
|
||||||
axisLabel: {
|
|
||||||
color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b',
|
|
||||||
fontSize: 10,
|
|
||||||
formatter: hasBenchmark ? ((v: number) => v.toFixed(0)) : axisMoneyFmt,
|
|
||||||
},
|
|
||||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
|
||||||
axisLine: { show: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'value', gridIndex: 0,
|
|
||||||
position: 'right',
|
|
||||||
scale: true,
|
|
||||||
name: hasBenchmark ? '策略资金' : '',
|
|
||||||
nameTextStyle: { color: '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
|
|
||||||
axisLabel: {
|
|
||||||
show: hasBenchmark,
|
|
||||||
color: '#64748b',
|
|
||||||
fontSize: 10,
|
|
||||||
formatter: axisMoneyFmt,
|
|
||||||
},
|
|
||||||
splitLine: { show: false },
|
|
||||||
axisLine: { show: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'value', gridIndex: 1,
|
|
||||||
position: 'right',
|
|
||||||
max: 0,
|
|
||||||
axisLabel: {
|
|
||||||
color: '#64748b', fontSize: 10,
|
|
||||||
formatter: (v: number) => `${v.toFixed(1)}%`,
|
|
||||||
},
|
|
||||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
|
||||||
axisLine: { show: false },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dataZoom: [
|
|
||||||
{
|
|
||||||
type: 'inside',
|
|
||||||
xAxisIndex: [0, 1],
|
|
||||||
filterMode: 'filter',
|
|
||||||
zoomOnMouseWheel: true,
|
|
||||||
moveOnMouseMove: true,
|
|
||||||
moveOnMouseWheel: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'slider',
|
|
||||||
xAxisIndex: [0, 1],
|
|
||||||
filterMode: 'filter',
|
|
||||||
height: 16,
|
|
||||||
bottom: 10,
|
|
||||||
borderColor: 'rgba(148,163,184,0.18)',
|
|
||||||
backgroundColor: 'rgba(15,23,42,0.55)',
|
|
||||||
fillerColor: 'rgba(59,130,246,0.18)',
|
|
||||||
handleStyle: { color: '#64748b', borderColor: '#94a3b8' },
|
|
||||||
textStyle: { color: '#64748b', fontSize: 10 },
|
|
||||||
brushSelect: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
backgroundColor: 'rgba(15,23,42,0.95)',
|
|
||||||
borderColor: 'rgba(148,163,184,0.2)',
|
|
||||||
textStyle: { color: '#e2e8f0', fontSize: 12 },
|
|
||||||
formatter: (params: any) => {
|
|
||||||
const date = params[0]?.axisValue ?? ''
|
|
||||||
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
|
|
||||||
for (const p of params) {
|
|
||||||
if (p.value == null) continue
|
|
||||||
const isDrawdown = p.seriesName === '回撤'
|
|
||||||
const isBenchmark = p.seriesName === '同期上证指数'
|
|
||||||
html += `<div style="display:flex;justify-content:space-between;gap:16px">
|
|
||||||
<span style="color:${p.color}">${p.seriesName}</span>
|
|
||||||
<span style="font-family:monospace">${
|
|
||||||
isDrawdown
|
|
||||||
? `${(p.value as number).toFixed(2)}%`
|
|
||||||
: isBenchmark
|
|
||||||
? `${valueFmt.format(p.value as number)} 点`
|
|
||||||
: moneyFmt.format(p.value as number)
|
|
||||||
}</span>
|
|
||||||
</div>`
|
|
||||||
}
|
|
||||||
return html
|
|
||||||
},
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: '净值',
|
|
||||||
type: 'line',
|
|
||||||
xAxisIndex: 0,
|
|
||||||
yAxisIndex: hasBenchmark ? 1 : 0,
|
|
||||||
data: navValues,
|
|
||||||
symbol: 'none',
|
|
||||||
lineStyle: { color: '#3b82f6', width: 2.2 },
|
|
||||||
areaStyle: {
|
|
||||||
color: {
|
|
||||||
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
|
||||||
colorStops: [
|
|
||||||
{ offset: 0, color: 'rgba(59,130,246,0.15)' },
|
|
||||||
{ offset: 1, color: 'rgba(59,130,246,0.01)' },
|
|
||||||
],
|
|
||||||
} as any,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(hasBenchmark ? [{
|
|
||||||
name: '同期上证指数',
|
|
||||||
type: 'line',
|
|
||||||
xAxisIndex: 0,
|
|
||||||
yAxisIndex: 0,
|
|
||||||
data: benchmarkValues,
|
|
||||||
symbol: 'none',
|
|
||||||
connectNulls: true,
|
|
||||||
lineStyle: { color: 'rgba(148,163,184,0.45)', width: 1, type: 'dashed' },
|
|
||||||
}] : []),
|
|
||||||
{
|
|
||||||
name: '回撤',
|
|
||||||
type: 'line',
|
|
||||||
xAxisIndex: 1,
|
|
||||||
yAxisIndex: 2,
|
|
||||||
data: ddValues,
|
|
||||||
symbol: 'none',
|
|
||||||
lineStyle: { color: 'rgba(240,68,56,0.6)', width: 1 },
|
|
||||||
areaStyle: { color: 'rgba(240,68,56,0.12)' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any
|
|
||||||
}, [result.equity_curve, result.drawdown_curve, result.benchmark_curve, result.run_id])
|
|
||||||
|
|
||||||
const chartRef = useECharts(option, [result.run_id])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-wrap items-center gap-4 px-4 pb-2">
|
|
||||||
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
|
|
||||||
<span className="w-3 h-0.5 rounded bg-accent" />
|
|
||||||
策略净值
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
|
|
||||||
<span className="w-3 h-0.5 rounded bg-red-400/60" />
|
|
||||||
回撤
|
|
||||||
</span>
|
|
||||||
{(result.benchmark_curve?.length ?? 0) > 0 && (
|
|
||||||
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
|
|
||||||
<span className="w-3 h-0.5 rounded border-t border-dashed border-slate-400/60" />
|
|
||||||
同期上证指数
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="ml-auto text-[10px] text-muted">滚轮缩放 · 拖动平移</span>
|
|
||||||
</div>
|
|
||||||
<div ref={chartRef} className="h-[282px]" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import * as echarts from 'echarts'
|
|
||||||
import type { ECharts, EChartsOption } from 'echarts'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ECharts 实例管理 Hook — 自动初始化/resize/销毁。
|
|
||||||
* 返回 ref 绑定到容器 div,和 setOption 方法。
|
|
||||||
*/
|
|
||||||
export function useECharts(
|
|
||||||
option: EChartsOption | null,
|
|
||||||
deps: any[] = [],
|
|
||||||
) {
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const instanceRef = useRef<ECharts | null>(null)
|
|
||||||
|
|
||||||
// 初始化 / 销毁
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chartRef.current) return
|
|
||||||
instanceRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
|
|
||||||
const handleResize = () => instanceRef.current?.resize()
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', handleResize)
|
|
||||||
instanceRef.current?.dispose()
|
|
||||||
instanceRef.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 更新 option
|
|
||||||
useEffect(() => {
|
|
||||||
if (!instanceRef.current || !option) return
|
|
||||||
instanceRef.current.setOption(option, { notMerge: true })
|
|
||||||
}, [option, ...deps])
|
|
||||||
|
|
||||||
return chartRef
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
|
||||||
import { Clock, X } from 'lucide-react'
|
|
||||||
import { StockPanel } from '@/components/StockPanel'
|
|
||||||
import type { ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
|
|
||||||
import type { StrategyBacktestTrade } from '@/lib/api'
|
|
||||||
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
trade: StrategyBacktestTrade | null
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDays(date: string, days: number): string {
|
|
||||||
const d = new Date(date)
|
|
||||||
d.setDate(d.getDate() + days)
|
|
||||||
return d.toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtMoney(v: number | null | undefined): string {
|
|
||||||
if (v == null || Number.isNaN(Number(v))) return '—'
|
|
||||||
const n = Number(v)
|
|
||||||
const abs = Math.abs(n)
|
|
||||||
if (abs >= 100_000_000) return `${(n / 100_000_000).toFixed(2)}亿`
|
|
||||||
if (abs >= 10_000) return `${(n / 10_000).toFixed(2)}万`
|
|
||||||
return n.toFixed(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtSignedMoney(v: number | null | undefined): string {
|
|
||||||
if (v == null || Number.isNaN(Number(v))) return '—'
|
|
||||||
const prefix = Number(v) > 0 ? '+' : ''
|
|
||||||
return `${prefix}${fmtMoney(v)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TradeKlineModal({ trade, onClose }: Props) {
|
|
||||||
const [showIntraday, setShowIntraday] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!trade) return
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') onClose()
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handler)
|
|
||||||
return () => document.removeEventListener('keydown', handler)
|
|
||||||
}, [trade, onClose])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (trade) setShowIntraday(false)
|
|
||||||
}, [trade])
|
|
||||||
|
|
||||||
const dateRange = useMemo(() => {
|
|
||||||
if (!trade) return null
|
|
||||||
return {
|
|
||||||
start: addDays(String(trade.entry_date).slice(0, 10), -45),
|
|
||||||
end: addDays(String(trade.exit_date).slice(0, 10), 20),
|
|
||||||
}
|
|
||||||
}, [trade])
|
|
||||||
|
|
||||||
const ranges = useMemo<ChartRange[]>(() => {
|
|
||||||
if (!trade) return []
|
|
||||||
return [{
|
|
||||||
start: String(trade.entry_date).slice(0, 10),
|
|
||||||
end: String(trade.exit_date).slice(0, 10),
|
|
||||||
label: '持仓区间',
|
|
||||||
color: 'rgba(59,130,246,0.07)',
|
|
||||||
}]
|
|
||||||
}, [trade])
|
|
||||||
|
|
||||||
const priceLines = useMemo<ChartPriceLine[]>(() => {
|
|
||||||
if (!trade) return []
|
|
||||||
const start = String(trade.entry_date).slice(0, 10)
|
|
||||||
const end = String(trade.exit_date).slice(0, 10)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
value: Number(trade.entry_price),
|
|
||||||
label: `买入价 ${fmtPrice(trade.entry_price)}`,
|
|
||||||
color: '#C74040',
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: Number(trade.exit_price),
|
|
||||||
label: `卖出价 ${fmtPrice(trade.exit_price)}`,
|
|
||||||
color: '#2D9B65',
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}, [trade])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{trade && dateRange && (
|
|
||||||
<div className="fixed inset-0 z-[70] flex items-center justify-center">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
|
||||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="relative flex max-h-[94vh] w-[92vw] max-w-[1120px] flex-col overflow-hidden rounded-card border border-border bg-base shadow-2xl"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-mono text-sm font-semibold text-foreground">{trade.symbol}</span>
|
|
||||||
<span className="truncate text-sm text-foreground">{trade.name || '交易回放'}</span>
|
|
||||||
<span className="rounded border border-accent/30 bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">交易回放</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-[11px] text-muted">
|
|
||||||
{String(trade.entry_date).slice(0, 10)} 买入 → {String(trade.exit_date).slice(0, 10)} 卖出 · 持仓 {trade.duration ?? '—'} 天
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-3 text-xs">
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-muted">买 / 卖</div>
|
|
||||||
<div className="num text-foreground">{fmtPrice(trade.entry_price)} / {fmtPrice(trade.exit_price)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-muted">盈亏</div>
|
|
||||||
<div className={`num font-semibold ${priceColorClass(trade.pnl_amount ?? trade.pnl_pct)}`}>
|
|
||||||
{fmtSignedMoney(trade.pnl_amount)} / {fmtPct(trade.pnl_pct)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowIntraday((v) => !v)}
|
|
||||||
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
|
|
||||||
showIntraday
|
|
||||||
? 'border border-accent/30 bg-accent/15 text-accent'
|
|
||||||
: 'border border-border bg-elevated text-secondary hover:border-accent/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
分时
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-btn p-1 text-secondary transition-colors hover:bg-elevated hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-4">
|
|
||||||
<StockPanel
|
|
||||||
symbol={trade.symbol}
|
|
||||||
height={520}
|
|
||||||
dateRange={dateRange}
|
|
||||||
ranges={ranges}
|
|
||||||
priceLines={priceLines}
|
|
||||||
showLimitMarkers={false}
|
|
||||||
showMarkerToggle={false}
|
|
||||||
showIntraday={showIntraday}
|
|
||||||
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -32,9 +32,7 @@ interface NavEntry {
|
|||||||
|
|
||||||
const BUILTIN_PAGES: NavEntry[] = [
|
const BUILTIN_PAGES: NavEntry[] = [
|
||||||
{ id: '/', label: '看板', type: 'builtin', visible: true },
|
{ id: '/', label: '看板', type: 'builtin', visible: true },
|
||||||
{ id: '/watchlist', label: '自选', type: 'builtin', visible: true },
|
|
||||||
{ id: '/screener', label: '策略', type: 'builtin', visible: true },
|
{ id: '/screener', label: '策略', type: 'builtin', visible: true },
|
||||||
{ id: '/backtest', label: '回测', type: 'builtin', visible: true },
|
|
||||||
{ id: '/limit-ladder', label: '连板梯队', type: 'builtin', visible: true },
|
{ id: '/limit-ladder', label: '连板梯队', type: 'builtin', visible: true },
|
||||||
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
|
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
|
||||||
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
|
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||||
import { Layout } from './components/Layout'
|
import { Layout } from './components/Layout'
|
||||||
import { Watchlist } from './pages/Watchlist'
|
|
||||||
import { Screener } from './pages/Screener'
|
import { Screener } from './pages/Screener'
|
||||||
import { Backtest } from './pages/Backtest'
|
|
||||||
import { Financials } from './pages/Financials'
|
import { Financials } from './pages/Financials'
|
||||||
import { Onboarding } from './pages/Onboarding'
|
import { Onboarding } from './pages/Onboarding'
|
||||||
import { Auth } from './pages/Auth'
|
import { Auth } from './pages/Auth'
|
||||||
@@ -70,9 +68,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'industry-analysis', element: <IndustryAnalysis /> },
|
{ path: 'industry-analysis', element: <IndustryAnalysis /> },
|
||||||
{ path: 'stock-analysis', element: <StockAnalysis /> },
|
{ path: 'stock-analysis', element: <StockAnalysis /> },
|
||||||
{ path: 'review', element: <Review /> },
|
{ path: 'review', element: <Review /> },
|
||||||
{ path: 'watchlist', element: <Watchlist /> },
|
|
||||||
{ path: 'screener', element: <Screener /> },
|
{ path: 'screener', element: <Screener /> },
|
||||||
{ path: 'backtest', element: <Backtest /> },
|
|
||||||
{ path: 'financials', element: <Financials /> },
|
{ path: 'financials', element: <Financials /> },
|
||||||
{ path: 'data', element: <Data /> },
|
{ path: 'data', element: <Data /> },
|
||||||
{ path: 'monitor', element: <Monitor /> },
|
{ path: 'monitor', element: <Monitor /> },
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 653 KiB |
Reference in New Issue
Block a user