diff --git a/serve/backend/app/api/backtest.py b/serve/backend/app/api/backtest.py deleted file mode 100644 index 0fe0736..0000000 --- a/serve/backend/app/api/backtest.py +++ /dev/null @@ -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": "任务不存在或已完成"} - diff --git a/serve/backend/app/api/data.py b/serve/backend/app/api/data.py index 2d78a19..d100eac 100644 --- a/serve/backend/app/api/data.py +++ b/serve/backend/app/api/data.py @@ -505,7 +505,7 @@ def _compute_storage(data_dir: Path) -> dict: stats[f"{key}_size_mb"] = sz # total: 再加上其他零散文件(pools, financials, capabilities.json 等) - other_dirs = ["pools", "financials", "backtest_results", "screener_results", "ai_cache"] + other_dirs = ["pools", "financials", "screener_results", "ai_cache"] for name in other_dirs: d = data_dir / name if d.exists(): @@ -629,7 +629,7 @@ def clear_data(request: Request): "kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched", "kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute", "adj_factor", "adj_factor_etf", "instruments", "instruments_index", "instruments_etf", "pools", "financials", - "backtest_results", "screener_results", "ai_cache", + "screener_results", "ai_cache", ): d = data_dir / sub if d.exists(): diff --git a/serve/backend/app/api/kline.py b/serve/backend/app/api/kline.py index 8e09ad9..eded068 100644 --- a/serve/backend/app/api/kline.py +++ b/serve/backend/app/api/kline.py @@ -150,7 +150,7 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di """按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。 key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。 - JOIN 逻辑参考 watchlist.watchlist_enriched;任何 ext 表/字段缺失都静默跳过。 + JOIN 逻辑;任何 ext 表/字段缺失都静默跳过。 """ if not ext_columns or not ext_columns.strip(): return resp @@ -380,7 +380,7 @@ async def sync_minute(request: Request): try: progress("sync_minute", 5, "解析标的池…") - universe = sorted(set(get_pool("watchlist")) | set(get_pool("CN_Equity_A"))) + universe = sorted(set(get_pool("CN_Equity_A"))) # 补充 instruments 全量标的,覆盖北交所、新股等 inst_path = repo.store.data_dir / "instruments" / "instruments.parquet" if inst_path.exists(): diff --git a/serve/backend/app/api/settings.py b/serve/backend/app/api/settings.py index 1139af4..ea7ea57 100644 --- a/serve/backend/app/api/settings.py +++ b/serve/backend/app/api/settings.py @@ -332,7 +332,6 @@ def get_preferences() -> dict: "instruments_schedule": preferences.get_instruments_schedule(), "enriched_batch_size": preferences.get_enriched_batch_size(), "index_daily_batch_size": preferences.get_index_daily_batch_size(), - "watchlist_columns": preferences.get_watchlist_columns(), "screener_result_columns": preferences.get_screener_result_columns(), "sidebar_index_symbols": preferences.get_sidebar_index_symbols(), "nav_order": preferences.get_nav_order(), @@ -343,14 +342,6 @@ def get_preferences() -> dict: } -@router.get("/preferences/watchlist-columns") -def get_watchlist_columns() -> dict: - """返回自选列表列配置。""" - from app.services import preferences - cols = preferences.get_watchlist_columns() - return {"columns": cols} - - class NavOrderIn(BaseModel): nav_order: list[str] @@ -375,15 +366,6 @@ def update_nav_hidden(req: NavHiddenIn) -> dict: return {"nav_hidden": saved} -@router.put("/preferences/watchlist-columns") -def update_watchlist_columns(req: dict) -> dict: - """保存自选列表列配置。""" - from app.services import preferences - columns = req.get("columns", []) - saved = preferences.set_watchlist_columns(columns) - return {"columns": saved} - - @router.get("/preferences/screener-result-columns") def get_screener_result_columns() -> dict: """返回策略结果列表列配置。""" diff --git a/serve/backend/app/api/watchlist.py b/serve/backend/app/api/watchlist.py deleted file mode 100644 index 81fc34d..0000000 --- a/serve/backend/app/api/watchlist.py +++ /dev/null @@ -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 diff --git a/serve/backend/app/backtest/__init__.py b/serve/backend/app/backtest/__init__.py deleted file mode 100644 index adfd49b..0000000 --- a/serve/backend/app/backtest/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""回测模块 — 因子回测 + 策略回测 + 信号回测。 - -架构: BacktestEngine (共享) → FactorBacktestService / StrategyBacktestService -""" diff --git a/serve/backend/app/backtest/engine.py b/serve/backend/app/backtest/engine.py deleted file mode 100644 index 943490c..0000000 --- a/serve/backend/app/backtest/engine.py +++ /dev/null @@ -1,1532 +0,0 @@ -"""回测引擎 — 共享数据加载 + 撮合 + 统计计算。 - -纯 Polars/NumPy 实现,不依赖 pandas/vectorbt。 -""" -from __future__ import annotations - -import hashlib -import logging -import time -from collections import OrderedDict -from dataclasses import dataclass -from datetime import date -from typing import Callable - -logger = logging.getLogger(__name__) -from typing import Literal - -import numpy as np -import polars as pl - -from app.tickflow.repository import KlineRepository - -logger = logging.getLogger(__name__) - - -# ================================================================ -# 数据结构 -# ================================================================ - -@dataclass -class MatcherConfig: - # matching 为向后兼容入口: 仅传 matching 时, entry_fill/exit_fill 都取 matching 的值。 - # 显式传入 entry_fill/exit_fill 时以二者为准 (允许建仓/清仓口径不同)。 - matching: Literal["close_t", "open_t+1"] = "close_t" - 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 - stop_loss_pct: float | None = None - take_profit_pct: float | None = None - trailing_stop_pct: float | None = None - trailing_take_profit_activate_pct: float | None = None - trailing_take_profit_drawdown_pct: float | None = None - max_hold_days: int | None = None - max_positions: int = 10 - max_exposure_pct: float = 1.0 - score_min: float | None = None - score_max: float | None = None - initial_capital: float = 1_000_000.0 - position_sizing: Literal["equal", "score_weight"] = "equal" - - def __post_init__(self) -> None: - # 解析最终口径: 优先 entry_fill/exit_fill, 否则回退到 matching (向后兼容)。 - if self.entry_fill is None: - self.entry_fill = self.matching - if self.exit_fill is None: - self.exit_fill = self.matching - - -@dataclass -class TradeRecord: - symbol: str - entry_date: date - exit_date: date - entry_price: float - exit_price: float - pnl_pct: float - duration: int - exit_reason: str # "signal" | "stop_loss" | "take_profit" | "trailing_stop" | "trailing_take_profit" | "max_hold" | "end" - # 退出优先级 (高→低): pending_exit(历史挂单) > 风控(止损/移动止损/移动止盈) > signal(卖点) > max_hold(到期) > end - name: str = "" - shares: float = 0.0 - lots: float = 0.0 - position_pct: float = 0.0 - entry_value: float = 0.0 - exit_value: float = 0.0 - pnl_amount: float = 0.0 - entry_score: float | None = None - entry_signal_date: date | str | None = None - exit_signal_date: date | str | None = None - blocked_exit_days: int = 0 - - -@dataclass -class SimResult: - equity_curve: list[dict] # [{date, value}] - drawdown_curve: list[dict] # [{date, value}] - trades: list[TradeRecord] - per_symbol_stats: list[dict] - stats: dict - - -# ================================================================ -# PanelCache — 避免重复 scan_parquet + compute_all -# ================================================================ - -class _CacheEntry: - __slots__ = ("df", "ts") - - def __init__(self, df: pl.DataFrame, ts: float): - self.df = df - self.ts = ts - - -class PanelCache: - """LRU + TTL 数据面板缓存。""" - - def __init__(self, max_size: int = 2, ttl_seconds: int = 180): - self._cache: OrderedDict[str, _CacheEntry] = OrderedDict() - self._max_size = max_size - self._ttl = ttl_seconds - - def get_or_compute( - self, - symbols: list[str] | None, - start: date, - end: date, - columns: list[str] | None, - compute_fn, - ) -> pl.DataFrame: - key = self._make_key(symbols, start, end, columns) - now = time.monotonic() - - if key in self._cache: - entry = self._cache[key] - if now - entry.ts < self._ttl: - self._cache.move_to_end(key) - return entry.df - del self._cache[key] - - df = compute_fn(symbols, start, end, columns) - self._cache[key] = _CacheEntry(df=df, ts=now) - if len(self._cache) > self._max_size: - self._cache.popitem(last=False) - return df - - def invalidate(self) -> None: - self._cache.clear() - - @staticmethod - def _make_key(symbols: list[str] | None, start: date, end: date, columns: list[str] | None) -> str: - if symbols is None: - h = "all" - else: - h = hashlib.md5(",".join(sorted(symbols)).encode()).hexdigest()[:12] - cols = "all" if columns is None else hashlib.md5(",".join(sorted(columns)).encode()).hexdigest()[:8] - return f"{h}:{start}:{end}:{cols}" - - -# ================================================================ -# BacktestEngine -# ================================================================ - -class BacktestEngine: - """回测引擎 — 数据加载 + 撮合模拟 + 统计计算。""" - - def __init__(self, repo: KlineRepository) -> None: - self.repo = repo - self._cache = PanelCache() - - # ── 数据加载 ────────────────────────────────────── - - def load_panel( - self, - symbols: list[str] | None, - start: date, - end: date, - columns: list[str] | None = None, - ) -> pl.DataFrame: - """加载 enriched 数据面板,带缓存。""" - return self._cache.get_or_compute(symbols, start, end, columns, self._load_panel_inner) - - def _load_panel_inner( - self, - symbols: list[str] | None, - start: date, - end: date, - columns: list[str] | None = None, - ) -> pl.DataFrame: - t0 = time.perf_counter() - - # 近期区间优先复用 repository 的预计算 enriched 历史缓存,避免重复 scan_parquet + compute_all。 - try: - if self.repo is not None and hasattr(self.repo, "get_enriched_range"): - cached = self.repo.get_enriched_range(start, end, symbols=symbols, columns=columns) - if cached is not None and not cached.is_empty(): - elapsed = (time.perf_counter() - t0) * 1000 - logger.info("load_panel(cache): %.0fms, %d rows, %d columns", elapsed, len(cached), len(cached.columns)) - return cached - except Exception as e: # noqa: BLE001 - logger.debug("backtest load panel cache miss: %s", e) - - enriched_glob = str(self.repo.store.data_dir / "kline_daily_enriched" / "**" / "*.parquet") - - try: - lf = pl.scan_parquet(enriched_glob) - if symbols is not None: - lf = lf.filter(pl.col("symbol").is_in(symbols)) - if columns is not None: - available = set(lf.collect_schema().names()) - selected = [c for c in columns if c in available] - if "symbol" not in selected and "symbol" in available: - selected.insert(0, "symbol") - if "date" not in selected and "date" in available: - selected.insert(1, "date") - lf = lf.select(selected) - df = ( - lf.filter( - (pl.col("date") >= start) - & (pl.col("date") <= end) - ) - .sort(["symbol", "date"]) - .collect(streaming=True) - ) - except Exception as e: - logger.warning("backtest load panel failed: %s", e) - return pl.DataFrame() - - if df.is_empty(): - return df - - if columns is not None: - elapsed = (time.perf_counter() - t0) * 1000 - logger.info("load_panel: %.0fms, %d rows, %d columns", elapsed, len(df), len(df.columns)) - return df - - from app.indicators.pipeline import compute_all - instruments = self.repo.get_instruments() - df = compute_all(df, instruments=instruments) - if not instruments.is_empty() and "name" not in df.columns: - inst_cols = [c for c in ["symbol", "name"] if c in instruments.columns] - if len(inst_cols) == 2: - df = df.join( - instruments.select(inst_cols).unique(subset=["symbol"]), - on="symbol", - how="left", - ) - - elapsed = (time.perf_counter() - t0) * 1000 - logger.info("load_panel: %.0fms, %d rows", elapsed, len(df)) - return df - - # ── 撮合模拟 ────────────────────────────────────── - - def simulate( - self, - panel: pl.DataFrame, - entries: pl.Series | None, - exits: pl.Series | None, - config: MatcherConfig, - ) -> SimResult: - """纯 NumPy 撮合模拟 — 逐 symbol 状态机。""" - if panel.is_empty(): - return self._empty_result() - - n = len(panel) - panel_dates = panel["date"].to_numpy() - panel_symbols = panel["symbol"].to_numpy() - - # 构建信号数组 - ent = np.zeros(n, dtype=bool) - ext = np.zeros(n, dtype=bool) - if entries is not None and len(entries) == n: - ent = entries.to_numpy().astype(bool) - if exits is not None and len(exits) == n: - ext = exits.to_numpy().astype(bool) - - if not ent.any(): - return self._empty_result() - - # 成交口径: entry/exit 可分别配置 close_t (信号当日收盘) 或 open_t+1 (次日开盘)。 - # open_t+1 时信号右移 1 天 (用前一根的信号 + 当根的 open 成交)。 - open_prices = panel["open"].to_numpy() - close_prices = panel["close"].to_numpy() - - # 同一 symbol 内相邻行掩码, 跨 symbol 边界不允许 shift (避免错配)。 - same_prev_symbol = np.zeros(n, dtype=bool) - same_prev_symbol[1:] = panel_symbols[1:] == panel_symbols[:-1] - - entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices - exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices - - if config.entry_fill == "open_t+1": - ent_s = np.zeros(n, dtype=bool) - ent_s[1:] = ent[:-1] & same_prev_symbol - ent = ent_s - if config.exit_fill == "open_t+1": - ext_s = np.zeros(n, dtype=bool) - ext_s[1:] = ext[:-1] & same_prev_symbol - ext = ext_s - - # 逐 symbol 撮合 - trades: list[TradeRecord] = [] - unique_symbols = np.unique(panel_symbols) - - for sym in unique_symbols: - mask = panel_symbols == sym - sym_ent = ent[mask] - sym_ext = ext[mask] - sym_entry_prices = entry_prices[mask] - sym_exit_prices = exit_prices[mask] - sym_close = close_prices[mask] - sym_dates = panel_dates[mask] - - holding = False - entry_idx = -1 - entry_price = 0.0 - hold_days = 0 - - for i in range(len(sym_ent)): - if not holding: - if sym_ent[i]: - holding = True - entry_idx = i - entry_price = float(sym_entry_prices[i]) - hold_days = 0 - else: - hold_days += 1 - exit_triggered = False - exit_reason = "" - - # 止损 — 用当日 close 检测 (优先级最高) - if config.stop_loss_pct is not None: - pnl = (float(sym_close[i]) - entry_price) / entry_price - if pnl <= -abs(config.stop_loss_pct): - exit_triggered = True - exit_reason = "stop_loss" - - # 信号退出 (优先于 max_hold: 卖点信号是策略主动离场) - if not exit_triggered and sym_ext[i]: - exit_triggered = True - exit_reason = "signal" - - # 最大持仓天数 (兜底: 无信号/未止损时强制平仓) - if not exit_triggered and config.max_hold_days is not None: - if hold_days >= config.max_hold_days: - exit_triggered = True - exit_reason = "max_hold" - - if exit_triggered: - exit_price = float(sym_exit_prices[i]) - pnl_pct = (exit_price - entry_price) / entry_price if entry_price > 0 else 0.0 - fee_cost = config.fees_pct * 2 + config.slippage_bps / 10000.0 * 2 - pnl_pct -= fee_cost - - e_date = sym_dates[entry_idx] - x_date = sym_dates[i] - trades.append(TradeRecord( - symbol=str(sym), - entry_date=e_date.item() if hasattr(e_date, "item") else e_date, - exit_date=x_date.item() if hasattr(x_date, "item") else x_date, - entry_price=round(entry_price, 4), - exit_price=round(exit_price, 4), - pnl_pct=round(pnl_pct, 6), - duration=int(hold_days), - exit_reason=exit_reason, - )) - holding = False - - # 净值曲线: 按出场日期归集收益 - all_dates_sorted = np.sort(np.unique(panel_dates)) - equity_curve, drawdown_curve = self._build_curves(trades, all_dates_sorted, config.initial_capital) - - # 统计 - date_min = panel_dates.min() - date_max = panel_dates.max() - d_min = date_min.item() if hasattr(date_min, "item") else date_min - d_max = date_max.item() if hasattr(date_max, "item") else date_max - stats = self._calc_stats(trades, config.initial_capital, d_min, d_max) - per_symbol = self._calc_per_symbol(trades) - - return SimResult( - equity_curve=equity_curve, - drawdown_curve=drawdown_curve, - trades=trades, - per_symbol_stats=per_symbol, - stats=stats, - ) - - def simulate_independent_candidates( - self, - panel: pl.DataFrame, - entries: pl.Series | None, - exits: pl.Series | None, - config: MatcherConfig, - progress_cb: "Callable[[dict], None] | None" = None, - cancel_event: "threading.Event | None" = None, - ) -> SimResult: - """全量候选独立执行:每个买入信号都是独立样本, 不受资金/仓位限制。""" - if panel.is_empty(): - return self._empty_result() - - n = len(panel) - panel_dates = panel["date"].to_numpy() - panel_symbols = panel["symbol"].to_numpy() - - ent_raw = np.zeros(n, dtype=bool) - ext_raw = np.zeros(n, dtype=bool) - if entries is not None and len(entries) == n: - ent_raw = entries.to_numpy().astype(bool) - if exits is not None and len(exits) == n: - ext_raw = exits.to_numpy().astype(bool) - n_candidates = int(ent_raw.sum()) - if n_candidates <= 0: - return self._empty_result() - - entry_signal_dates = np.array([None] * n, dtype=object) - exit_signal_dates = np.array([None] * n, dtype=object) - same_prev_symbol = panel_symbols[1:] == panel_symbols[:-1] - - # 建仓口径: close_t 用信号日收盘, open_t+1 右移到次日 open 成交。 - ent = np.zeros(n, dtype=bool) - if config.entry_fill == "open_t+1": - ent[1:] = ent_raw[:-1] & same_prev_symbol - for idx in np.flatnonzero(ent): - entry_signal_dates[idx] = self._date_str(panel_dates[idx - 1]) - else: - ent = ent_raw - for idx in np.flatnonzero(ent): - entry_signal_dates[idx] = self._date_str(panel_dates[idx]) - - # 清仓口径: 独立于建仓, close_t 用信号日收盘, open_t+1 右移到次日 open。 - ext = np.zeros(n, dtype=bool) - if config.exit_fill == "open_t+1": - ext[1:] = ext_raw[:-1] & same_prev_symbol - for idx in np.flatnonzero(ext): - exit_signal_dates[idx] = self._date_str(panel_dates[idx - 1]) - else: - ext = ext_raw - for idx in np.flatnonzero(ext): - exit_signal_dates[idx] = self._date_str(panel_dates[idx]) - - open_prices = panel["open"].to_numpy() - high_prices = panel["high"].to_numpy() if "high" in panel.columns else open_prices - low_prices = panel["low"].to_numpy() - close_prices = panel["close"].to_numpy() - # 撮合价: 建仓/清仓各自独立选列。 - entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices - exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices - has_volume = "volume" in panel.columns - volumes = panel["volume"].fill_null(0).to_numpy() if has_volume else np.ones(n, dtype=float) - names = panel["name"].fill_null("").to_numpy() if "name" in panel.columns else np.array([""] * n) - scores = panel["score"].fill_null(0).to_numpy() if "score" in panel.columns else np.zeros(n, dtype=float) - trade_scores = scores.copy() - # 评分跟随建仓口径 shift (评分在买入日生效)。 - if config.entry_fill == "open_t+1": - trade_scores[1:] = np.where(panel_symbols[1:] == panel_symbols[:-1], scores[:-1], trade_scores[1:]) - limit_up_flags = ( - panel["signal_limit_up"].fill_null(False).to_numpy().astype(bool) - if "signal_limit_up" in panel.columns else np.zeros(n, dtype=bool) - ) - limit_down_flags = ( - panel["signal_limit_down"].fill_null(False).to_numpy().astype(bool) - if "signal_limit_down" in panel.columns else np.zeros(n, dtype=bool) - ) - - symbol_rows: dict[str, list[int]] = {} - row_pos_in_symbol = np.zeros(n, dtype=int) - for i, sym_value in enumerate(panel_symbols): - sym = str(sym_value) - rows = symbol_rows.setdefault(sym, []) - row_pos_in_symbol[i] = len(rows) - rows.append(i) - - buy_cost_pct = config.fees_pct + config.slippage_bps / 10000.0 - sell_cost_pct = config.fees_pct + config.slippage_bps / 10000.0 - score_min = getattr(config, "score_min", None) - score_max = getattr(config, "score_max", None) - trades: list[TradeRecord] = [] - execution_stats: dict[str, int] = { - "buy_invalid_price": 0, - "buy_suspended": 0, - "buy_limit_up": 0, - "buy_score_filter": 0, - "buy_no_next_bar": max(n_candidates - int(ent.sum()), 0), - "sell_invalid_price": 0, - "sell_suspended": 0, - "sell_limit_down": 0, - "sell_no_future": 0, - "pending_exit": 0, - } - - def _count(key: str) -> None: - execution_stats[key] = execution_stats.get(key, 0) + 1 - - def _valid_price(value) -> bool: - try: - v = float(value) - except (TypeError, ValueError): - return False - return v > 0 and np.isfinite(v) - - def _is_suspended(idx: int) -> bool: - o = float(open_prices[idx]) - h = float(high_prices[idx]) - l = float(low_prices[idx]) - c = float(close_prices[idx]) - valid_bar = any(_valid_price(x) for x in (o, h, l, c)) - if not valid_bar: - return True - if has_volume and float(volumes[idx] or 0) <= 0: - same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01) - if same_price: - return True - return False - - def _is_one_price_limit(idx: int, direction: str) -> bool: - if _is_suspended(idx): - return False - o = float(open_prices[idx]) - h = float(high_prices[idx]) - l = float(low_prices[idx]) - c = float(close_prices[idx]) - if not all(_valid_price(x) for x in (o, h, l, c)): - return False - same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01) - if direction == "up": - return bool(limit_up_flags[idx]) and same_price - return bool(limit_down_flags[idx]) and same_price - - def _can_buy(idx: int) -> tuple[bool, str]: - if _is_suspended(idx): - return False, "buy_suspended" - if not _valid_price(entry_prices[idx]): - return False, "buy_invalid_price" - if _is_one_price_limit(idx, "up"): - return False, "buy_limit_up" - return True, "" - - def _can_sell(idx: int, exit_price_override: float | None = None) -> tuple[bool, str]: - if _is_suspended(idx): - return False, "sell_suspended" - exit_price = exit_price_override if exit_price_override is not None else exit_prices[idx] - if not _valid_price(exit_price): - return False, "sell_invalid_price" - if _is_one_price_limit(idx, "down"): - return False, "sell_limit_down" - return True, "" - - def _risk_exit(pos: dict, idx: int) -> tuple[str | None, float | None]: - if pos.get("pending_exit_reason") or pos.get("entry_idx") == idx: - return None, None - entry_price = float(pos["entry_price"]) - if entry_price <= 0: - return None, None - open_price = float(open_prices[idx]) - low_price = float(low_prices[idx]) - high_price = float(high_prices[idx]) - peak_price = float(pos.get("max_high", entry_price)) - risk_lines: list[tuple[float, str]] = [] - - if config.stop_loss_pct is not None: - risk_lines.append((entry_price * (1 - abs(config.stop_loss_pct)), "stop_loss")) - if config.trailing_stop_pct is not None and peak_price > 0: - risk_lines.append((peak_price * (1 - abs(config.trailing_stop_pct)), "trailing_stop")) - - activate_pct = getattr(config, "trailing_take_profit_activate_pct", None) - drawdown_pct = getattr(config, "trailing_take_profit_drawdown_pct", None) - if activate_pct is not None and drawdown_pct is not None and peak_price > entry_price: - peak_profit = peak_price / entry_price - 1 - if peak_profit >= abs(float(activate_pct)): - risk_lines.append((entry_price * (1 + peak_profit - abs(float(drawdown_pct))), "trailing_take_profit")) - - risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)] - # 止损/移损/回撤止盈: 价格跌破风控线触发 (取最高优先级线) - if risk_lines: - stop_price, reason = max(risk_lines, key=lambda item: item[0]) - if _valid_price(open_price) and open_price <= stop_price: - return reason, open_price - if _valid_price(low_price) and low_price <= stop_price: - return reason, stop_price - - # 固定止盈: 价格涨破止盈线触发 - tp_pct = getattr(config, "take_profit_pct", None) - if tp_pct is not None: - tp_line = entry_price * (1 + abs(float(tp_pct))) - if _valid_price(tp_line): - # 开盘即超过止盈线 → 以开盘价成交; 否则当日触及高点止盈 - if _valid_price(open_price) and open_price >= tp_line: - return "take_profit", open_price - if _valid_price(high_price) and high_price >= tp_line: - return "take_profit", tp_line - return None, None - - def _try_close(pos: dict, idx: int, reason: str, signal_date: str, exit_price_override: float | None = None) -> bool: - ok, block_reason = _can_sell(idx, exit_price_override) - if not ok: - if not pos.get("pending_exit_reason"): - pos["pending_exit_reason"] = reason - pos["pending_exit_signal_date"] = signal_date - _count("pending_exit") - pos["blocked_exit_days"] = int(pos.get("blocked_exit_days", 0)) + 1 - _count(block_reason) - return False - - exit_price = float(exit_price_override) if exit_price_override is not None else float(exit_prices[idx]) - shares = 100.0 - entry_value = shares * float(pos["entry_price"]) * (1 + buy_cost_pct) - exit_value = shares * exit_price * (1 - sell_cost_pct) - pnl_amount = exit_value - entry_value - pnl_pct = pnl_amount / entry_value if entry_value > 0 else 0.0 - trades.append(TradeRecord( - symbol=str(pos["symbol"]), - name=str(pos.get("name", "")), - entry_date=pos["entry_date"], - exit_date=self._date_str(panel_dates[idx]), - entry_price=round(float(pos["entry_price"]), 4), - exit_price=round(exit_price, 4), - pnl_pct=round(float(pnl_pct), 6), - duration=int(pos["hold_days"]), - exit_reason=reason, - shares=shares, - lots=1.0, - position_pct=0.0, - entry_value=round(float(entry_value), 2), - exit_value=round(float(exit_value), 2), - pnl_amount=round(float(pnl_amount), 2), - entry_score=round(float(pos["entry_score"]), 2) if pos.get("entry_score") is not None else None, - entry_signal_date=pos.get("entry_signal_date"), - exit_signal_date=signal_date, - blocked_exit_days=int(pos.get("blocked_exit_days", 0)), - )) - return True - - candidate_indices = np.flatnonzero(ent) - for seq, entry_idx in enumerate(candidate_indices, start=1): - if cancel_event is not None and cancel_event.is_set(): - logger.info("全量模拟被用户取消 (第 %d/%d 个候选)", seq, len(candidate_indices)) - break - if progress_cb is not None and (seq == 1 or seq % 500 == 0): - try: - progress_cb({ - "day": seq, - "total": len(candidate_indices), - "date": self._date_str(panel_dates[entry_idx]), - "equity": 0, - }) - except Exception: - pass - - ok, block_reason = _can_buy(entry_idx) - if not ok: - _count(block_reason) - continue - score = float(trade_scores[entry_idx] or 0.0) - if score_min is not None and score < score_min: - _count("buy_score_filter") - continue - if score_max is not None and score > score_max: - _count("buy_score_filter") - continue - - sym = str(panel_symbols[entry_idx]) - rows = symbol_rows.get(sym, []) - start_pos = int(row_pos_in_symbol[entry_idx]) - if start_pos >= len(rows): - _count("sell_no_future") - continue - - entry_price = float(entry_prices[entry_idx]) - pos = { - "symbol": sym, - "name": str(names[entry_idx] or ""), - "entry_idx": entry_idx, - "entry_date": self._date_str(panel_dates[entry_idx]), - "entry_signal_date": entry_signal_dates[entry_idx] or self._date_str(panel_dates[entry_idx]), - "entry_price": entry_price, - "entry_score": score, - "hold_days": 0, - "max_high": entry_price, - "pending_exit_reason": None, - "pending_exit_signal_date": None, - "blocked_exit_days": 0, - } - hi = float(high_prices[entry_idx]) - if _valid_price(hi): - pos["max_high"] = max(float(pos["max_high"]), hi) - - closed = False - last_idx = entry_idx - for idx in rows[start_pos + 1:]: - last_idx = idx - pos["hold_days"] = int(pos["hold_days"]) + 1 - d_str = self._date_str(panel_dates[idx]) - - def _scheduled_reason() -> tuple[str | None, str]: - if pos.get("pending_exit_reason"): - return str(pos["pending_exit_reason"]), str(pos.get("pending_exit_signal_date") or d_str) - # 卖点信号优先于到期: 策略主动离场先于 max_hold 兜底。 - if ext[idx]: - return "signal", str(exit_signal_dates[idx] or d_str) - if config.max_hold_days is not None and pos["hold_days"] >= config.max_hold_days: - return "max_hold", d_str - if idx == rows[-1]: - return "end", d_str - return None, d_str - - # 统一退出顺序: 风控(止损/移动止损/止盈)先于计划出场 (signal/max_hold/end)。 - # 无论 entry/exit 口径如何, 风控都是保护性离场, 必须最高优先级。 - reason, override_price = _risk_exit(pos, idx) - if reason and _try_close(pos, idx, reason, d_str, override_price): - closed = True - break - reason, signal_date = _scheduled_reason() - if reason and _try_close(pos, idx, reason, signal_date): - closed = True - break - - hi = float(high_prices[idx]) - if _valid_price(hi): - pos["max_high"] = max(float(pos.get("max_high", entry_price)), hi) - - if not closed: - if last_idx == entry_idx: - _count("sell_no_future") - elif not pos.get("pending_exit_reason"): - _try_close(pos, last_idx, "end", self._date_str(panel_dates[last_idx])) - - return self._calc_independent_candidate_result(trades, n_candidates, execution_stats) - - def simulate_portfolio( - self, - panel: pl.DataFrame, - entries: pl.Series | None, - exits: pl.Series | None, - config: MatcherConfig, - progress_cb: "Callable[[dict], None] | None" = None, - cancel_event: "threading.Event | None" = None, - ) -> SimResult: - """账户级组合回测:日线信号 → 成交约束 → 仓位/现金撮合。""" - if panel.is_empty(): - return self._empty_result() - - n = len(panel) - panel_dates = panel["date"].to_numpy() - panel_symbols = panel["symbol"].to_numpy() - - ent_raw = np.zeros(n, dtype=bool) - ext_raw = np.zeros(n, dtype=bool) - if entries is not None and len(entries) == n: - ent_raw = entries.to_numpy().astype(bool) - if exits is not None and len(exits) == n: - ext_raw = exits.to_numpy().astype(bool) - if not ent_raw.any(): - return self._empty_result() - - entry_signal_dates = np.array([None] * n, dtype=object) - exit_signal_dates = np.array([None] * n, dtype=object) - same_prev_symbol = panel_symbols[1:] == panel_symbols[:-1] - - # 建仓口径: close_t 用信号日收盘, open_t+1 右移到次日 open 成交。 - ent = np.zeros(n, dtype=bool) - if config.entry_fill == "open_t+1": - ent[1:] = ent_raw[:-1] & same_prev_symbol - for idx in np.flatnonzero(ent): - entry_signal_dates[idx] = self._date_str(panel_dates[idx - 1]) - else: - ent = ent_raw - for idx in np.flatnonzero(ent): - entry_signal_dates[idx] = self._date_str(panel_dates[idx]) - - # 清仓口径: 独立于建仓。 - ext = np.zeros(n, dtype=bool) - if config.exit_fill == "open_t+1": - ext[1:] = ext_raw[:-1] & same_prev_symbol - for idx in np.flatnonzero(ext): - exit_signal_dates[idx] = self._date_str(panel_dates[idx - 1]) - else: - ext = ext_raw - for idx in np.flatnonzero(ext): - exit_signal_dates[idx] = self._date_str(panel_dates[idx]) - - open_prices = panel["open"].to_numpy() - high_prices = panel["high"].to_numpy() if "high" in panel.columns else open_prices - low_prices = panel["low"].to_numpy() - close_prices = panel["close"].to_numpy() - # 撮合价: 建仓/清仓各自独立选列。 - entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices - exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices - has_volume = "volume" in panel.columns - volumes = panel["volume"].fill_null(0).to_numpy() if has_volume else np.ones(n, dtype=float) - names = ( - panel["name"].fill_null("").to_numpy() - if "name" in panel.columns else np.array([""] * n) - ) - scores = ( - panel["score"].fill_null(0).to_numpy() - if "score" in panel.columns else np.zeros(n, dtype=float) - ) - trade_scores = scores.copy() - # 评分跟随建仓口径 shift (评分在买入日生效)。 - if config.entry_fill == "open_t+1": - trade_scores[1:] = np.where(panel_symbols[1:] == panel_symbols[:-1], scores[:-1], trade_scores[1:]) - limit_up_flags = ( - panel["signal_limit_up"].fill_null(False).to_numpy().astype(bool) - if "signal_limit_up" in panel.columns else np.zeros(n, dtype=bool) - ) - limit_down_flags = ( - panel["signal_limit_down"].fill_null(False).to_numpy().astype(bool) - if "signal_limit_down" in panel.columns else np.zeros(n, dtype=bool) - ) - - date_to_indices: dict[str, list[int]] = {} - for i, d in enumerate(panel_dates): - d_str = self._date_str(d) - date_to_indices.setdefault(d_str, []).append(i) - all_dates = sorted(date_to_indices.keys()) - if not all_dates: - return self._empty_result() - - buy_cost_pct = config.fees_pct + config.slippage_bps / 10000.0 - sell_cost_pct = config.fees_pct + config.slippage_bps / 10000.0 - cash = float(config.initial_capital) - peak = cash - max_positions = max(int(config.max_positions), 0) - max_exposure_pct = min(max(float(getattr(config, "max_exposure_pct", 1.0)), 0.0), 1.0) - score_min = getattr(config, "score_min", None) - score_max = getattr(config, "score_max", None) - positions: dict[str, dict] = {} - last_close: dict[str, float] = {} - trades: list[TradeRecord] = [] - equity_curve: list[dict] = [] - drawdown_curve: list[dict] = [] - execution_stats: dict[str, int] = { - "buy_invalid_price": 0, - "buy_suspended": 0, - "buy_limit_up": 0, - "buy_no_slot": 0, - "buy_cash": 0, - "buy_lot_size": 0, - "buy_same_day_reentry": 0, - "buy_exposure": 0, - "buy_score_filter": 0, - "sell_invalid_price": 0, - "sell_suspended": 0, - "sell_limit_down": 0, - "pending_exit": 0, - } - - def _count(key: str) -> None: - execution_stats[key] = execution_stats.get(key, 0) + 1 - - def _valid_price(value) -> bool: - try: - v = float(value) - except (TypeError, ValueError): - return False - return v > 0 and np.isfinite(v) - - def _market_value() -> float: - value = 0.0 - for pos in positions.values(): - mark = last_close.get(pos["symbol"], pos["entry_price"]) - value += pos["shares"] * mark - return value - - def _is_suspended(idx: int) -> bool: - o = float(open_prices[idx]) - h = float(high_prices[idx]) - l = float(low_prices[idx]) - c = float(close_prices[idx]) - valid_bar = any(_valid_price(x) for x in (o, h, l, c)) - if not valid_bar: - return True - if has_volume and float(volumes[idx] or 0) <= 0: - same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01) - if same_price: - return True - return False - - def _is_one_price_limit(idx: int, direction: str) -> bool: - if _is_suspended(idx): - return False - o = float(open_prices[idx]) - h = float(high_prices[idx]) - l = float(low_prices[idx]) - c = float(close_prices[idx]) - if not all(_valid_price(x) for x in (o, h, l, c)): - return False - same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01) - if direction == "up": - return bool(limit_up_flags[idx]) and same_price - return bool(limit_down_flags[idx]) and same_price - - def _can_buy(idx: int) -> tuple[bool, str]: - if _is_suspended(idx): - return False, "buy_suspended" - if not _valid_price(entry_prices[idx]): - return False, "buy_invalid_price" - if _is_one_price_limit(idx, "up"): - return False, "buy_limit_up" - return True, "" - - def _can_sell(idx: int, exit_price_override: float | None = None) -> tuple[bool, str]: - if _is_suspended(idx): - return False, "sell_suspended" - exit_price = exit_price_override if exit_price_override is not None else exit_prices[idx] - if not _valid_price(exit_price): - return False, "sell_invalid_price" - if _is_one_price_limit(idx, "down"): - return False, "sell_limit_down" - return True, "" - - def _mark_pending(sym: str, reason: str, signal_date: str) -> None: - pos = positions[sym] - if not pos.get("pending_exit_reason"): - pos["pending_exit_reason"] = reason - pos["pending_exit_signal_date"] = signal_date - _count("pending_exit") - pos["blocked_exit_days"] = int(pos.get("blocked_exit_days", 0)) + 1 - - def _sell( - sym: str, - idx: int, - reason: str, - signal_date: str, - sold_today: set[str], - exit_price_override: float | None = None, - ) -> None: - nonlocal cash - pos = positions.pop(sym) - exit_price = float(exit_price_override) if exit_price_override is not None else float(exit_prices[idx]) - exit_value = pos["shares"] * exit_price * (1 - sell_cost_pct) - cash += exit_value - pnl_amount = exit_value - pos["entry_value"] - pnl_pct = (exit_value - pos["entry_value"]) / pos["entry_value"] if pos["entry_value"] > 0 else 0.0 - sold_today.add(sym) - trades.append(TradeRecord( - symbol=sym, - name=pos.get("name", ""), - entry_date=pos["entry_date"], - exit_date=self._date_str(panel_dates[idx]), - entry_price=round(float(pos["entry_price"]), 4), - exit_price=round(exit_price, 4), - pnl_pct=round(float(pnl_pct), 6), - duration=int(pos["hold_days"]), - exit_reason=reason, - shares=round(float(pos["shares"]), 4), - lots=round(float(pos["lots"]), 2), - position_pct=round(float(pos.get("position_pct", 0.0)), 6), - entry_value=round(float(pos["entry_value"]), 2), - exit_value=round(float(exit_value), 2), - pnl_amount=round(float(pnl_amount), 2), - entry_score=round(float(pos["entry_score"]), 2) if pos.get("entry_score") is not None else None, - entry_signal_date=pos.get("entry_signal_date"), - exit_signal_date=signal_date, - blocked_exit_days=int(pos.get("blocked_exit_days", 0)), - )) - - def _try_sell( - sym: str, - idx: int | None, - reason: str, - signal_date: str, - sold_today: set[str], - exit_price_override: float | None = None, - ) -> bool: - if idx is None: - _mark_pending(sym, reason, signal_date) - _count("sell_suspended") - return False - ok, block_reason = _can_sell(idx, exit_price_override) - if not ok: - _mark_pending(sym, reason, signal_date) - _count(block_reason) - return False - _sell(sym, idx, reason, signal_date, sold_today, exit_price_override) - return True - - def _process_scheduled_exits( - d_idx: int, - d_str: str, - row_by_symbol: dict[str, int], - sold_today: set[str], - ) -> None: - for sym in list(positions.keys()): - pos = positions.get(sym) - if pos is None: - continue - idx = row_by_symbol.get(sym) - reason = "" - signal_date = d_str - if pos.get("pending_exit_reason"): - reason = str(pos["pending_exit_reason"]) - signal_date = str(pos.get("pending_exit_signal_date") or d_str) - # 卖点信号优先于到期: 策略主动离场先于 max_hold 兜底。 - elif idx is not None and ext[idx]: - reason = "signal" - signal_date = str(exit_signal_dates[idx] or d_str) - elif config.max_hold_days is not None and pos["hold_days"] >= config.max_hold_days: - reason = "max_hold" - elif d_idx == len(all_dates) - 1: - reason = "end" - if reason: - _try_sell(sym, idx, reason, signal_date, sold_today) - - def _process_risk_exits(d_str: str, row_by_symbol: dict[str, int], sold_today: set[str]) -> None: - for sym in list(positions.keys()): - pos = positions.get(sym) - if pos is None or pos.get("pending_exit_reason"): - continue - if pos.get("entry_date") == d_str: - continue - idx = row_by_symbol.get(sym) - if idx is None or pos["entry_price"] <= 0: - continue - open_price = float(open_prices[idx]) - low_price = float(low_prices[idx]) - high_price = float(high_prices[idx]) - entry_price = float(pos["entry_price"]) - peak_price = float(pos.get("max_high", entry_price)) - risk_lines: list[tuple[float, str]] = [] - - if config.stop_loss_pct is not None: - risk_lines.append((entry_price * (1 - abs(config.stop_loss_pct)), "stop_loss")) - - if config.trailing_stop_pct is not None and peak_price > 0: - risk_lines.append((peak_price * (1 - abs(config.trailing_stop_pct)), "trailing_stop")) - - activate_pct = getattr(config, "trailing_take_profit_activate_pct", None) - drawdown_pct = getattr(config, "trailing_take_profit_drawdown_pct", None) - if activate_pct is not None and drawdown_pct is not None and peak_price > entry_price: - peak_profit = peak_price / entry_price - 1 - if peak_profit >= abs(float(activate_pct)): - take_profit_line = entry_price * (1 + peak_profit - abs(float(drawdown_pct))) - risk_lines.append((take_profit_line, "trailing_take_profit")) - - # 止损/移损/回撤止盈: 价格跌破风控线触发 - risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)] - if risk_lines: - stop_price, reason = max(risk_lines, key=lambda item: item[0]) - exit_price_override = None - if _valid_price(open_price) and open_price <= stop_price: - exit_price_override = open_price - elif _valid_price(low_price) and low_price <= stop_price: - exit_price_override = stop_price - if exit_price_override is not None: - _try_sell(sym, idx, reason, d_str, sold_today, exit_price_override) - continue - - # 固定止盈: 价格涨破止盈线触发 - tp_pct = getattr(config, "take_profit_pct", None) - if tp_pct is not None: - tp_line = entry_price * (1 + abs(float(tp_pct))) - if _valid_price(tp_line): - if _valid_price(open_price) and open_price >= tp_line: - _try_sell(sym, idx, "take_profit", d_str, sold_today, open_price) - elif _valid_price(high_price) and high_price >= tp_line: - _try_sell(sym, idx, "take_profit", d_str, sold_today, tp_line) - - def _process_entries( - d_str: str, - idxs: list[int], - sold_today: set[str], - ) -> None: - nonlocal cash - if max_positions <= 0: - return - candidates: list[tuple[int, str, float]] = [] - for idx in idxs: - if not ent[idx]: - continue - sym = str(panel_symbols[idx]) - if sym in positions: - continue - if sym in sold_today: - _count("buy_same_day_reentry") - continue - ok, block_reason = _can_buy(idx) - if not ok: - _count(block_reason) - continue - score = float(trade_scores[idx] or 0.0) - if score_min is not None and score < score_min: - _count("buy_score_filter") - continue - if score_max is not None and score > score_max: - _count("buy_score_filter") - continue - candidates.append((idx, sym, score)) - if not candidates: - return - candidates.sort(key=lambda x: x[2], reverse=True) - - slots = max_positions - len(positions) - if slots <= 0: - execution_stats["buy_no_slot"] += len(candidates) - return - - selected = candidates[:slots] - market_value_before = _market_value() - account_equity_before_buy = cash + market_value_before - if account_equity_before_buy <= 0 or max_exposure_pct <= 0: - execution_stats["buy_exposure"] += len(selected) - return - target_position_value = account_equity_before_buy * max_exposure_pct / max_positions - max_exposure_value = account_equity_before_buy * max_exposure_pct - exposure_capacity = max_exposure_value - market_value_before - if exposure_capacity <= 0: - execution_stats["buy_exposure"] += len(selected) - return - - weights = np.repeat(1 / len(selected), len(selected)) - if config.position_sizing == "score_weight": - raw = np.array([max(x[2], 0.0) for x in selected], dtype=float) - if raw.sum() > 0: - weights = raw / raw.sum() - total_budget = min(cash, exposure_capacity, target_position_value * len(selected)) - - for (idx, sym, _score), weight in zip(selected, weights): - if len(positions) >= max_positions: - _count("buy_no_slot") - break - current_market_value = _market_value() - current_equity = cash + current_market_value - current_exposure_capacity = current_equity * max_exposure_pct - current_market_value - allocation = min(total_budget * float(weight), target_position_value, cash, current_exposure_capacity) - if allocation <= 0: - _count("buy_exposure") - continue - entry_price = float(entry_prices[idx]) - shares = np.floor(allocation / (entry_price * (1 + buy_cost_pct)) / 100) * 100 - entry_value = shares * entry_price * (1 + buy_cost_pct) - if shares <= 0: - _count("buy_lot_size") - continue - if entry_value > cash + 1e-6: - _count("buy_cash") - continue - if entry_value > current_exposure_capacity + 1e-6: - _count("buy_exposure") - continue - cash -= entry_value - positions[sym] = { - "symbol": sym, - "name": str(names[idx] or ""), - "entry_date": self._date_str(panel_dates[idx]), - "entry_signal_date": entry_signal_dates[idx] or self._date_str(panel_dates[idx]), - "entry_price": entry_price, - "entry_value": entry_value, - "shares": shares, - "lots": shares / 100, - "position_pct": entry_value / account_equity_before_buy if account_equity_before_buy > 0 else 0.0, - "entry_score": _score, - "max_high": entry_price, - "hold_days": 0, - "pending_exit_reason": None, - "pending_exit_signal_date": None, - "blocked_exit_days": 0, - } - - for d_idx, d_str in enumerate(all_dates): - if d_idx % 20 == 0: - if cancel_event is not None and cancel_event.is_set(): - logger.info("回测被用户取消 (第 %d/%d 天)", d_idx, len(all_dates)) - break - if progress_cb is not None: - try: - progress_cb({ - "day": d_idx + 1, - "total": len(all_dates), - "date": str(d_str)[:10], - "equity": round(cash + _market_value(), 2), - }) - except Exception: - pass - - idxs = date_to_indices[d_str] - row_by_symbol = {str(panel_symbols[i]): i for i in idxs} - sold_today: set[str] = set() - - for pos in positions.values(): - pos["hold_days"] += 1 - - # 统一执行顺序 (不分口径): 风控(止损/移动止损/止盈) → 计划出场(signal/max_hold/end) → 建仓。 - # 风控是保护性离场, 必须最先; 计划出场次之; 建仓最后 (卖出释放的现金/仓位先用于满足新买)。 - # 当天新建仓不会被风控误杀 (_process_risk_exits 跳过 entry_date == d_str 的仓位)。 - _process_risk_exits(d_str, row_by_symbol, sold_today) - _process_scheduled_exits(d_idx, d_str, row_by_symbol, sold_today) - if d_idx < len(all_dates) - 1: - _process_entries(d_str, idxs, sold_today) - - for sym, pos in positions.items(): - idx = row_by_symbol.get(sym) - if idx is not None: - hi = float(high_prices[idx]) - if _valid_price(hi): - pos["max_high"] = max(float(pos.get("max_high", pos["entry_price"])), hi) - - for i in idxs: - c = float(close_prices[i]) - if c > 0 and np.isfinite(c): - last_close[str(panel_symbols[i])] = c - - market_value = _market_value() - equity = cash + market_value - peak = max(peak, equity) - dd = (equity - peak) / peak if peak > 0 else 0.0 - exposure = market_value / equity if equity > 0 else 0.0 - equity_curve.append({ - "date": d_str[:10], - "value": round(float(equity), 2), - "cash": round(float(cash), 2), - "positions": len(positions), - "exposure": round(float(exposure), 4), - }) - drawdown_curve.append({"date": d_str[:10], "value": round(float(dd), 4)}) - - stats = self._calc_portfolio_stats(equity_curve, trades, config.initial_capital) - stats["execution"] = execution_stats - stats["pending_exit_positions"] = sum(1 for p in positions.values() if p.get("pending_exit_reason")) - per_symbol = self._calc_per_symbol(trades) - return SimResult( - equity_curve=equity_curve, - drawdown_curve=drawdown_curve, - trades=trades, - per_symbol_stats=per_symbol, - stats=stats, - ) - - # ── 净值曲线 ────────────────────────────────────── - - @staticmethod - def _build_curves( - trades: list[TradeRecord], - all_dates: np.ndarray, - initial_capital: float, - ) -> tuple[list[dict], list[dict]]: - """从交易记录构建日频净值曲线和回撤曲线。 - - 资金模型: 每笔交易等权分配 (1/N_capital),N_capital = 同时持仓数上限。 - 简化版: 按出场日归集所有已平仓交易的平均收益作为当日组合收益。 - """ - if not trades or len(all_dates) == 0: - return [], [] - - # 按出场日归集 pnl - exit_pnl: dict[str, list[float]] = {} - for t in trades: - d_str = str(t.exit_date) - exit_pnl.setdefault(d_str, []).append(t.pnl_pct) - - equity = initial_capital - peak = initial_capital - curve: list[dict] = [] - dd_curve: list[dict] = [] - - for d in all_dates: - d_str = str(d.item() if hasattr(d, "item") else d) - pnls = exit_pnl.get(d_str, []) - # 当日组合收益 = 该日所有出场交易的平均收益 - daily_ret = float(np.mean(pnls)) if pnls else 0.0 - equity *= (1 + daily_ret) - peak = max(peak, equity) - dd = (equity - peak) / peak if peak > 0 else 0.0 - curve.append({"date": d_str[:10], "value": round(equity, 2)}) - dd_curve.append({"date": d_str[:10], "value": round(dd, 4)}) - - return curve, dd_curve - - # ── 统计计算 ────────────────────────────────────── - - @staticmethod - def _calc_stats( - trades: list[TradeRecord], - initial_capital: float, - start: date, - end: date, - ) -> dict: - if not trades: - return {"total_return": 0, "n_trades": 0} - - pnls = np.array([t.pnl_pct for t in trades]) - n_trades = len(trades) - - # 从净值曲线推算总收益 (等权组合) - cumulative = 1.0 - for p in pnls: - cumulative *= (1 + p) - # 修正: 等权组合的总收益不等于各笔复乘,用曲线终点更准 - # 但这里作为简化,用各笔复乘作为近似 - total_return = cumulative - 1.0 - - # 年化 - n_days = max((end - start).days, 1) - years = n_days / 365.25 - if total_return > -1.0 and years > 0: - annual_return = (1 + total_return) ** (1 / years) - 1 - else: - annual_return = total_return - - # 胜率 - wins = pnls[pnls > 0] - losses = pnls[pnls <= 0] - win_rate = len(wins) / n_trades - - # 盈亏比 - avg_win = float(np.mean(wins)) if len(wins) > 0 else 0.0 - avg_loss = abs(float(np.mean(losses))) if len(losses) > 0 else 0.0 - profit_factor = avg_win / avg_loss if avg_loss > 0 else (float("inf") if avg_win > 0 else 0.0) - - # 最大回撤 — 用交易序列近似 - equity = initial_capital - peak = initial_capital - max_dd = 0.0 - for p in pnls: - equity *= (1 + p) - peak = max(peak, equity) - dd = (equity - peak) / peak - max_dd = min(max_dd, dd) - - # 夏普 — 用交易收益标准差近似 - sharpe = float(np.mean(pnls) / np.std(pnls)) * np.sqrt(252) if np.std(pnls) > 0 else 0.0 - - # Calmar - calmar = annual_return / abs(max_dd) if abs(max_dd) > 0.001 else 0.0 - - return { - "total_return": round(float(total_return), 4), - "annual_return": round(float(annual_return), 4), - "max_drawdown": round(float(max_dd), 4), - "sharpe": round(float(sharpe), 2), - "calmar": round(float(calmar), 2), - "win_rate": round(float(win_rate), 4), - "profit_factor": round(float(profit_factor), 2) if np.isfinite(profit_factor) else None, - "n_trades": n_trades, - "avg_pnl": round(float(np.mean(pnls)), 4), - "avg_win": round(avg_win, 4), - "avg_loss": round(avg_loss, 4), - } - - @staticmethod - def _calc_per_symbol(trades: list[TradeRecord]) -> list[dict]: - if not trades: - return [] - by_sym: dict[str, dict] = {} - for t in trades: - s = t.symbol - d = by_sym.setdefault(s, { - "symbol": s, "n_trades": 0, "total_return": 1.0, - "best": -999.0, "worst": 999.0, "wins": 0, "pnls": [], - }) - d["n_trades"] += 1 - d["pnls"].append(t.pnl_pct) - d["total_return"] *= (1 + t.pnl_pct) - d["best"] = max(d["best"], t.pnl_pct) - d["worst"] = min(d["worst"], t.pnl_pct) - if t.pnl_pct > 0: - d["wins"] += 1 - - result = [] - for d in by_sym.values(): - result.append({ - "symbol": d["symbol"], - "n_trades": d["n_trades"], - "total_return": round(d["total_return"] - 1.0, 4), - "win_rate": round(d["wins"] / d["n_trades"], 4) if d["n_trades"] > 0 else 0.0, - "best": round(d["best"], 4), - "worst": round(d["worst"], 4), - }) - return sorted(result, key=lambda x: x["total_return"], reverse=True) - - @staticmethod - def _calc_independent_candidate_result( - trades: list[TradeRecord], - n_candidates: int, - execution_stats: dict[str, int], - ) -> SimResult: - """全量独立候选统计:按每个候选样本的实际执行收益聚合。""" - if not trades: - return SimResult( - equity_curve=[], - drawdown_curve=[], - trades=[], - per_symbol_stats=[], - stats={ - "mode": "full", - "full_kind": "candidate_execution", - "error": "no executable trades", - "n_candidates": int(n_candidates), - "n_trades": 0, - "execution": execution_stats, - }, - ) - - pnls = np.array([t.pnl_pct for t in trades], dtype=float) - durations = np.array([t.duration for t in trades], dtype=float) - wins = pnls[pnls > 0] - losses = pnls[pnls <= 0] - avg_win = float(np.mean(wins)) if len(wins) else 0.0 - avg_loss = abs(float(np.mean(losses))) if len(losses) else 0.0 - - # 按退出日聚合已实现样本收益, 构造“样本收益曲线”。它不是账户净值。 - daily_returns: dict[str, list[float]] = {} - for t in trades: - daily_returns.setdefault(str(t.exit_date)[:10], []).append(float(t.pnl_pct)) - - equity_curve: list[dict] = [] - drawdown_curve: list[dict] = [] - equity = 1.0 - peak = 1.0 - daily_avg: list[float] = [] - for d_str in sorted(daily_returns.keys()): - values = daily_returns[d_str] - avg_ret = float(np.mean(values)) if values else 0.0 - daily_avg.append(avg_ret) - equity *= (1 + avg_ret) - peak = max(peak, equity) - dd = (equity - peak) / peak if peak > 0 else 0.0 - equity_curve.append({ - "date": d_str, - "value": round(float(equity), 4), - "positions": len(values), - }) - drawdown_curve.append({"date": d_str, "value": round(float(dd), 4)}) - - values = np.array([r["value"] for r in equity_curve], dtype=float) - total_return = float(values[-1] - 1.0) if len(values) else 0.0 - peaks = np.maximum.accumulate(values) if len(values) else np.array([]) - drawdowns = values / peaks - 1 if len(values) else np.array([]) - max_drawdown = float(drawdowns.min()) if len(drawdowns) else 0.0 - daily = np.array(daily_avg, dtype=float) - sharpe = float(np.mean(daily) / np.std(daily) * np.sqrt(252)) if len(daily) > 1 and np.std(daily) > 0 else 0.0 - - lo, hi, nbins = -0.20, 0.20, 20 - clipped = np.clip(pnls, 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] / pnls.size), 4) if pnls.size else 0.0, - } - for i in range(nbins) - ] - - stats = { - "mode": "full", - "full_kind": "candidate_execution", - "n_candidates": int(n_candidates), - "n_trades": int(len(trades)), - "n_days": int(len(daily_returns)), - "avg_daily_candidates": round(float(len(trades) / max(len(daily_returns), 1)), 1), - "avg_return": round(float(np.mean(pnls)), 4), - "median_return": round(float(np.median(pnls)), 4), - "win_rate": round(float(len(wins) / len(pnls)), 4) if len(pnls) else 0.0, - "profit_factor": round(float(avg_win / avg_loss), 2) if avg_loss > 0 else None, - "best": round(float(np.max(pnls)), 4), - "worst": round(float(np.min(pnls)), 4), - "avg_duration": round(float(np.mean(durations)), 1) if len(durations) else 0.0, - "total_return": round(float(total_return), 4), - "max_drawdown": round(float(max_drawdown), 4), - "sharpe": round(float(sharpe), 2), - "return_distribution": dist, - "execution": execution_stats, - } - - return SimResult( - equity_curve=equity_curve, - drawdown_curve=drawdown_curve, - trades=trades, - per_symbol_stats=BacktestEngine._calc_per_symbol(trades), - stats=stats, - ) - - @staticmethod - def _calc_portfolio_stats( - equity_curve: list[dict], - trades: list[TradeRecord], - initial_capital: float, - ) -> dict: - if not equity_curve: - return {"total_return": 0, "n_trades": 0} - final_equity = float(equity_curve[-1]["value"]) - total_return = final_equity / initial_capital - 1 if initial_capital > 0 else 0.0 - values = np.array([float(r["value"]) for r in equity_curve], dtype=float) - daily = values[1:] / values[:-1] - 1 if len(values) > 1 else np.array([]) - annual_return = (1 + total_return) ** (252 / max(len(equity_curve), 1)) - 1 if total_return > -1 else total_return - peaks = np.maximum.accumulate(values) - drawdowns = values / peaks - 1 - max_drawdown = float(drawdowns.min()) if len(drawdowns) else 0.0 - sharpe = float(np.mean(daily) / np.std(daily) * np.sqrt(252)) if len(daily) and np.std(daily) > 0 else 0.0 - pnls = np.array([t.pnl_pct for t in trades], dtype=float) if trades else np.array([]) - exposures = np.array([float(r.get("exposure", 0.0)) for r in equity_curve], dtype=float) - wins = pnls[pnls > 0] - losses = pnls[pnls <= 0] - avg_win = float(np.mean(wins)) if len(wins) else 0.0 - avg_loss = abs(float(np.mean(losses))) if len(losses) else 0.0 - return { - "total_return": round(float(total_return), 4), - "annual_return": round(float(annual_return), 4), - "max_drawdown": round(float(max_drawdown), 4), - "sharpe": round(float(sharpe), 2), - "calmar": round(float(annual_return / abs(max_drawdown)), 2) if abs(max_drawdown) > 0.001 else 0.0, - "win_rate": round(float(len(wins) / len(pnls)), 4) if len(pnls) else 0.0, - "profit_factor": round(float(avg_win / avg_loss), 2) if avg_loss > 0 else None, - "n_trades": len(trades), - "avg_pnl": round(float(np.mean(pnls)), 4) if len(pnls) else 0.0, - "avg_win": round(avg_win, 4), - "avg_loss": round(avg_loss, 4), - "final_equity": round(final_equity, 2), - "initial_capital": round(float(initial_capital), 2), - "avg_exposure": round(float(np.mean(exposures)), 4) if len(exposures) else 0.0, - "max_exposure": round(float(np.max(exposures)), 4) if len(exposures) else 0.0, - } - - @staticmethod - def _date_str(value) -> str: - value = value.item() if hasattr(value, "item") else value - return str(value)[:10] - - @staticmethod - def _empty_result() -> SimResult: - return SimResult( - equity_curve=[], drawdown_curve=[], trades=[], - per_symbol_stats=[], stats={"error": "no data or no signals"}, - ) - - # ── 截面工具 (因子回测用) ───────────────────────── - - @staticmethod - def cross_section_rank(panel: pl.DataFrame, col: str) -> pl.DataFrame: - return panel.with_columns( - pl.col(col).rank(method="random").over("date").alias(f"{col}_rank") - ) - - @staticmethod - def cross_section_qcut(panel: pl.DataFrame, col: str, n_groups: int) -> pl.DataFrame: - return panel.with_columns( - pl.col(col).qcut(n_groups, labels=[f"Q{i+1}" for i in range(n_groups)]) - .over("date").alias("_group") - ) diff --git a/serve/backend/app/backtest/factor.py b/serve/backend/app/backtest/factor.py deleted file mode 100644 index 740582a..0000000 --- a/serve/backend/app/backtest/factor.py +++ /dev/null @@ -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, - } diff --git a/serve/backend/app/backtest/strategy.py b/serve/backend/app/backtest/strategy.py deleted file mode 100644 index 0518fb3..0000000 --- a/serve/backend/app/backtest/strategy.py +++ /dev/null @@ -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"))) diff --git a/serve/backend/app/config.py b/serve/backend/app/config.py index f6c4f43..2844612 100644 --- a/serve/backend/app/config.py +++ b/serve/backend/app/config.py @@ -93,8 +93,6 @@ class Settings(BaseSettings): host: str = "0.0.0.0" port: int = 3018 log_level: str = "INFO" - backtest_range_guard: bool = False - # Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env) # 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。 auth_password: str = "" diff --git a/serve/backend/app/jobs/daily_pipeline.py b/serve/backend/app/jobs/daily_pipeline.py index ec8876e..f112938 100644 --- a/serve/backend/app/jobs/daily_pipeline.py +++ b/serve/backend/app/jobs/daily_pipeline.py @@ -44,7 +44,7 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]: """解析标的池 — 以 CN_Equity_A (沪深京A股 ~5522只) 为主。 有 batch 能力 → 直接拉 CN_Equity_A universe - 其他用户 → 用 instruments parquet + watchlist 兜底 + 其他用户 → 用 instruments parquet 兜底 """ if capset.has(Cap.KLINE_DAILY_BATCH): try: @@ -54,9 +54,8 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]: except Exception as e: # noqa: BLE001 logger.warning("CN_Equity_A pool unavailable, fallback: %s", e) - # Free 用户兜底: instruments parquet + watchlist + demo + # Free 用户兜底: instruments parquet + demo base: set[str] = set(DEMO_SYMBOLS) - base.update(get_pool("watchlist")) d = Path(settings.data_dir) inst_path = d / "instruments" / "instruments.parquet" if inst_path.exists(): diff --git a/serve/backend/app/main.py b/serve/backend/app/main.py index e180569..c49c698 100644 --- a/serve/backend/app/main.py +++ b/serve/backend/app/main.py @@ -11,7 +11,7 @@ from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from app import __version__ -from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, kline, market_recap, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist +from app.api import analysis, auth as auth_api, data, ext_data, financials, indices, kline, market_recap, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy from app.api.routes import router as core_router from app.config import settings from app.jobs import daily_pipeline @@ -182,9 +182,7 @@ async def auth_middleware(request: Request, call_next): app.include_router(core_router) app.include_router(auth_api.router) app.include_router(kline.router) -app.include_router(watchlist.router) app.include_router(screener.router) -app.include_router(backtest.router) app.include_router(indices.router) app.include_router(overview.router) app.include_router(analysis.router) diff --git a/serve/backend/app/services/backtest.py b/serve/backend/app/services/backtest.py deleted file mode 100644 index 806541c..0000000 --- a/serve/backend/app/services/backtest.py +++ /dev/null @@ -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) diff --git a/serve/backend/app/services/extend_history.py b/serve/backend/app/services/extend_history.py index 1339a52..1222076 100644 --- a/serve/backend/app/services/extend_history.py +++ b/serve/backend/app/services/extend_history.py @@ -38,7 +38,7 @@ def _invalidate(table: str | None = None) -> None: def _resolve_universe(capset: CapabilitySet) -> list[str]: - """解析标的池 — 与 daily_pipeline 独立的副本。""" + """解析标的池。""" if capset.has(Cap.KLINE_DAILY_BATCH): try: from app.tickflow.pools import get_pool @@ -48,12 +48,11 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]: except Exception as e: logger.warning("CN_Equity_A pool unavailable: %s", e) - from app.tickflow.pools import DEMO_SYMBOLS, get_pool as _get_pool + from app.tickflow.pools import DEMO_SYMBOLS from app.config import settings from pathlib import Path import polars as pl base: set[str] = set(DEMO_SYMBOLS) - base.update(_get_pool("watchlist")) d = Path(settings.data_dir) inst_path = d / "instruments" / "instruments.parquet" if inst_path.exists(): diff --git a/serve/backend/app/services/preferences.py b/serve/backend/app/services/preferences.py index 39908cd..9c5870e 100644 --- a/serve/backend/app/services/preferences.py +++ b/serve/backend/app/services/preferences.py @@ -296,17 +296,6 @@ def set_nav_hidden(hidden: list[str]) -> list[str]: return get_nav_hidden() -def get_watchlist_columns() -> list[dict] | None: - """返回自选列表列配置。""" - return load().get("watchlist_columns") - - -def set_watchlist_columns(columns: list[dict]) -> list[dict]: - """保存自选列表列配置。""" - save({"watchlist_columns": columns}) - return columns - - def get_screener_result_columns() -> list[dict] | None: """返回策略结果列表列配置。""" return load().get("screener_result_columns") diff --git a/serve/backend/app/services/watchlist.py b/serve/backend/app/services/watchlist.py deleted file mode 100644 index 7515b72..0000000 --- a/serve/backend/app/services/watchlist.py +++ /dev/null @@ -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 diff --git a/serve/backend/app/tickflow/pools.py b/serve/backend/app/tickflow/pools.py index 77a45d9..6523c98 100644 --- a/serve/backend/app/tickflow/pools.py +++ b/serve/backend/app/tickflow/pools.py @@ -3,7 +3,6 @@ Phase 1 实现: - 常用指数成份(沪深 300 / 中证 500 / 上证 50)用 TickFlow `quote.pool` 端点拉取并缓存 - 全 A 通过 instruments.batch 获取 - - 自选池 = 用户的 watchlist """ from __future__ import annotations @@ -19,7 +18,7 @@ from app.tickflow.client import get_client logger = logging.getLogger(__name__) -PoolId = Literal["CSI300", "CSI500", "SSE50", "CN_Equity_A", "CN_Index", "watchlist"] +PoolId = Literal["CSI300", "CSI500", "SSE50", "CN_Equity_A", "CN_Index"] # TickFlow universe id 是它内部命名(见 tf.universes.list())。 # 没有官方对照表,启动时按名称模糊匹配从 universes.list() 里找。 @@ -54,9 +53,6 @@ def _pool_cache_path(pool_id: str) -> Path: def get_pool(pool_id: PoolId, refresh: bool = False) -> list[str]: """返回标的池里的 symbol 列表。""" - if pool_id == "watchlist": - return _load_watchlist() - cache = _pool_cache_path(pool_id) if cache.exists() and not refresh: df = pl.read_parquet(cache) @@ -132,17 +128,6 @@ def _fetch_pool(pool_id: PoolId) -> list[str]: return [] -def _load_watchlist() -> list[str]: - """读取用户自选(由 watchlist service 维护)。""" - path = settings.data_dir / "user_data" / "watchlist.parquet" - if not path.exists(): - return [] - df = pl.read_parquet(path) - if df.is_empty() or "symbol" not in df.columns: - return [] - return df["symbol"].to_list() - - # 兜底:Free 用户/无 API 时给一个小型可用集合,让 UI 不至于空白 DEMO_SYMBOLS = [ "600000.SH", # 浦发银行 diff --git a/serve/backend/app/tickflow/repository.py b/serve/backend/app/tickflow/repository.py index 7ddd521..1e8e519 100644 --- a/serve/backend/app/tickflow/repository.py +++ b/serve/backend/app/tickflow/repository.py @@ -56,7 +56,6 @@ class DataStore: "instruments_ext", "kline_ext", "pools", - "backtest_results", "screener_results", "ai_cache", "user_data", @@ -77,7 +76,7 @@ class DataStore: 背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录), 新版改为 exe_dir / "data" (子目录)。老用户首次升级时旧数据在兄弟目录, - 若不迁移会导致历史行情/策略/回测/监控全部"丢失"(实际还在旧位置)。 + 若不迁移会导致历史行情/策略/监控全部"丢失"(实际还在旧位置)。 策略 (仅打包桌面版触发, 开发/Docker 不受影响): 1. 旧目录存在且新 data/ 还基本为空 → 整目录搬迁 (shutil.move, 跨盘符安全)。 @@ -408,7 +407,7 @@ class KlineRepository: how="left", ) - # 缓存完整历史 (含指标+必要基础信息) 供 filter_history/backtest 直接复用 + # 缓存完整历史 (含指标+必要基础信息) self._enriched_history_cache = df_full self._enriched_history_start = df_full["date"].min() logger.info("enriched 历史缓存: %d rows, %s ~ %s", diff --git a/serve/backend/tests/backtest/test_engine_portfolio.py b/serve/backend/tests/backtest/test_engine_portfolio.py deleted file mode 100644 index 2c0ede5..0000000 --- a/serve/backend/tests/backtest/test_engine_portfolio.py +++ /dev/null @@ -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" diff --git a/serve/backend/tests/backtest/test_full_simulation_tail.py b/serve/backend/tests/backtest/test_full_simulation_tail.py deleted file mode 100644 index f5352b9..0000000 --- a/serve/backend/tests/backtest/test_full_simulation_tail.py +++ /dev/null @@ -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" diff --git a/serve/backend/tests/backtest/test_strategy_backtest_correctness.py b/serve/backend/tests/backtest/test_strategy_backtest_correctness.py deleted file mode 100644 index 6b55ba8..0000000 --- a/serve/backend/tests/backtest/test_strategy_backtest_correctness.py +++ /dev/null @@ -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) diff --git a/serve/frontend/src/components/ColumnCustomizer.tsx b/serve/frontend/src/components/ColumnCustomizer.tsx index 814dcd7..1f124d3 100644 --- a/serve/frontend/src/components/ColumnCustomizer.tsx +++ b/serve/frontend/src/components/ColumnCustomizer.tsx @@ -1,5 +1,5 @@ import { ListColumnCustomizer } from '@/components/ListColumnCustomizer' -import { COLUMN_GROUPS, type ColumnConfig } from '@/lib/watchlist-columns' +import type { ColumnConfig } from '@/lib/list-columns' interface ColumnCustomizerProps { columns: ColumnConfig[] @@ -12,7 +12,7 @@ export function ColumnCustomizer({ columns, onChange, open, onClose }: ColumnCus return ( 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 关闭 useEffect(() => { if (!symbol) return @@ -239,8 +222,6 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }} dateRange={dateRange} onMonitor={() => setShowMonitorEditor(true)} - inWatchlist={inWatchlist} - onToggleWatchlist={() => toggleWatchlist.mutate()} /> diff --git a/serve/frontend/src/components/screener/ScreenerTable.tsx b/serve/frontend/src/components/screener/ScreenerTable.tsx index 3ec62ea..2930da6 100644 --- a/serve/frontend/src/components/screener/ScreenerTable.tsx +++ b/serve/frontend/src/components/screener/ScreenerTable.tsx @@ -6,7 +6,7 @@ * score、signals、candle、ext 列。其余纯数据列(价格/指标/财务…)交给共享原语。 */ 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 { fmtPrice } from '@/lib/format' import type { ColumnConfig } from '@/lib/screener-columns' @@ -22,10 +22,7 @@ interface ScreenerTableProps { strategyIdToName: Record symbolStrategyMap: Map activeStrategy: string | null - watchlistSet: Set onPreview: (symbol: string, name: string) => void - onToggleWatchlist: (symbol: string, inList: boolean) => void - watchlistPending: boolean /** symbol → 日k 数据,仅当启用日k列时传入 */ klineData?: Record /** 日k蜡烛图是否显示(表头眼睛开关) */ @@ -114,7 +111,7 @@ function renderExtValue( export function ScreenerTable({ rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy, - watchlistSet, onPreview, onToggleWatchlist, watchlistPending, klineData = {}, + onPreview, klineData = {}, dailyKChartVisible = true, onToggleDailyKChart, sort, onSortToggle, }: ScreenerTableProps) { @@ -164,7 +161,6 @@ export function ScreenerTable({ switch (key) { case 'symbol': { const board = boardTag(r.symbol) - const inWatchlist = watchlistSet.has(r.symbol) return (
@@ -189,25 +185,10 @@ export function ScreenerTable({ )} - {isExpired ? ( + {isExpired && ( 失效 - ) : ( - )}
diff --git a/serve/frontend/src/components/screener/StrategySettingsDialog.tsx b/serve/frontend/src/components/screener/StrategySettingsDialog.tsx index f4eb336..d9ae17d 100644 --- a/serve/frontend/src/components/screener/StrategySettingsDialog.tsx +++ b/serve/frontend/src/components/screener/StrategySettingsDialog.tsx @@ -2,25 +2,36 @@ import { motion, AnimatePresence } from 'framer-motion' import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles } from 'lucide-react' import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api' -import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns' import { color } from '@/lib/colors' import { SignalPicker } from './SignalPicker' import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions' -// 内置列名 → 中文标签 -const FIELD_LABEL: Record = {} -for (const c of BUILTIN_COLUMNS) { - if (c.source.type === 'builtin') FIELD_LABEL[c.source.key] = c.label -} -// enriched 列名别名 -Object.assign(FIELD_LABEL, { +// 字段名 → 中文标签 +const FIELD_LABEL: Record = { + close: '收盘价', open: '开盘价', high: '最高价', low: '最低价', change_pct: '涨跌幅', consecutive_limit_ups: '连板', + momentum_5d: '5D动量', momentum_10d: '10D动量', + momentum_20d: '20D动量', momentum_30d: '30D动量', momentum_60d: '60D动量', turnover_rate: '换手率', + change_amount: '涨跌额', amplitude: '振幅', + volume: '成交量', amount: '成交额', rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24', vol_ratio_5d: '量比', vol_ratio_20d: '20日量比', + vol_ratio: '量比', macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱', 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 { strategyId: string | null diff --git a/serve/frontend/src/lib/api.ts b/serve/frontend/src/lib/api.ts index 96cf1c7..ceef787 100644 --- a/serve/frontend/src/lib/api.ts +++ b/serve/frontend/src/lib/api.ts @@ -192,23 +192,6 @@ export interface KlineRow { [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 { symbol: string name?: string | null @@ -505,111 +488,6 @@ export interface LimitLadderResult { sealed_counts_down?: { real: number; fake: number; pending: number } } -// ===== Backtest ===== -export interface BacktestResult { - run_id: string - config: any - stats: Record - 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 - 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[] - long_short_stats: Record - 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 - stats: Record - 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 ===== /** 端点发现清单 —— 对应 tickflow.org/endpoints.json */ @@ -856,15 +734,6 @@ export const api = { 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: () => request<{ columns: any[] | null }>('/api/settings/preferences/screener-result-columns'), @@ -976,37 +845,6 @@ export const api = { 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'), screenerRunPreset: (strategy_id: string, pool?: string[], asOf?: string, extColumns?: string) => request('/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('/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('/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 | null - overrides?: Record | 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('/api/backtest/strategy/run', { - method: 'POST', - body: JSON.stringify(payload), - }), - pipelineRun: () => request<{ job_id: string; reused: boolean }>( '/api/pipeline/run', { method: 'POST' }, ), diff --git a/serve/frontend/src/lib/backtestTask.ts b/serve/frontend/src/lib/backtestTask.ts deleted file mode 100644 index 2c2eb6d..0000000 --- a/serve/frontend/src/lib/backtestTask.ts +++ /dev/null @@ -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 { - 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 | null - overrides?: Record | 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 { - // 从 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) -} diff --git a/serve/frontend/src/lib/queryKeys.ts b/serve/frontend/src/lib/queryKeys.ts index 60e06d4..f0efbc9 100644 --- a/serve/frontend/src/lib/queryKeys.ts +++ b/serve/frontend/src/lib/queryKeys.ts @@ -16,11 +16,7 @@ export const QK = { overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const, indexList: ['index-list'] as const, - // Watchlist - 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, + // Search instrumentSearch: (q: string) => ['instrument-search', q] as const, // Screener @@ -31,9 +27,6 @@ export const QK = { marketSnapshot: ['market-snapshot'] as const, limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const, - // Backtest - backtestStatus: ['backtest-status'] as const, - // Data / Pipeline dataStatus: ['data-status'] as const, pipelineJobs: ['pipeline-jobs'] as const, diff --git a/serve/frontend/src/lib/screener-columns.ts b/serve/frontend/src/lib/screener-columns.ts index 01601a8..35a8126 100644 --- a/serve/frontend/src/lib/screener-columns.ts +++ b/serve/frontend/src/lib/screener-columns.ts @@ -1,8 +1,5 @@ /** * 策略结果列表自定义列配置。 - * - * 内置列与分组与自选页 (watchlist-columns) 保持一致,额外保留策略特有的 - * 「策略」「评分」两列。通用列模型/合并/扩展列参数来自 list-columns。 */ import { storage } from '@/lib/storage' import { diff --git a/serve/frontend/src/lib/signals.ts b/serve/frontend/src/lib/signals.ts index eff1b76..89a8c2e 100644 --- a/serve/frontend/src/lib/signals.ts +++ b/serve/frontend/src/lib/signals.ts @@ -1,7 +1,7 @@ /** - * 买卖触发器信号定义 — 选股页弹窗 / 回测页共用。 + * 买卖触发器信号定义 — 选股页弹窗共用。 * - * 信号 ID 必须与后端 backtest/strategy.py:_build_signal_mask 对齐 + * 信号 ID 必须与后端对齐 * (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。 */ diff --git a/serve/frontend/src/lib/storage.ts b/serve/frontend/src/lib/storage.ts index 1b3db9b..54a0b8f 100644 --- a/serve/frontend/src/lib/storage.ts +++ b/serve/frontend/src/lib/storage.ts @@ -27,27 +27,15 @@ export const storage = { /** 策略池 (screener) */ strategyPool: kv('strategy-pool'), - /** 自选列表列配置 */ - watchlistColumns: kv('watchlist_columns'), - /** 个股日K信息条指标配置 */ stockInfoBarFields: kv('stock_info_bar_fields'), /** 策略结果列表列配置 */ screenerResultColumns: kv('screener_result_columns'), - /** 自选列表视图模式 table | card */ - watchlistView: kv('watchlist_view'), - - /** 自选列表日K蜡烛图显示状态 */ - watchlistCandle: kv('watchlist_showCandle'), - /** 策略结果列表日K蜡烛图显示状态 */ screenerCandle: kv('screener_showCandle'), - /** 自选列表板块筛选 */ - watchlistBoardFilter: kv('watchlist_boardFilter'), - /** Screener 卡片尺寸 */ screenerCardSize: kv('screener-card-size'), @@ -78,31 +66,6 @@ export const storage = { /** 已保存策略的原始规则(策略ID → 规则文本) */ strategyRules: kv>('strategy-rules'), - /** 策略回测快捷区间按钮配置 */ - strategyBacktestQuickRanges: kv('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 - overrides?: Record - result: any - } | null>('strategy-backtest-last'), - /** 概念分析页面字段配置 */ conceptAnalysisConfig: kv>('concept-analysis-config'), diff --git a/serve/frontend/src/lib/useSharedMutations.ts b/serve/frontend/src/lib/useSharedMutations.ts index d4bb726..5ba20e3 100644 --- a/serve/frontend/src/lib/useSharedMutations.ts +++ b/serve/frontend/src/lib/useSharedMutations.ts @@ -1,18 +1,3 @@ /** * 共享 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() }) - }, - }) -} diff --git a/serve/frontend/src/lib/watchlist-columns.ts b/serve/frontend/src/lib/watchlist-columns.ts deleted file mode 100644 index 3c69cc4..0000000 --- a/serve/frontend/src/lib/watchlist-columns.ts +++ /dev/null @@ -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 { - 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 { - // 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) -} diff --git a/serve/frontend/src/pages/Backtest.tsx b/serve/frontend/src/pages/Backtest.tsx deleted file mode 100644 index 08ab0d7..0000000 --- a/serve/frontend/src/pages/Backtest.tsx +++ /dev/null @@ -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 = { - factor: { - title: '因子回测', - subtitle: '验证单个因子是否有预测能力', - hint: '看 IC / IR、分层收益和多空组合,适合先筛掉无效指标。', - }, - strategy: { - title: '策略回测', - subtitle: '验证完整选股和交易规则', - hint: '看净值曲线、回撤、胜率和交易明细,适合判断策略是否可执行。', - }, -} - -export function Backtest() { - const [activeTab, setActiveTab] = useState('strategy') - - const modeSwitch = ( -
- {(['factor', 'strategy'] as const).map(tab => { - const Icon = tab === 'factor' ? BarChart3 : FlaskConical - const active = activeTab === tab - return ( - - ) - })} -
- ) - - return ( -
- - -
- {activeTab === 'factor' && } - {activeTab === 'strategy' && } -
-
- ) -} diff --git a/serve/frontend/src/pages/LimitUpLadder.tsx b/serve/frontend/src/pages/LimitUpLadder.tsx index 3f1ef84..d034a52 100644 --- a/serve/frontend/src/pages/LimitUpLadder.tsx +++ b/serve/frontend/src/pages/LimitUpLadder.tsx @@ -12,7 +12,7 @@ import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { useCapabilities, usePreferences } from '@/lib/useSharedQueries' import { SealedBadge } from '@/components/SealedBadge' -import type { ExtColumnDisplayConfig } from '@/lib/watchlist-columns' +import type { ExtColumnDisplayConfig } from '@/lib/list-columns' // ===== Ext 字段配置 ===== diff --git a/serve/frontend/src/pages/Screener.tsx b/serve/frontend/src/pages/Screener.tsx index 2970546..d2bef6f 100644 --- a/serve/frontend/src/pages/Screener.tsx +++ b/serve/frontend/src/pages/Screener.tsx @@ -1,10 +1,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' 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 { useDataStatus, usePreferences } from '@/lib/useSharedQueries' -import { useWatchlistBatchAdd } from '@/lib/useSharedMutations' import { QK } from '@/lib/queryKeys' import { storage } from '@/lib/storage' import { PageHeader } from '@/components/PageHeader' @@ -35,7 +34,6 @@ export function Screener() { const [activeStrategy, setActiveStrategy] = useState(null) const [result, setResult] = useState(null) const [asOf, setAsOf] = useState('') - const [batchMsg, setBatchMsg] = useState('') const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, []) @@ -402,28 +400,6 @@ export function Screener() { const minDate = dataStatus.data?.enriched?.earliest_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({ 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 ( <> )} - {displayRows.length > 0 && ( - - )} - {batchMsg && ( - {batchMsg} - )} {!showAll && result && result.elapsed_ms > 0 && (
@@ -749,10 +694,7 @@ export function Screener() { strategyIdToName={strategyIdToName} symbolStrategyMap={symbolStrategyMap} activeStrategy={activeStrategy} - watchlistSet={watchlistSet} onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name) }} - onToggleWatchlist={(symbol, inList) => toggleWatchlist.mutate({ symbol, inList })} - watchlistPending={toggleWatchlist.isPending} klineData={klineData} dailyKChartVisible={dailyKChartVisible} onToggleDailyKChart={toggleDailyKChart} diff --git a/serve/frontend/src/pages/Watchlist.tsx b/serve/frontend/src/pages/Watchlist.tsx deleted file mode 100644 index 0414a4b..0000000 --- a/serve/frontend/src/pages/Watchlist.tsx +++ /dev/null @@ -1,1146 +0,0 @@ -import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { motion, AnimatePresence } from 'framer-motion' -import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp } from 'lucide-react' -import { api, type KlineRow } from '@/lib/api' -import { QK } from '@/lib/queryKeys' -import { storage } from '@/lib/storage' -import { fmtPrice, fmtPct, fmtBigNum, priceColorClass } from '@/lib/format' -import { PageHeader } from '@/components/PageHeader' -import { EmptyState } from '@/components/EmptyState' -import { StockPreviewDialog } from '@/components/StockPreviewDialog' -import { ColumnCustomizer } from '@/components/ColumnCustomizer' -import { StockDataTable } from '@/components/stock-table/StockDataTable' -import { useTableSort } from '@/components/stock-table/useTableSort' -import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick' -import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives' -import { getSignals, signalCls, getSortValue, UNSORTABLE_KEYS } from '@/lib/stock-table' -import { resolveCandleConfig } from '@/lib/list-columns' -import { - type ColumnConfig, - BUILTIN_COLUMNS, - COLUMN_GROUPS, - loadColumnConfig, - saveColumnConfig, - buildExtColumnsParam, -} from '@/lib/watchlist-columns' - -// ===== 板块标识(筛选/卡片用) ===== -// 注: boardTag(创/科/北 标签)已移至共享 @/components/stock-table/primitives - -const BOARDS = ['沪主板', '深主板', '创业板', '科创板', '北交所'] as const -type BoardType = typeof BOARDS[number] - -function getBoardType(symbol: string): BoardType | null { - if (/^(300|301)/.test(symbol)) return '创业板' - if (/^688/.test(symbol)) return '科创板' - if (/\.BJ$/.test(symbol)) return '北交所' - if (/^60[0135]/.test(symbol)) return '沪主板' - if (/^00[012]/.test(symbol)) return '深主板' - return null -} - -// ===== 换手率分档色(卡片/表格用) ===== - -function turnoverColor(rate: number | null | undefined): string { - if (rate == null || Number.isNaN(rate)) return 'text-[#888]' - if (rate < 5) return 'text-[#888]' - if (rate < 10) return 'text-[#d4a800]' - if (rate < 20) return 'text-[#f97316]' - if (rate < 35) return 'text-[#d94a3d]' - return 'text-[#b84a8a]' -} - -// ===== 动态列渲染 ===== -// 表头/单元格渲染已共享化:纯数据列由 @/components/stock-table/primitives 的 -// renderBuiltinDataCell 处理;symbol/signals/candle/ext 等需上下文的列由下方 -// 表格 renderCell 回调处理。表格骨架使用 StockDataTable。 - -/** 渲染扩展数据列的值(含分隔/标签/展开配置) */ -function renderExtValue( - val: any, - col: ColumnConfig, - expanded: boolean, - onToggle: () => void, - inline?: boolean, -): React.ReactNode { - if (val == null || Number.isNaN(val)) return - if (typeof val === 'number') { - // int 类型不显示小数 - const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val) - return {displayVal} - } - if (typeof val === 'boolean') { - return {val ? '是' : '否'} - } - - // String — 按 extDisplay 配置渲染 - const cfg = col.extDisplay - const str = String(val) - - // 纯文本模式 - if (cfg?.displayMode === 'text') { - return {str} - } - - // 标签模式(默认) - const separator = cfg?.separator?.trim() || null - const tags = separator - ? str.split(separator).map(s => s.trim()).filter(Boolean) - : str.split(/[、,,;;\-]/).map(s => s.trim()).filter(Boolean) - - if (tags.length === 0) return - - const maxTags = cfg?.maxTags ?? 0 - const showAll = maxTags <= 0 || expanded || tags.length <= maxTags - const sliced = showAll ? tags : tags.slice(0, maxTags) - const hiddenIndices = maxTags > 0 ? cfg?.hiddenIndices : undefined - const visibleTags = hiddenIndices?.length - ? sliced.filter((_, i) => !hiddenIndices.includes(i)) - : sliced - const hiddenCount = tags.length - visibleTags.length - - // 竖向排列:仅在表格视图、收起状态、设定了显示上限时生效 - const isVertical = !inline && cfg?.tagLayout === 'vertical' && !expanded - - const tagEls = ( - <> - {visibleTags.map((tag, i) => ( - - {tag} - - ))} - {!showAll && hiddenCount > 0 && ( - - )} - {showAll && maxTags > 0 && tags.length > maxTags && ( - - )} - - ) - - if (inline) { - // 卡片视图:返回 inline 片段 - return tagEls - } - // 表格视图:用
包裹 - return
{tagEls}
-} - -/** 渲染扩展数据列的 */ -function renderExtCell( - r: any, - col: ColumnConfig, - expandedCells: Set, - onToggleExpand: (key: string) => void, -): React.ReactNode { - if (col.source.type !== 'ext') return null - const { configId, fieldName } = col.source - const val = r[`${configId}__${fieldName}`] - const cellKey = `${r.symbol}::${col.id}` - const expanded = expandedCells.has(cellKey) - - const style: React.CSSProperties = {} - if (col.extDisplay?.maxWidth) { - style.maxWidth = col.extDisplay.maxWidth - } - - // 根据值类型决定 td class - const tdClass = val == null || Number.isNaN(val) - ? 'px-2 py-1.5 text-right num tabular-nums text-muted' - : typeof val === 'number' - ? 'px-2 py-1.5 text-right num tabular-nums' - : typeof val === 'boolean' - ? 'px-2 py-1.5 text-right' - : 'px-2 py-1.5' - - return ( - - {renderExtValue(val, col, expanded, () => onToggleExpand(cellKey))} - - ) -} - -// ===== 搜索框组件(紧凑内联式)===== - -function StockSearchBox({ - onPreview, - existingSymbols, - onAdd, -}: { - onPreview: (symbol: string, name: string) => void - existingSymbols: string[] - onAdd: (symbol: string) => void -}) { - const [query, setQuery] = useState('') - const [open, setOpen] = useState(false) - const containerRef = useRef(null) - const inputRef = useRef(null) - const [activeIdx, setActiveIdx] = useState(-1) - - const search = useQuery({ - queryKey: QK.instrumentSearch(query), - queryFn: () => api.instrumentSearch(query), - enabled: query.trim().length > 0, - staleTime: 30_000, - }) - - const results = search.data?.results ?? [] - - useEffect(() => { - function handleClick(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false) - } - } - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, []) - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Escape') { setOpen(false); return } - if (!open || results.length === 0) return - if (e.key === 'ArrowDown') { - e.preventDefault() - setActiveIdx(i => Math.min(i + 1, results.length - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setActiveIdx(i => Math.max(i - 1, -1)) - } else if (e.key === 'Enter') { - e.preventDefault() - if (activeIdx >= 0) handleSelect(results[activeIdx]) - else if (results.length > 0) handleSelect(results[0]) - } - } - - function handleSelect(r: { symbol: string; name: string }) { - onPreview(r.symbol, r.name) - setQuery('') - setOpen(false) - setActiveIdx(-1) - } - - return ( -
-
- - { setQuery(e.target.value); setOpen(true); setActiveIdx(-1) }} - onFocus={() => { if (query.trim()) setOpen(true) }} - onKeyDown={handleKeyDown} - className="w-44 h-8 pl-8 pr-2.5 rounded-btn bg-elevated border border-border text-xs text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 focus:w-56 transition-all duration-200" - /> -
- - - {open && results.length > 0 && ( - - {results.map((r, i) => { - const inWatchlist = existingSymbols.includes(r.symbol) - return ( -
- - -
- ) - })} -
- )} -
-
- ) -} - -// ===== 卡片组件 ===== - -function StockCard({ - r, - candleRows, - showCandle, - onPreview, - onConfirmRemove, - onCancelRemove, - onRequestRemove, - confirmRemove, - extCols, - expandedCells, - onToggleExpand, -}: { - r: any - candleRows: KlineRow[] - showCandle: boolean - onPreview: (symbol: string, name: string) => void - onConfirmRemove: (symbol: string) => void - onCancelRemove: () => void - onRequestRemove: (symbol: string) => void - confirmRemove: string | null - extCols: ColumnConfig[] - expandedCells: Set - onToggleExpand: (key: string) => void -}) { - const board = boardTag(r.symbol) - const price = r.rt_price ?? r.close - const pct = r.rt_pct ?? r.change_pct - const name = r.rt_name ?? r.name - const signals = getSignals(r) - const isUp = (pct ?? 0) > 0 - const isDown = (pct ?? 0) < 0 - - // 动态背景渐变: 涨=红底, 跌=绿底, 平=无色 - const bgGlow = isUp - ? 'bg-gradient-to-br from-bull/[0.06] via-transparent to-bull/[0.02]' - : isDown - ? 'bg-gradient-to-br from-bear/[0.06] via-transparent to-bear/[0.02]' - : '' - // 左侧指示条颜色 - const barColor = isUp ? 'bg-bull/70' : isDown ? 'bg-bear/70' : 'bg-muted/30' - // 涨跌幅标签背景 - const pctBg = isUp ? 'bg-bull/12 text-bull' : isDown ? 'bg-bear/12 text-bear' : 'bg-elevated text-secondary' - - return ( -
onPreview(r.symbol, name ?? '')} - > - {/* 左侧彩色指示条 */} -
- - {/* 删除按钮 / 确认区 */} -
- {confirmRemove === r.symbol ? ( -
e.stopPropagation()}> - - -
- ) : ( - - )} -
- - {/* 卡片内容 */} -
- {/* 第一行: 代码 + 名称 + 板块标识 */} -
- - {r.symbol} - - {name && ( - {name} - )} - {board && ( - - {board.label} - - )} - {r.consecutive_limit_ups > 0 && ( - - {r.consecutive_limit_ups === 1 ? '首板' : `${r.consecutive_limit_ups}连`} - - )} -
- - {/* 第二行: 大价格 + 涨跌幅胶囊 */} -
- - {fmtPrice(price)} - - {pct != null && ( - - {isUp ? '+' : ''}{pct.toFixed(2)}% - - )} -
- - {/* 第三行: 指标 */} -
- 换手{r.turnover_rate != null ? `${r.turnover_rate.toFixed(2)}%` : '—'} - 量比{fmtPrice(r.vol_ratio_5d)} - RSI{r.rsi_14 != null ? r.rsi_14.toFixed(1) : '—'} - {/* 扩展数据列展示在卡片中 */} - {extCols.map(col => { - if (col.source.type !== 'ext') return null - const { configId, fieldName } = col.source - const val = r[`${configId}__${fieldName}`] - if (val == null) return null - - const cellKey = `${r.symbol}::${col.id}` - const expanded = expandedCells.has(cellKey) - - return ( - - {fieldName} - - {renderExtValue(val, col, expanded, () => onToggleExpand(cellKey), true)} - - - ) - })} -
-
- - {/* 信号标签区 */} - {signals.length > 0 && ( -
- {signals.slice(0, 3).map(s => ( - - {s.label} - - ))} - {signals.length > 3 && ( - - +{signals.length - 3} - - )} -
- )} - - {/* 迷你蜡烛图 */} - {showCandle && candleRows.length > 0 && ( -
- -
- )} -
- ) -} - -// ===== 主页面 ===== - -export function Watchlist() { - const qc = useQueryClient() - const [viewMode, setViewMode] = useState<'table' | 'card'>(() => { - return (storage.watchlistView.get('table') as 'table' | 'card') - }) - const [dailyKChartVisible, setDailyKChartVisible] = useState(() => { - return storage.watchlistCandle.get(true) - }) - - // 列配置 — 从后端/localStorage 异步加载 - const [columns, setColumns] = useState([...BUILTIN_COLUMNS]) - const [customizerOpen, setCustomizerOpen] = useState(false) - const columnsLoaded = useRef(false) - - useEffect(() => { - if (columnsLoaded.current) return - columnsLoaded.current = true - loadColumnConfig().then(setColumns) - }, []) - - const handleColumnsChange = useCallback((next: ColumnConfig[]) => { - setColumns(next) - saveColumnConfig(next) - }, []) - - const candleColumn = useMemo(() => - columns.find(c => c.source.type === 'builtin' && c.source.key === 'candle' && c.visible), - [columns], - ) - const candleColumnEnabled = !!candleColumn - // 日k列渲染配置(来自列定制,已钳制边界) - const candleResolved = useMemo(() => resolveCandleConfig(candleColumn?.candleConfig), [candleColumn]) - const candleDays = candleResolved.days - const candleSize = dailyKChartVisible - ? { width: candleResolved.enabledWidth, height: candleResolved.enabledHeight } - : { width: candleResolved.disabledWidth, height: candleResolved.disabledHeight } - - const dailyKVisible = candleColumnEnabled && dailyKChartVisible - - // 计算可见列(列是否出现只由自定义列配置决定) - const visibleColumns = useMemo(() => { - return columns.filter(c => c.visible) - }, [columns]) - - // 计算 ext 列参数 - const extColumnsParam = useMemo(() => buildExtColumnsParam(columns), [columns]) - - const toggleView = useCallback(() => { - setViewMode(v => { - const next = v === 'table' ? 'card' : 'table' - storage.watchlistView.set(next) - return next - }) - }, []) - const toggleDailyKChart = useCallback(() => { - setDailyKChartVisible(v => { - const next = !v - storage.watchlistCandle.set(next) - return next - }) - }, []) - const [previewSymbol, setPreviewSymbol] = useState(null) - const [previewName, setPreviewName] = useState('') - const [expandedCells, setExpandedCells] = useState>(new Set()) - const closePreview = useCallback(() => { - setPreviewSymbol(null) - setPreviewName('') - }, []) - - const handleToggleExpand = useCallback((cellKey: string) => { - setExpandedCells(prev => { - const next = new Set(prev) - if (next.has(cellKey)) next.delete(cellKey) - else next.add(cellKey) - return next - }) - }, []) - - const list = useQuery({ - queryKey: QK.watchlist, - queryFn: api.watchlistList, - }) - - // enriched 数据 — 传入 ext_columns 参数 - const enriched = useQuery({ - queryKey: QK.watchlistEnriched(extColumnsParam), - queryFn: () => api.watchlistEnriched(extColumnsParam || undefined), - enabled: (list.data?.symbols.length ?? 0) > 0, - }) - - const symbols = enriched.data?.rows?.map((r: any) => r.symbol) ?? [] - const symbolsKey = symbols.join(',') - - // 批量日k数据 (天数由列配置决定) - const klineBatch = useQuery({ - queryKey: QK.watchlistKlineBatch(`${symbolsKey}|${candleDays}`), - queryFn: () => api.klineDailyBatch(symbols, candleDays), - enabled: dailyKVisible && symbols.length > 0, - staleTime: 5 * 60_000, // 5 分钟内不重请求 - }) - - const klineData = dailyKVisible ? (klineBatch.data?.data ?? {}) : {} - - const addMutation = useMutation({ - mutationFn: (sym: string) => api.watchlistAdd(sym), - onSuccess: (data) => { - qc.setQueryData(QK.watchlist, data) - qc.invalidateQueries({ queryKey: QK.watchlist }) - qc.invalidateQueries({ queryKey: ['watchlist-enriched'] }) - qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] }) - }, - }) - - const remove = useMutation({ - mutationFn: (sym: string) => api.watchlistRemove(sym), - onSuccess: (_data, sym) => { - // 1. 立即从 enriched 缓存中移除该股票,UI 即时更新 - qc.setQueryData(['watchlist-enriched', extColumnsParam], (old: any) => { - if (!old?.rows) return old - return { ...old, rows: old.rows.filter((r: any) => r.symbol !== sym) } - }) - // 2. 清除 list 缓存,触发后台 refetch - qc.invalidateQueries({ queryKey: QK.watchlist }) - qc.invalidateQueries({ queryKey: QK.watchlistEnriched() }) - qc.invalidateQueries({ queryKey: QK.watchlistKlineBatch('') }) - }, - }) - - const moveToTop = useMutation({ - mutationFn: (sym: string) => api.watchlistMoveToTop(sym), - onSuccess: (data) => { - qc.setQueryData(QK.watchlist, data) - qc.invalidateQueries({ queryKey: QK.watchlist }) - qc.invalidateQueries({ queryKey: ['watchlist-enriched'] }) - qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] }) - qc.invalidateQueries({ queryKey: QK.preferences }) - }, - }) - - const clearAll = useMutation({ - mutationFn: () => api.watchlistClear(), - onSuccess: () => { - setConfirmClear(false) - // 立即清空 enriched 缓存 - qc.setQueryData(['watchlist-enriched', extColumnsParam], { rows: [], as_of: null, elapsed_ms: 0 }) - qc.invalidateQueries({ queryKey: QK.watchlist }) - qc.invalidateQueries({ queryKey: QK.watchlistEnriched() }) - qc.invalidateQueries({ queryKey: QK.watchlistKlineBatch('') }) - }, - }) - - // 二次确认状态 - const [confirmClear, setConfirmClear] = useState(false) - const [confirmRemove, setConfirmRemove] = useState(null) - - const allSymbols = list.data?.symbols?.map(s => s.symbol) ?? [] - const rows = enriched.data?.rows ?? [] - - // ===== 筛选 ===== - const [filterOpen, setFilterOpen] = useState(false) - const [filters, setFilters] = useState>({}) - - // 板块筛选(持久化) - const [boardFilter, setBoardFilter] = useState>(() => { - const saved = storage.watchlistBoardFilter.get([]) - return saved.length > 0 ? new Set(saved) : new Set(BOARDS) // 默认全选 - }) - const persistBoardFilter = useCallback((next: Set) => { - setBoardFilter(next) - storage.watchlistBoardFilter.set([...next]) - }, []) - - const toggleBoard = useCallback((board: string) => { - setBoardFilter(prev => { - const next = new Set(prev) - if (next.has(board)) next.delete(board) - else next.add(board) - persistBoardFilter(next) - return next - }) - }, [persistBoardFilter]) - - const updateFilter = useCallback((colId: string, patch: { min?: string; max?: string; text?: string }) => { - setFilters(prev => { - const next = { ...prev } - const existing = next[colId] || {} - const merged = { ...existing, ...patch } - if (!merged.min && !merged.max && !merged.text) { - delete next[colId] - } else { - next[colId] = merged - } - return next - }) - }, []) - - const clearFilters = useCallback(() => setFilters({}), []) - - // 可筛选的内置列 - const filterableBuiltinCols = useMemo( - () => columns.filter(c => c.source.type === 'builtin' && !UNSORTABLE_KEYS.has(c.source.key) && c.id !== 'builtin:symbol'), - [columns], - ) - - // 按类别索引(复用列配置的分组定义) - const colsByCategory = useMemo(() => { - const map: Record = {} - for (const cat of COLUMN_GROUPS) { - map[cat.label] = [] - for (const key of cat.keys) { - const col = filterableBuiltinCols.find(c => c.source.type === 'builtin' && c.source.key === key) - if (col) map[cat.label].push({ id: col.id, label: col.label, col }) - } - } - return map - }, [filterableBuiltinCols]) - - // 筛选 + 排序 - const filteredRows = useMemo(() => { - // 板块筛选(全选时跳过) - let result = rows - if (boardFilter.size > 0 && boardFilter.size < BOARDS.length) { - result = result.filter(r => { - const board = getBoardType(r.symbol) - return board != null && boardFilter.has(board) - }) - } - // 数值/文本筛选 - const activeFilters = Object.entries(filters).filter(([, v]) => v.min || v.max || v.text) - if (activeFilters.length > 0) { - result = result.filter(r => { - for (const [colId, f] of activeFilters) { - const col = columns.find(c => c.id === colId) - if (!col) continue - const val = getSortValue(r, col) - if (val == null) return false - if (typeof val === 'number') { - if (f.min && val < Number(f.min)) return false - if (f.max && val > Number(f.max)) return false - } else { - if (f.text && !String(val).includes(f.text)) return false - } - } - return true - }) - } - return result - }, [rows, filters, columns, boardFilter]) - - const activeFilterCount = Object.values(filters).filter(v => v.min || v.max || v.text).length - - // 排序(复用共享三态排序 hook) - const { sort, toggle: handleSortToggle, sortRows } = useTableSort() - - const sortedRows = useMemo( - () => sortRows(filteredRows, columns), - [filteredRows, sortRows, columns], - ) - - // 可见的 ext 列(卡片视图使用) - const visibleExtCols = useMemo( - () => visibleColumns.filter(c => c.source.type === 'ext'), - [visibleColumns] - ) - - // 被过滤掉的个股数 (筛选/板块过滤导致的隐藏) - const hiddenCount = Math.max(0, allSymbols.length - sortedRows.length) - - return ( -
- - {/* 计数胶囊: 显示数/总数, mono 字体突出数字 */} - - {sortedRows.length} - / - {allSymbols.length} - - - {/* 过滤提示: 仅在有隐藏时出现, 柔和橙色融入整体 */} - {hiddenCount > 0 && ( - - - 已过滤 {hiddenCount} - - )} - - } - right={ -
- {/* 筛选 / 搜索 */} - - { setPreviewSymbol(sym); setPreviewName(name) }} - existingSymbols={allSymbols as string[]} - onAdd={(sym) => addMutation.mutate(sym)} - /> -
- {/* 视图 */} - -
- {/* 自定义列 / 刷新 */} - - - {allSymbols.length > 0 && ( - <> -
- - - )} -
- } - /> - - {/* 筛选栏 */} - {filterOpen && ( -
- {/* 板块筛选 */} -
-
板块
-
- {BOARDS.map(board => { - const active = boardFilter.has(board) - return ( - - ) - })} -
-
- {COLUMN_GROUPS.map(cat => { - const items = colsByCategory[cat.label]?.filter(i => i.col) - if (!items?.length) return null - return ( -
-
{cat.label}
-
- {items.map(item => { - const f = filters[item.id] || {} - const hasFilter = !!f.min || !!f.max || !!f.text - return ( -
- {item.label} - updateFilter(item.id, { min: e.target.value })} - placeholder="min" - className={`w-12 h-5 rounded border text-[10px] px-1 placeholder:text-muted focus:outline-none ${ - hasFilter ? 'border-accent/30 bg-accent/5' : 'border-border bg-elevated' - } text-foreground focus:border-accent/50`} - /> - ~ - updateFilter(item.id, { max: e.target.value })} - placeholder="max" - className={`w-12 h-5 rounded border text-[10px] px-1 placeholder:text-muted focus:outline-none ${ - hasFilter ? 'border-accent/30 bg-accent/5' : 'border-border bg-elevated' - } text-foreground focus:border-accent/50`} - /> -
- ) - })} -
-
- ) - })} - {activeFilterCount > 0 && ( - - )} -
- )} - - {/* 可滚动列表区 — 占满剩余高度,内部独立滚动,表头 sticky 固定 */} -
-
- {/* 列表 */} - {list.isLoading &&
加载中…
} - {list.isError &&
读取自选失败
} - - {allSymbols.length === 0 ? ( - - ) : viewMode === 'table' ? ( - r.symbol} - rowClassName={() => 'border-t border-border hover:bg-elevated/50 transition-colors duration-150 ease-smooth'} - // 日k列表头:标签 + 显示/隐藏眼睛按钮 - renderHeaderContent={(col) => { - if (col.source.type === 'builtin' && col.source.key === 'candle') { - return ( - - {col.label} - - - ) - } - return undefined - }} - renderCell={(r: any, col: ColumnConfig) => { - // ext 列 - if (col.source.type === 'ext') { - return renderExtCell(r, col, expandedCells, handleToggleExpand) - } - const key = col.source.key - const price = r.rt_price ?? r.close - const pct = r.rt_pct ?? r.change_pct - const name = r.rt_name ?? r.name - // 自选页 symbol 列:预览 + 内嵌删除(减号图标,二次确认) - if (key === 'symbol') { - const board = boardTag(r.symbol) - return ( - -
- - {/* 删除入口:默认减号图标,二次确认时替换为确定按钮 */} -
- {confirmRemove === r.symbol ? ( -
- - -
- ) : ( -
- - -
- )} -
-
- - ) - } - // 实时行情列:price/pct/amount 使用 rt_ 回退(自选页有实时推送) - const numCls = 'px-2 py-1.5 text-right num tabular-nums' - if (key === 'price') { - return {fmtPrice(price)} - } - if (key === 'pct') { - return {fmtPct(pct)} - } - if (key === 'amount') { - return {fmtBigNum(r.rt_amount ?? r.amount)} - } - if (key === 'turnover') { - return {r.turnover_rate != null ? `${r.turnover_rate.toFixed(2)}%` : '—'} - } - // 信号列 - if (key === 'signals') { - const signals = getSignals(r) - return ( - - {signals.length > 0 && ( -
- {signals.slice(0, 3).map((s) => ( - - {s.label} - - ))} - {signals.length > 3 && ( - +{signals.length - 3} - )} -
- )} - - ) - } - // 日k列 - if (key === 'candle') { - return ( - - - - ) - } - // 其余纯数据列 → 共享原语 - return renderBuiltinDataCell(r, col) - }} - className="rounded-card overflow-x-auto" - /> - ) : ( -
- {rows.map((r: any) => ( - { setPreviewSymbol(sym); setPreviewName(name) }} - onConfirmRemove={(sym) => { remove.mutate(sym); setConfirmRemove(null) }} - onCancelRemove={() => setConfirmRemove(null)} - onRequestRemove={(sym) => setConfirmRemove(sym)} - confirmRemove={confirmRemove} - extCols={visibleExtCols} - expandedCells={expandedCells} - onToggleExpand={handleToggleExpand} - /> - ))} -
- )} -
-
- - {/* 清空确认弹窗 */} - - {confirmClear && ( -
- setConfirmClear(false)} - /> - -

确认清空自选

-

- 将移除全部 {allSymbols.length} 只自选股,此操作不可恢复。 -

-
- - -
-
-
- )} -
- - {/* 列自定义侧栏 */} - setCustomizerOpen(false)} - /> - - -
- ) -} diff --git a/serve/frontend/src/pages/backtest/FactorBacktest.tsx b/serve/frontend/src/pages/backtest/FactorBacktest.tsx deleted file mode 100644 index 3c44771..0000000 --- a/serve/frontend/src/pages/backtest/FactorBacktest.tsx +++ /dev/null @@ -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 ( -
-
{label}
-
- {value ?? '—'} -
-
- ) -} - -function LoadingPanel({ symbolsText }: { symbolsText: string }) { - return ( -
-
-
-
-
正在计算因子分析
-
{symbolsText} · 完成后会一次性刷新 IC、分层收益和净值曲线。
-
-
-
-
-
-
-
- -
- {['读取因子', '计算 IC', '分层回测', '汇总指标'].map(item => ( -
-
-
{item}
-
- ))} -
- -
-
-
分层净值预览
-
等待后端返回完整结果
-
-
-
- {[46, 38, 54, 50, 64, 58, 74, 68, 84, 78, 90, 86].map((h, i) => ( -
- ))} -
-
-
-
- ) -} - -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(null) - - const columns = useQuery({ - queryKey: ['backtest-factor-columns'], - queryFn: api.factorColumns, - }) - - // 按 group 分类的因子 - const factorGroups = useMemo(() => { - const cols = columns.data?.columns ?? [] - const groups: Record = {} - 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 ( -
- {/* 配置面板 */} -
-
-
因子配置
-
选择因子、区间和分组方式。默认最近 3 个月。
-
- -
- - - {factorDesc && ( -

{factorDesc}

- )} -
- -
- - 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`} - /> -
- -
-
-
回测区间
- - {rangeTitle} - -
- -
-
- - -
-
- - -
-
- -
- - - - -
-
- -
-
- - -
-
- - -
-
- - setFees(e.target.value)} - className={INPUT_CLS} /> -
-
- - -
- - {/* 结果面板 */} -
- {result?.error && !result.ic_mean && ( -
- {result.error} -
- )} - - {run.isError && ( -
- {String((run.error as any).message)} -
- )} - - {!result && !run.isPending && ( - - )} - - {run.isPending && result && ( -
- 正在重新计算,当前暂时展示上一次因子分析结果,完成后会自动替换。 -
- )} - - {run.isPending && !result && ( - - )} - - {result && result.ic_mean != null && ( - - {/* IC/IR 指标 */} -
-
-

因子预测能力

-
- - Rank IC · 日度调仓 - - {result.elapsed_ms > 0 && ( - - - {result.elapsed_ms.toFixed(0)} ms - - )} -
-
-
- 0.03 ? 'bull' : result.ic_mean < -0.03 ? 'bear' : 'neutral' - : undefined} - /> - - 0.5 ? (result.ir > 0 ? 'bull' : 'bear') : 'neutral' - : undefined} - /> - -
-
- - {/* IC 时序图 */} - {result.ic_series.length > 0 && ( -
-
- IC 时序 -
-
- -
-
- )} - - {/* 分层净值 */} - {result.group_nav.length > 0 && ( -
-
- 分层净值曲线 -
-
- -
-
- )} - - {/* 分层统计表 */} - {result.group_stats.length > 0 && ( -
- - - - - - - - - - - - - {result.group_stats.map((g: GroupStat) => ( - - - - - - - - - ))} - {/* 多空行 */} - {result.long_short_stats?.total_return != null && ( - - - - - - - - - )} - -
分组总收益年化最大回撤夏普胜率
{g.label} - {fmtPct(g.total_return)} - - {fmtPct(g.annual_return)} - {fmtPct(g.max_drawdown)}{g.sharpe?.toFixed(2)}{fmtPct(g.win_rate)}
- 多空({result.long_short_stats.top_group ?? ''}-{result.long_short_stats.bottom_group ?? ''}) - - {fmtPct(result.long_short_stats.total_return as number)} - - {fmtPct(result.long_short_stats.max_drawdown as number)} -
-
- )} - - {/* 数据概要 */} -
- {result.n_symbols} 只标的 - {result.n_dates} 个交易日 - run_id: {result.run_id} -
-
- )} -
-
- ) -} diff --git a/serve/frontend/src/pages/backtest/StrategyBacktest.tsx b/serve/frontend/src/pages/backtest/StrategyBacktest.tsx deleted file mode 100644 index bc951c5..0000000 --- a/serve/frontend/src/pages/backtest/StrategyBacktest.tsx +++ /dev/null @@ -1,2268 +0,0 @@ -import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react' -import { useQuery } from '@tanstack/react-query' -import { motion } from 'framer-motion' -import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus } from 'lucide-react' -import { - api, - type StrategyBacktestResult, - type StrategyBacktestTrade, - type StrategyDetail, - type StrategyParamDef, -} from '@/lib/api' -import { QK } from '@/lib/queryKeys' -import { tierRank } from '@/lib/capability-labels' -import { storage } from '@/lib/storage' -import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format' -import { boardTag } from '@/lib/board' -import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns' -import { SignalPicker } from '@/components/screener/SignalPicker' -import { startBacktest, stopBacktest, tryReconnect, useBacktestTask } from '@/lib/backtestTask' -import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries' -import { EmptyState } from '@/components/EmptyState' -import { WarmupBadge } from '@/components/WarmupBadge' -import { DatePicker } from '@/components/DatePicker' -import { StrategyNavChart } from './charts/StrategyNavChart' -import { ReturnDistributionChart } from './charts/ReturnDistributionChart' -import { TradeKlineModal } from './components/TradeKlineModal' -import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions' - -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) - -type QuickRangeUnit = 'month' | 'year' | 'all' -type QuickRangeConfig = { id: string; enabled: boolean; unit: QuickRangeUnit; value: number } - -const QUICK_RANGE_LIMITS = { - month: { min: 1, max: 120 }, - year: { min: 1, max: 10 }, -} as const -const DEFAULT_QUICK_RANGES: QuickRangeConfig[] = [ - { id: 'range-1', enabled: true, unit: 'month', value: 3 }, - { id: 'range-2', enabled: true, unit: 'month', value: 6 }, - { id: 'range-3', enabled: true, unit: 'year', value: 1 }, - { id: 'range-4', enabled: true, unit: 'all', value: 0 }, -] -const quickRangeValue = (unit: QuickRangeUnit, value: unknown, fallback: number) => { - if (unit === 'all') return 0 - const limits = QUICK_RANGE_LIMITS[unit] - const num = Number(value) - const safe = Number.isFinite(num) ? Math.round(num) : fallback - return clamp(safe, limits.min, limits.max) -} -const normalizeQuickRange = (raw: unknown, fallback: QuickRangeConfig): QuickRangeConfig => { - const obj = raw && typeof raw === 'object' ? raw as Partial : {} - const unit: QuickRangeUnit = obj.unit === 'month' || obj.unit === 'year' || obj.unit === 'all' - ? obj.unit - : fallback.unit - const enabled = typeof obj.enabled === 'boolean' ? obj.enabled : fallback.enabled - return { id: fallback.id, enabled, unit, value: quickRangeValue(unit, obj.value, fallback.value) } -} -const normalizeQuickRanges = (raw: unknown) => { - const items = Array.isArray(raw) ? raw : [] - const ranges = DEFAULT_QUICK_RANGES.map((fallback, index) => { - const byId = items.find(item => item && typeof item === 'object' && (item as { id?: unknown }).id === fallback.id) - return normalizeQuickRange(byId ?? items[index], fallback) - }) - return ranges.some(range => range.enabled) - ? ranges - : ranges.map((range, index) => index === 0 ? { ...range, enabled: true } : range) -} -const loadQuickRanges = () => normalizeQuickRanges(storage.strategyBacktestQuickRanges.get(DEFAULT_QUICK_RANGES)) -const quickRangeMonths = (range: QuickRangeConfig) => range.unit === 'year' ? range.value * 12 : range.value -const quickRangeLabel = (range: QuickRangeConfig) => range.unit === 'all' - ? '全部' - : range.unit === 'year' - ? `${range.value}年` - : `${range.value}个月` -const quickRangeTitle = (range: QuickRangeConfig) => range.unit === 'all' - ? '全部历史' - : range.unit === 'year' - ? `近 ${range.value} 年` - : `近 ${range.value} 个月` - -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` - -const SRC_MAP: Record = { builtin: '内置', custom: '自定义', ai: 'AI' } -const TRADE_PAGE_SIZE_OPTIONS = [10, 20, 30, 50, 100] -const BADGE_CLS_MAP: Record = { - builtin: 'bg-secondary/10 text-muted border-border', - ai: 'bg-purple-500/10 text-purple-400 border-purple-500/30', - custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30', -} -const FIELD_LABEL: Record = {} -for (const c of BUILTIN_COLUMNS) { - if (c.source.type === 'builtin') FIELD_LABEL[c.source.key] = c.label -} -Object.assign(FIELD_LABEL, { - change_pct: '涨跌幅', consecutive_limit_ups: '连板', - momentum_60d: '60D动量', turnover_rate: '换手率', - rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24', - vol_ratio_5d: '量比', vol_ratio_20d: '20日量比', - macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱', - boll_upper: '布林上轨', boll_lower: '布林下轨', -}) -const BOARD_OPTIONS = ['沪主板', '深主板', '创业板', '科创板', '北交所'] -const BASIC_FILTER_FIELDS = [ - { key: 'price_min', label: '最低价', unit: '元' }, - { key: 'price_max', label: '最高价', unit: '元' }, - { key: 'amount_min', label: '最低成交额', unit: '亿', scale: 1e8 }, - { key: 'market_cap_min', label: '最低总市值', unit: '亿', scale: 1e8 }, - { key: 'turnover_min', label: '最低换手率', unit: '%' }, - { key: 'turnover_max', label: '最高换手率', unit: '%' }, -] -type AdvancedSettingsTab = 'params' | 'filter' | 'entry' | 'exit' | 'scoring' | 'risk' | 'range' -type StrategyGroup = 'all' | 'custom' | 'ai' | 'builtin' -const STRATEGY_GROUPS: { id: StrategyGroup; label: string }[] = [ - { id: 'all', label: '全部' }, - { id: 'custom', label: '自定义' }, - { id: 'ai', label: 'AI' }, - { id: 'builtin', label: '内置' }, -] -const ADVANCED_TABS: { id: AdvancedSettingsTab; label: string }[] = [ - { id: 'params', label: '策略参数' }, - { id: 'filter', label: '基础过滤' }, - { id: 'entry', label: '买入触发器' }, - { id: 'exit', label: '卖出触发器' }, - { id: 'scoring', label: '评分权重' }, - { id: 'risk', label: '风控' }, - { id: 'range', label: '回测范围' }, -] -const toSignalId = (sig: string) => (sig.startsWith('signal_') || sig.startsWith('csg_')) ? sig : `signal_${sig}` -const numOrNull = (v: string) => v === '' || Number.isNaN(Number(v)) ? null : Number(v) -const clamp = (v: number, min?: number, max?: number) => { - let next = v - if (min != null) next = Math.max(next, min) - if (max != null) next = Math.min(next, max) - return next -} -const strategyDefaultParams = (detail: StrategyDetail) => { - const values: Record = { ...detail.params_defaults } - detail.params.forEach(p => { - if (!(p.id in values)) values[p.id] = p.default - }) - return values -} -const buildDefaultOverrides = (detail: StrategyDetail) => ({ - basic_filter: { ...detail.basic_filter }, - entry_signals: detail.entry_signals.map(toSignalId), - exit_signals: detail.exit_signals.map(toSignalId), - scoring: { ...detail.scoring }, - stop_loss: detail.stop_loss, - take_profit: detail.take_profit, - trailing_stop: detail.trailing_stop, - trailing_take_profit_activate: detail.trailing_take_profit_activate, - trailing_take_profit_drawdown: detail.trailing_take_profit_drawdown, - score_min: null, - score_max: null, - max_hold_days: detail.max_hold_days, -}) - -const fmtMoney = (v: number | null | undefined) => { - if (v == null || Number.isNaN(v)) return '—' - return v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) -} - -const fmtSignedMoney = (v: number | null | undefined) => { - if (v == null || Number.isNaN(v)) return '—' - const sign = v > 0 ? '+' : '' - return `${sign}${fmtMoney(v)}` -} - -const fmtShares = (v: number | null | undefined) => { - if (v == null || Number.isNaN(v)) return '—' - return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 }) -} - -const fmtLots = (v: number | null | undefined) => { - if (v == null || Number.isNaN(v)) return '—' - return v.toLocaleString('zh-CN', { maximumFractionDigits: 2 }) -} - -const statValueColor = (v: number | null | undefined) => { - if (v == null || Number.isNaN(v) || v === 0) return '#f8fafc' - return v > 0 ? '#f87171' : '#34d399' -} - -function ExitReasonBadge({ reason }: { reason: string }) { - const config: Record = { - signal: { label: '信号', cls: 'bg-accent/10 text-accent border-accent/30' }, - stop_loss: { label: '止损', cls: 'bg-red-500/10 text-red-400 border-red-500/30' }, - take_profit: { label: '止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' }, - trailing_stop: { label: '移损', cls: 'bg-orange-500/10 text-orange-400 border-orange-500/30' }, - trailing_take_profit: { label: '回撤止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' }, - max_hold: { label: '超期', cls: 'bg-amber-400/10 text-amber-400 border-amber-400/30' }, - pending_exit: { label: '待卖', cls: 'bg-orange-400/10 text-orange-400 border-orange-400/30' }, - end: { label: '期末', cls: 'bg-secondary/10 text-secondary border-border' }, - } - const c = config[reason] ?? { label: reason, cls: 'bg-elevated text-muted border-border' } - return ( - {c.label} - ) -} - -type DailyTradeRow = { - date: string - buys: StrategyBacktestTrade[] - sells: StrategyBacktestTrade[] - buyValue: number - sellValue: number - realizedPnl: number - cumulativePnl: number -} - -function fmtPositionPct(v: number | null | undefined, digits = 2): string { - if (v == null || Number.isNaN(v)) return '—' - return `${(Math.abs(v) * 100).toFixed(digits)}%` -} - -function fmtScore(v: number | null | undefined): string { - if (v == null || Number.isNaN(Number(v))) return '—' - return Number(v).toFixed(1) -} - -function DailyTradeChip({ trade, side, strategyName, onClick }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell'; strategyName?: string; onClick?: () => void }) { - const isBuy = side === 'buy' - const tag = boardTag(trade.symbol) - const price = isBuy ? trade.entry_price : trade.exit_price - const amount = isBuy ? trade.entry_value : trade.exit_value - const pnlColor = priceColorClass(trade.pnl_amount ?? trade.pnl_pct) - const footerColor = isBuy ? 'text-secondary' : pnlColor - const footerText = `仓位 ${fmtPositionPct(trade.position_pct, 2)}` - const scoreText = fmtScore(trade.entry_score) - const buyStrategy = strategyName || '策略' - - return ( - - ) -} - -function TradeLegCell({ trade, side }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell' }) { - const isBuy = side === 'buy' - const date = String(isBuy ? trade.entry_date : trade.exit_date).slice(0, 10) - const signalDate = String(isBuy ? trade.entry_signal_date ?? '' : trade.exit_signal_date ?? '').slice(0, 10) - const price = isBuy ? trade.entry_price : trade.exit_price - const amount = isBuy ? trade.entry_value : trade.exit_value - - return ( -
-
- {date} - - {isBuy ? '买' : '卖'} - -
-
- {fmtPrice(price)} - {fmtMoney(amount)} -
- {signalDate && signalDate !== date && ( -
信号 {signalDate}
- )} -
- ) -} - -function fmtDuration(ms: number): string { - const s = ms / 1000 - if (s < 1) return `${ms.toFixed(0)}ms` - if (s < 60) return `${s.toFixed(1)}秒` - const m = Math.floor(s / 60) - const rest = Math.round(s % 60) - return `${m}分${rest}秒` -} - -function SharpeLabel() { - const [open, setOpen] = useState(false) - const [alignRight, setAlignRight] = useState(false) - const ref = useRef(null) - useEffect(() => { - if (!open) return - const onClick = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) - } - document.addEventListener('mousedown', onClick) - return () => document.removeEventListener('mousedown', onClick) - }, [open]) - const toggle = () => { - if (!open && ref.current) { - const rect = ref.current.getBoundingClientRect() - setAlignRight(rect.left + 240 > window.innerWidth) - } - setOpen(o => !o) - } - return ( - - 夏普 - - {open && ( - - 夏普比率 (Sharpe Ratio) - 衡量单位波动风险换来的超额收益。 - 数值越高,收益相对波动越优秀; - 短周期或交易次数少时容易偏高,仅供参考。 - - )} - - ) -} - -function Stat({ label, value, color }: { label: ReactNode; value: string; color?: string }) { - return ( -
-
{label}
-
- {value} -
-
- ) -} - -function ConfigSection({ title, hint, actions, children }: { title: string; hint?: ReactNode; actions?: ReactNode; children: ReactNode }) { - return ( -
-
-
- {title} - {hint && {hint}} -
- {actions &&
{actions}
} -
-
{children}
-
- ) -} - - -const scoringToPct = (values: Record) => { - const total = Object.values(values).reduce((a, b) => a + Math.max(0, Number(b) || 0), 0) - if (total <= 0) return Object.fromEntries(Object.keys(values).map(k => [k, 0])) as Record - return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, Math.round((Math.max(0, Number(v) || 0) / total) * 100)])) as Record -} - -const normalizePctWeights = (values: Record) => { - const total = Object.values(values).reduce((a, b) => a + Math.max(0, Number(b) || 0), 0) - if (total <= 0) return Object.fromEntries(Object.keys(values).map(k => [k, 0])) as Record - return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, +(Math.max(0, Number(v) || 0) / total).toFixed(4)])) as Record -} - -function ScoringWeightRow({ name, weight, pct, editing, onChange }: { - name: string - weight: number - pct: number - editing: boolean - onChange: (value: number) => void -}) { - const label = FIELD_LABEL[name] ?? name - return ( -
- {label} - {editing ? ( - onChange(Number(e.target.value))} - className="h-1 flex-1 cursor-pointer accent-amber-400" - /> - ) : ( -
-
-
- )} - {editing ? weight : `${pct}%`} -
- ) -} - -function StrategyParamInput({ param, value, onChange }: { - param: StrategyParamDef - value: any - onChange: (value: any) => void -}) { - if (param.type === 'bool') { - const checked = value === true || value === 'true' || value === 'True' || value === true - return ( - - ) - } - if (param.type === 'select') { - return ( - - ) - } - return ( - - ) -} - -function StockPoolPicker({ value, onChange }: { value: string; onChange: (value: string) => void }) { - const symbols = useMemo(() => value.split(',').map(s => s.trim()).filter(Boolean), [value]) - const [query, setQuery] = useState('') - const [open, setOpen] = useState(false) - const [symbolNames, setSymbolNames] = useState>({}) - const ref = useRef(null) - const search = useQuery({ - queryKey: QK.instrumentSearch(query), - queryFn: () => api.instrumentSearch(query), - enabled: query.trim().length > 0, - staleTime: 30_000, - }) - const results = search.data?.results ?? [] - // 自选列表 — 供「从自选导入」一键填入回测范围 - const watchlist = useQuery({ - queryKey: QK.watchlist, - queryFn: () => api.watchlistList(), - staleTime: 30_000, - }) - - useEffect(() => { - if (results.length === 0) return - setSymbolNames(prev => { - const next = { ...prev } - results.forEach(r => { - if (r.name) next[r.symbol] = r.name - }) - return next - }) - }, [results]) - - useEffect(() => { - const onClick = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) - } - document.addEventListener('mousedown', onClick) - return () => document.removeEventListener('mousedown', onClick) - }, []) - - const setSymbols = (next: string[]) => onChange(Array.from(new Set(next)).join(',')) - const addSymbol = (symbol: string, name?: string | null) => { - if (name) setSymbolNames(prev => ({ ...prev, [symbol]: name })) - setSymbols([...symbols, symbol]) - setQuery('') - setOpen(false) - } - const removeSymbol = (symbol: string) => setSymbols(symbols.filter(s => s !== symbol)) - // 一键导入自选: 合并去重, 顺带回填股票名 - const importFromWatchlist = () => { - const entries = watchlist.data?.symbols ?? [] - if (entries.length === 0) return - setSymbolNames(prev => { - const next = { ...prev } - entries.forEach(e => { if (e.name) next[e.symbol] = e.name }) - return next - }) - setSymbols([...symbols, ...entries.map(e => e.symbol)]) - } - const watchlistCount = watchlist.data?.symbols?.length ?? 0 - - return ( -
-
-
- - { setQuery(e.target.value); setOpen(true) }} - onFocus={() => { if (query.trim()) setOpen(true) }} - placeholder="搜索股票名称/代码添加股票池" - className="w-full rounded-input border border-border bg-surface py-1.5 pl-8 pr-2.5 text-xs focus:border-accent focus:outline-none" - /> - {open && results.length > 0 && ( -
- {results.map(r => { - const added = symbols.includes(r.symbol) - return ( - - ) - })} -
- )} -
- {/* 操作按钮 — 紧贴输入框右侧 */} -
- {/* 当前范围 — 有范围显示个数, 无范围显示全市场 */} - - {symbols.length === 0 ? '全市场' : `共 ${symbols.length} 只`} - - - -
-
-
- {symbols.length === 0 ? ( - 默认全市场回测,由基础过滤和策略条件筛选。 - ) : symbols.map(symbol => { - const name = symbolNames[symbol] - return ( - - {symbol} - {name && {name}} - - - ) - })} -
-
- ) -} - -export function StrategyBacktest() { - const [saved] = useState(() => storage.strategyBacktestLast.get(null)) - const [selectedStrategy, setSelectedStrategy] = useState(saved?.selectedStrategy ?? null) - const [strategyGroup, setStrategyGroup] = useState('all') - const [symbols, setSymbols] = useState(saved?.symbols ?? '') - const [start, setStart] = useState(saved?.start ?? THREE_MONTHS_AGO) - const [end, setEnd] = useState(saved?.end ?? TODAY) - // 成交口径: 建仓/清仓可独立配置。向后兼容老 matching (派生为 entry=exit=matching)。 - const [matching] = useState<'close_t' | 'open_t+1'>(saved?.matching ?? 'open_t+1') - const [entryFill, setEntryFill] = useState<'close_t' | 'open_t+1'>(saved?.entryFill ?? saved?.matching ?? 'open_t+1') - const [exitFill, setExitFill] = useState<'close_t' | 'open_t+1'>(saved?.exitFill ?? saved?.matching ?? 'close_t') - const [fees, setFees] = useState(saved?.fees ?? '2') - const [slippage, setSlippage] = useState(saved?.slippage ?? '5') - const [maxPositions, setMaxPositions] = useState(saved?.maxPositions ?? '10') - const [maxExposure, setMaxExposure] = useState(saved?.maxExposure ?? '100') - const [initialCapital, setInitialCapital] = useState(saved?.initialCapital ?? '1000000') - const [positionSizing, setPositionSizing] = useState<'equal' | 'score_weight'>(saved?.positionSizing ?? 'equal') - const [simMode, setSimMode] = useState<'position' | 'full'>(saved?.mode ?? 'position') - const [holdingDays, setHoldingDays] = useState(saved?.holdingDays ?? '5') - const [settingsOpen, setSettingsOpen] = useState(false) - // 高颗粒回测(分钟K精确回测)— 开发中,Starter+ 功能 - const [highGranularity, setHighGranularity] = useState(false) - const { data: caps } = useCapabilities() - const isFreeTier = tierRank(caps?.label ?? '') < 1 - const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false) - const [quickRanges, setQuickRanges] = useState(loadQuickRanges) - const [settingsTab, setSettingsTab] = useState('params') - const [editingScoring, setEditingScoring] = useState(false) - const [scoringDraft, setScoringDraft] = useState>({}) - const [strategyParams, setStrategyParams] = useState>(saved?.params ?? {}) - const [overrides, setOverrides] = useState>(saved?.overrides ?? {}) - // result 不从 localStorage 恢复:它是运行产物(净值/交易),大且易过时, - // 跨会话/拉新代码后自动渲染一个可能对应已失效策略的旧结果会造成困惑 - // (切页不卸载组件,内存中的 result 仍保留,无需靠 localStorage 恢复)。 - const [result, setResult] = useState(null) - const [resultTab, setResultTab] = useState<'daily' | 'trades' | 'picks'>('daily') - const [dailyPage, setDailyPage] = useState(0) - const [tradePage, setTradePage] = useState(0) - const [tradePageSize, setTradePageSize] = useState(10) - const [selectedTrade, setSelectedTrade] = useState(null) - const loadedStrategyRef = useRef(null) - - const strategies = useQuery({ - queryKey: QK.screenerStrategies, - queryFn: api.screenerStrategies, - }) - - const strategyList = useMemo(() => strategies.data?.presets ?? [], [strategies.data]) - const filteredStrategyList = useMemo(() => ( - strategyGroup === 'all' ? strategyList : strategyList.filter(st => st.source === strategyGroup) - ), [strategyGroup, strategyList]) - - // 校验 localStorage 里保存的上次选中策略是否仍存在(本地开发残留的自定义策略 - // 拉新代码后会失效,导致 strategyGet 一直 404/加载中)。列表就绪后若失效, - // 连带清除其专属的 params/overrides/result(这些是该策略的运行配置/产物, - // 策略失效后留着会造成"孤儿"状态:界面显示旧回测结果却无对应策略)。 - useEffect(() => { - if (strategies.isLoading || strategyList.length === 0) return - if (selectedStrategy && !strategyList.some(st => st.id === selectedStrategy)) { - setSelectedStrategy(null) - setStrategyParams({}) - setOverrides({}) - setResult(null) - } - }, [strategies.isLoading, strategyList, selectedStrategy]) - - const strategyDetail = useQuery({ - queryKey: ['strategy-detail', selectedStrategy], - queryFn: () => api.strategyGet(selectedStrategy!), - enabled: !!selectedStrategy, - }) - - const backtestTask = useBacktestTask() - const isPending = backtestTask?.isPending ?? false - - const dataStatus = useDataStatus() - const earliestDate = dataStatus.data?.daily?.earliest_date ?? null - - const resetConfigFromDetail = (detail: StrategyDetail) => { - setStrategyParams(strategyDefaultParams(detail)) - setOverrides(buildDefaultOverrides(detail)) - } - - // 刷新页面后: 从 localStorage 恢复未完成的回测任务 - useEffect(() => { - tryReconnect() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - useEffect(() => { - const detail = strategyDetail.data - if (!detail || loadedStrategyRef.current === detail.id) return - loadedStrategyRef.current = detail.id - if (saved?.selectedStrategy === detail.id && (saved.params || saved.overrides)) { - setStrategyParams(saved.params ?? strategyDefaultParams(detail)) - setOverrides(saved.overrides ?? buildDefaultOverrides(detail)) - return - } - resetConfigFromDetail(detail) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [strategyDetail.data]) - - // 当全局回测任务完成时, 把结果写入组件 (切页回来也能恢复) - useEffect(() => { - if (backtestTask && !backtestTask.isPending && backtestTask.result) { - setResult(backtestTask.result) - setResultTab('daily') - setDailyPage(0) - setTradePage(0) - storage.strategyBacktestLast.set({ - selectedStrategy, - symbols, - start, - end, - matching, - entryFill, - exitFill, - fees, - slippage, - maxPositions, - maxExposure, - initialCapital, - positionSizing, - mode: simMode, - holdingDays, - params: strategyParams, - overrides, - result: backtestTask.result, - }) - } - }, [backtestTask]) - - const handleRun = () => { - if (!selectedStrategy) return - startBacktest({ - strategy_id: selectedStrategy, - symbols: symbols ? symbols.split(',').map(s => s.trim()).filter(Boolean) : null, - start: start || null, - end: end || undefined, - matching, - entry_fill: entryFill, - exit_fill: exitFill, - fees_pct: Number(fees) / 10000, - slippage_bps: Number(slippage), - max_positions: Number(maxPositions), - max_exposure_pct: Number(maxExposure) / 100, - initial_capital: Number(initialCapital), - position_sizing: positionSizing, - params: strategyParams, - overrides, - mode: simMode, - holding_days: Number(holdingDays) || 5, - }) - } - - // 提取统计 - const s = result?.stats - const pick = (...keys: string[]) => { - for (const k of keys) { - if (s && k in s && s[k] != null) return s[k] - } - return null - } - - const benchmarkReturn = useMemo(() => { - const values = (result?.benchmark_curve ?? []) - .map(r => Number(r.close ?? r.value)) - .filter(v => Number.isFinite(v) && v > 0) - if (values.length < 2) return null - return values[values.length - 1] / values[0] - 1 - }, [result?.benchmark_curve]) - - const strategyReturn = pick('total_return') as number | null - const excessReturn = strategyReturn != null && benchmarkReturn != null - ? strategyReturn - benchmarkReturn - : null - - const applyRange = (months: number) => { - setStart(monthsAgo(months)) - setEnd(formatDate(new Date())) - } - - const applyAllRange = () => { - setStart(earliestDate ?? '') - setEnd(formatDate(new Date())) - } - - // 进入页面/还在加载时就点了"全部": earliestDate 就绪后回填, 让 DatePicker 显示真实起始日 - useEffect(() => { - if (earliestDate && start === '' && end === TODAY) { - setStart(earliestDate) - } - }, [earliestDate, start, end]) - - const applyQuickRange = (range: QuickRangeConfig) => { - if (range.unit === 'all') { - applyAllRange() - return - } - applyRange(quickRangeMonths(range)) - } - - const saveQuickRanges = (next: QuickRangeConfig[]) => { - const normalized = normalizeQuickRanges(next) - storage.strategyBacktestQuickRanges.set(normalized) - return normalized - } - - const updateQuickRange = (id: string, patch: Partial>) => { - setQuickRanges(prev => { - const current = prev.find(range => range.id === id) - if (patch.enabled === false && current?.enabled && prev.filter(range => range.enabled).length <= 1) return prev - return saveQuickRanges(prev.map(range => range.id === id - ? normalizeQuickRange({ ...range, ...patch }, range) - : range - )) - }) - } - - const visibleQuickRanges = quickRanges.filter(range => range.enabled) - const matchedQuickRange = visibleQuickRanges.find(range => range.unit === 'all' - ? end === TODAY && (start === earliestDate || start === '') - : end === TODAY && start === monthsAgo(quickRangeMonths(range)) - ) - const rangeKey = matchedQuickRange?.id ?? 'custom' - const rangeTitle = matchedQuickRange ? quickRangeTitle(matchedQuickRange) : '自定义区间' - 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' - }` - - const sortedTrades = useMemo(() => { - return [...(result?.trades ?? [])].sort((a, b) => { - const exitCmp = String(b.exit_date).localeCompare(String(a.exit_date)) - if (exitCmp !== 0) return exitCmp - return String(b.entry_date).localeCompare(String(a.entry_date)) - }) - }, [result?.trades]) - - const dailyTradeRows = useMemo(() => { - const rows = new Map>() - const ensure = (date: string) => { - if (!rows.has(date)) { - rows.set(date, { date, buys: [], sells: [], buyValue: 0, sellValue: 0, realizedPnl: 0 }) - } - return rows.get(date)! - } - - for (const t of result?.trades ?? []) { - const entryDate = String(t.entry_date).slice(0, 10) - const exitDate = String(t.exit_date).slice(0, 10) - const buyRow = ensure(entryDate) - buyRow.buys.push(t) - buyRow.buyValue += Number(t.entry_value ?? 0) - - const sellRow = ensure(exitDate) - sellRow.sells.push(t) - sellRow.sellValue += Number(t.exit_value ?? 0) - sellRow.realizedPnl += Number(t.pnl_amount ?? 0) - } - - let cumulativePnl = 0 - return [...rows.values()] - .sort((a, b) => a.date.localeCompare(b.date)) - .map(row => { - cumulativePnl += row.realizedPnl - return { ...row, cumulativePnl } - }) - .reverse() - }, [result?.trades]) - - const tradePageCount = sortedTrades.length - ? Math.ceil(sortedTrades.length / tradePageSize) - : 0 - const dailyPageSize = 10 - const dailyPageCount = dailyTradeRows.length - ? Math.ceil(dailyTradeRows.length / dailyPageSize) - : 0 - const safeDailyPage = Math.min(dailyPage, Math.max(dailyPageCount - 1, 0)) - const dailyStart = safeDailyPage * dailyPageSize - const visibleDailyRows = dailyTradeRows.slice(dailyStart, dailyStart + dailyPageSize) - const dailyEnd = Math.min(dailyStart + visibleDailyRows.length, dailyTradeRows.length) - const safeTradePage = Math.min(tradePage, Math.max(tradePageCount - 1, 0)) - const tradeStart = safeTradePage * tradePageSize - const visibleTrades = sortedTrades.slice(tradeStart, tradeStart + tradePageSize) - const tradeEnd = Math.min(tradeStart + visibleTrades.length, sortedTrades.length) - const symbolNames = useMemo(() => { - const names: Record = {} - result?.trades.forEach(t => { - if (t.name) names[t.symbol] = t.name - }) - return names - }, [result?.trades]) - - const detail = strategyDetail.data - const basicFilter = (overrides.basic_filter ?? {}) as Record - const entrySignals = (overrides.entry_signals ?? []) as string[] - const exitSignals = (overrides.exit_signals ?? []) as string[] - - const scoring = useMemo(() => (overrides.scoring ?? {}) as Record, [overrides.scoring]) - const scoreMinValue = overrides.score_min == null ? '' : String(overrides.score_min) - const scoreMaxValue = overrides.score_max == null ? '' : String(overrides.score_max) - const stopLossPct = overrides.stop_loss == null ? '' : String(Math.abs(Number(overrides.stop_loss)) * 100) - const takeProfitPct = overrides.take_profit == null ? '' : String(Math.abs(Number(overrides.take_profit)) * 100) - const trailingStopPct = overrides.trailing_stop == null ? '' : String(Math.abs(Number(overrides.trailing_stop)) * 100) - const trailingTakeProfitActivatePct = overrides.trailing_take_profit_activate == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_activate)) * 100) - const trailingTakeProfitDrawdownPct = overrides.trailing_take_profit_drawdown == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_drawdown)) * 100) - const maxHoldDaysValue = overrides.max_hold_days == null ? '' : String(overrides.max_hold_days) - const targetPositionPct = Number(maxPositions) > 0 ? Number(maxExposure) / Number(maxPositions) : 0 - - useEffect(() => { - if (!editingScoring) setScoringDraft(scoringToPct(scoring)) - }, [scoring, editingScoring]) - - const updateOverride = (key: string, value: any) => { - setOverrides(prev => ({ ...prev, [key]: value })) - } - const updateBasicFilter = (key: string, value: any) => { - updateOverride('basic_filter', { ...basicFilter, [key]: value }) - } - const startScoringEdit = () => { - setScoringDraft(scoringToPct(scoring)) - setEditingScoring(true) - } - const cancelScoringEdit = () => { - setScoringDraft(scoringToPct(scoring)) - setEditingScoring(false) - } - const saveScoringDraft = () => { - updateOverride('scoring', normalizePctWeights(scoringDraft)) - setEditingScoring(false) - } - const scoreFilterSummary = scoreMinValue !== '' && scoreMaxValue !== '' - ? `评分 ${scoreMinValue}~${scoreMaxValue}` - : scoreMinValue !== '' - ? `评分 ≥${scoreMinValue}` - : scoreMaxValue !== '' - ? `评分 ≤${scoreMaxValue}` - : '评分不过滤' - const advancedSummary = detail - ? [ - detail.params.length > 0 ? `参数 ${detail.params.length}` : '无策略参数', - basicFilter.enabled !== false ? '过滤开' : '过滤关', - `买点 ${entrySignals.length}`, - `卖点 ${exitSignals.length}`, - scoreFilterSummary, - stopLossPct !== '' ? `止损 ${stopLossPct}%` : '止损未设', - takeProfitPct !== '' ? `止盈 ${takeProfitPct}%` : '止盈未设', - trailingStopPct !== '' ? `移损 ${trailingStopPct}%` : '移损未设', - trailingTakeProfitActivatePct !== '' && trailingTakeProfitDrawdownPct !== '' ? `回撤 ${trailingTakeProfitActivatePct}-${trailingTakeProfitDrawdownPct}点` : '回撤未设', - maxHoldDaysValue !== '' ? `最长 ${maxHoldDaysValue}天` : '不限持仓', - ].join(' · ') - : '选择策略后可调整参数 / 过滤 / 买卖触发器 / 评分 / 风控' - const selectedStrategyName = detail?.name ?? strategyList.find(st => st.id === selectedStrategy)?.name ?? '未选择策略' - const selectedStrategySource = detail?.source ?? strategyList.find(st => st.id === selectedStrategy)?.source - const stockPoolCount = symbols.split(',').map(s => s.trim()).filter(Boolean).length - const stockPoolSummary = stockPoolCount > 0 ? `股票池 已限定 ${stockPoolCount} 只` : '股票池 全市场' - const resultStartDate = result?.config?.start ?? result?.equity_curve?.[0]?.date ?? start - const resultEndDate = result?.config?.end ?? result?.equity_curve?.[result.equity_curve.length - 1]?.date ?? end - const resultTradeDays = result?.equity_curve?.length ?? 0 - const executionStats = (result?.stats?.execution ?? {}) as Record - const executionSummary = [ - ['buy_no_slot', '满仓未买'], - ['buy_exposure', '仓位上限'], - ['buy_score_filter', '评分过滤'], - ['buy_limit_up', '涨停未买'], - ['buy_suspended', '停牌未买'], - ['sell_limit_down', '跌停阻塞'], - ['sell_suspended', '停牌阻塞'], - ['pending_exit', '待卖阻塞'], - ] - .map(([key, label]) => ({ key, label, value: Number(executionStats[key] ?? 0) })) - .filter(item => item.value > 0) - - return ( -
- {/* 配置面板 */} -
-
-
- - {/* 高颗粒回测(分钟K)— 开发中占位 */} -
- - - 分钟K - {isFreeTier && ( - Starter+ - )} -
-
- {/* 高颗粒开启时的警告条 */} - {highGranularity && !isFreeTier && ( -
- -
- 高颗粒回测(开发中) - :将结合每日分钟K进行更精确的回测。 - ⚠️ 此功能尚未完成,且开启后会显著拖慢回测速度、占用大量资源。 -
-
- )} -
-
- {STRATEGY_GROUPS.map(group => ( - - ))} -
-
- {strategies.isLoading && ( - 加载中… - )} - {!strategies.isLoading && filteredStrategyList.length === 0 && ( - 当前分组暂无策略 - )} - {filteredStrategyList.map(st => ( - - ))} -
-
-
- - {selectedStrategy && strategyDetail.isLoading && ( -
加载策略配置…
- )} - - - -
-
-
-
回测区间
- -
- - {rangeTitle} - -
- -
-
- - -
-
- - -
-
- -
-
- {visibleQuickRanges.map(range => ( - - ))} -
- -
- - {rangeSettingsOpen && ( -
-
- 快捷区间 - 月 1-120 / 年 1-10 -
-
- {quickRanges.map((range, index) => { - const limits = range.unit === 'all' ? null : QUICK_RANGE_LIMITS[range.unit] - return ( -
- - - updateQuickRange(range.id, { value: Number(e.target.value) })} - placeholder="—" - className={`${INPUT_CLS} ${range.unit === 'all' ? 'opacity-50' : ''}`} - /> -
- ) - })} -
-
- )} -
- -
-
- - -
-
- - -
-
-
建仓默认次日开盘(避免未来函数),清仓默认当日收盘(持仓中可盘中/收盘卖);买卖点由策略触发器决定,这里只决定成交价。
- - {simMode === 'position' && ( -
-
- - setInitialCapital(e.target.value)} - className={INPUT_CLS} /> -
-
- - -
-
- - setMaxPositions(e.target.value)} - className={INPUT_CLS} /> -
-
- - setMaxExposure(e.target.value)} - className={INPUT_CLS} /> -
-
- - setFees(e.target.value)} className={INPUT_CLS} /> -
-
- - setSlippage(e.target.value)} className={INPUT_CLS} /> -
-
- )} - {simMode === 'position' && ( -
- 单票目标约 {Number.isFinite(targetPositionPct) ? targetPositionPct.toFixed(1) : '—'}%。最大总仓位控制资金投入;剩余现金不是新增持仓名额,只有实际卖出成功才释放持仓数。 -
- )} - {simMode === 'full' && ( -
- 全量模拟:每日将策略选出的全部候选独立买入,不受资金/最大持仓数限制;每一笔仍按策略卖点、止损、移动止盈/止损和最长持仓执行,用于评估策略本身的选股 + 交易规则质量。 -
- )} - - {isPending ? ( - - ) : ( - - )} -
- - {/* 结果面板 */} -
- {/* 模式切换: 仓位模拟 / 全量模拟 */} -
-
- {([['position', '仓位模拟'], ['full', '全量模拟']] as const).map(([val, label]) => ( - - ))} -
- {simMode === 'full' && ( - maxHoldDaysValue !== '' ? ( -
- 策略最长 {maxHoldDaysValue} 天 -
- ) : ( -
- 兜底上限 -
- {(['1', '5', '10', '20'] as const).map(d => ( - - ))} -
-
- ) - )} -
- - {result?.error && ( -
- {result.error} -
- )} - - {backtestTask?.error && ( -
- {backtestTask.error} -
- )} - - {!result && !isPending && ( - - )} - - {isPending && ( - -
- - - - -
-
- {backtestTask?.progress - ? `回测中 · 第 ${backtestTask.progress.day}/${backtestTask.progress.total} 天 (${backtestTask.progress.date})` - : '正在重新计算回测…'} -
-
- {result ? '当前展示上次结果,完成后自动替换' : '正在加载回测数据…'} -
-
- {backtestTask?.progress && ( - - {((backtestTask.progress.day / backtestTask.progress.total) * 100).toFixed(0)}% - - )} - -
- {backtestTask?.progress && ( -
-
-
- )} - - )} - - {/* 旧全量模拟结果: 固定前瞻收益统计 (兼容历史缓存结果) */} - {result && !result.error && result.stats && result.stats.mode === 'full' && result.stats.full_kind !== 'candidate_execution' && ( - -
- {result.strategy_info?.name ?? '策略'} - 全量模拟 - 持有 {result.config?.holding_days ?? 5} 天 - - {String(result.config?.start).slice(0,10)} ~ {String(result.config?.end).slice(0,10)} - -
- - {/* 统计卡片 */} -
- - - - - - - - -
- -
- 候选样本 {result.stats.n_candidates ?? 0} (标的×信号日) - 信号天数 {result.stats.n_days ?? 0} - 日均候选 {result.stats.avg_daily_candidates ?? 0} - 最佳 {fmtPct(result.stats.best)} - 最差 {fmtPct(result.stats.worst)} - 基准(上证) {fmtPct(result.stats.benchmark_return)} -
- - {/* 累计超额曲线 (复用 StrategyNavChart) */} - {result.equity_curve.length > 1 && ( -
-
累计收益曲线(日均复利)
- -
- )} - - {/* 收益分布直方图 */} - {Array.isArray(result.stats.return_distribution) && result.stats.return_distribution.length > 0 && ( -
-
- 候选标的收益分布(持有 {result.config?.holding_days ?? 5} 天) - 红=正收益 · 绿=负收益 -
- -
- )} - -
run_id: {result.run_id}
-
- )} - - {result && !result.error && result.stats && !result.stats.error && (result.stats.mode !== 'full' || result.stats.full_kind === 'candidate_execution') && ( - - {/* 策略信息 */} - {result.strategy_info && ( -
-
- {result.strategy_info.name} - {result.stats.full_kind === 'candidate_execution' && ( - 全量独立执行 - )} - {result.strategy_info.source && ( - - {SRC_MAP[result.strategy_info.source] ?? ''} - - )} -
- {result.strategy_info.stop_loss != null && ( - 止损 {fmtPct(result.strategy_info.stop_loss)} - )} - {result.strategy_info.take_profit != null && ( - 止盈 {fmtPct(result.strategy_info.take_profit)} - )} - {result.strategy_info.trailing_stop != null && ( - 移损 {fmtPct(result.strategy_info.trailing_stop)} - )} - {result.strategy_info.trailing_take_profit_activate != null && result.strategy_info.trailing_take_profit_drawdown != null && ( - 回撤 {fmtPct(result.strategy_info.trailing_take_profit_activate)}-{fmtPct(result.strategy_info.trailing_take_profit_drawdown)} - )} - {result.strategy_info.max_hold_days != null && ( - 最长 {result.strategy_info.max_hold_days} 天 - )} - {resultTradeDays > 0 && ( - - {String(resultStartDate).slice(0, 10)} ~ {String(resultEndDate).slice(0, 10)} - {resultTradeDays} 天 - - )} - {result.elapsed_ms > 0 && ( - 0 ? '' : 'ml-auto'}`}> - - 总耗时 - {fmtDuration(result.elapsed_ms)} - - )} -
- )} - - {/* 统计卡片 */} -
-
- - - - - } value={pick('sharpe') != null ? Number(pick('sharpe')).toFixed(2) : '—'} /> - - - - {result.stats.full_kind === 'candidate_execution' ? ( - - ) : ( - - )} -
-
- - {executionSummary.length > 0 && ( -
- 成交约束: - {executionSummary.map((item, index) => ( - - {index > 0 ? '· ' : ''}{item.label} {item.value} 次 - - ))} -
- )} - - {/* 净值曲线 */} - {result.equity_curve.length > 0 && ( -
- -
- )} - - {Array.isArray(result.stats.return_distribution) && result.stats.return_distribution.length > 0 && ( -
-
- 独立候选交易收益分布 - 红=正收益 · 绿=负收益 -
- -
- )} - - {/* Tab: 按日期 / 交易明细 / 选股分析 */} - {(result.trades.length > 0 || result.per_symbol_stats.length > 0) && ( -
-
- {(['daily', 'trades', 'picks'] as const).map(t => ( - - ))} -
- - {resultTab === 'daily' && ( -
-
- - - - - - - - - - - - {visibleDailyRows.map(row => ( - - - - - - - - ))} - -
日期买入卖出当日收益累计收益
-
{row.date}
-
- 买 {row.buys.length} / 卖 {row.sells.length} -
-
- {row.buys.length === 0 ? ( - - ) : ( -
- {row.buys.map((t, i) => ( - setSelectedTrade(t)} /> - ))} -
- )} -
- {row.sells.length === 0 ? ( - - ) : ( -
- {row.sells.map((t, i) => ( - setSelectedTrade(t)} /> - ))} -
- )} -
- {fmtSignedMoney(row.realizedPnl)} - - {fmtSignedMoney(row.cumulativePnl)} -
-
- {dailyTradeRows.length > 0 && ( -
- - 显示 {dailyStart + 1}-{dailyEnd} 天 / 共 {dailyTradeRows.length} 天,每页 10 天 - -
- - - {safeDailyPage + 1} / {dailyPageCount} - - -
-
- )} -
- )} - - {resultTab === 'trades' && ( -
- - - - - - - - - - - - - - {visibleTrades.map((t: StrategyBacktestTrade, i: number) => ( - - - - - - - - - - ))} - -
标的买入卖出仓位 / 手数单票盈亏持仓原因
-
- {t.name || t.symbol} -
-
{t.symbol}
-
- - - - -
{fmtPct(t.position_pct, 2)}
-
- {fmtLots(t.lots)} 手 - {fmtShares(t.shares)} 股 -
-
-
{fmtSignedMoney(t.pnl_amount)}
-
{fmtPct(t.pnl_pct)}
-
-
{t.duration} 天
- {!!t.blocked_exit_days &&
阻塞 {t.blocked_exit_days} 天
} -
- {sortedTrades.length > 0 && ( -
- - 显示 {tradeStart + 1}-{tradeEnd} 条 / 共 {sortedTrades.length} 条 - -
- - - - {safeTradePage + 1} / {tradePageCount} - - -
-
- )} -
- )} - - {resultTab === 'picks' && ( - - - - - - - - - - - - - {result.per_symbol_stats.map((r) => ( - - - - - - - - - ))} - -
标的选股次数总收益胜率最佳最差
-
- {symbolNames[r.symbol] || r.symbol} -
-
{r.symbol}
-
{r.n_trades} - {fmtPct(r.total_return)} - {fmtPct(r.win_rate)}{fmtPct(r.best)}{fmtPct(r.worst)}
- )} -
- )} - -
- run_id: {result.run_id} -
-
- )} -
- - {settingsOpen && detail && ( - <> - setSettingsOpen(false)} - className="fixed inset-0 z-50 bg-black/45 backdrop-blur-[1px]" - /> - -
-
-
-
- 高级策略设置 - - {SRC_MAP[detail.source] ?? ''} - -
-
{detail.name}
-
{advancedSummary}
-
- -
-
- {ADVANCED_TABS.map(tab => ( - - ))} -
-
- -
-
-
触发 / 成交 / 仓位关系
-
触发器决定什么时候产生买卖信号;评分只在多个买点同时出现时排序。
-
成交口径可分别设置建仓/清仓:默认建仓次日开盘(避免未来函数)、清仓当日收盘(持仓中可盘中/收盘卖)。
-
退出优先级:止损/移动止损 > 卖点信号 > 到期平仓;到期只作兜底,不抢占卖点或风控。
-
最大持仓数控制同时持股数量,最大总仓位控制资金投入比例;剩余现金不等于可新增持仓名额。
-
- - {settingsTab === 'range' && ( - - -
默认全市场回测,由基础过滤、策略条件和买卖触发器筛选;需要单票调试或自选池回测时再限定股票池。
-
- )} - - {settingsTab === 'params' && ( - - {detail.params.length > 0 ? ( -
- {detail.params.map(param => ( - setStrategyParams(prev => ({ ...prev, [param.id]: value }))} - /> - ))} -
- ) : ( -
当前策略没有可调参数。
- )} -
- )} - - {settingsTab === 'filter' && ( - - -
- {BASIC_FILTER_FIELDS.map(field => { - const scale = field.scale ?? 1 - const value = basicFilter[field.key] == null ? '' : Number(basicFilter[field.key]) / scale - return ( - - ) - })} -
- -
- {BOARD_OPTIONS.map(board => { - const boards = Array.isArray(basicFilter.boards) ? basicFilter.boards : [] - const checked = boards.includes(board) - return ( - - ) - })} -
-
- )} - - {settingsTab === 'entry' && ( - updateOverride('entry_signals', next)} />} - > - updateOverride('entry_signals', next)} - kind="entry" - /> - - )} - - {settingsTab === 'exit' && ( - updateOverride('exit_signals', next)} />} - > - updateOverride('exit_signals', next)} - kind="exit" - /> - - )} - - {settingsTab === 'scoring' && ( - - {Object.entries(scoring).length > 0 ? (() => { - const visibleWeights = editingScoring ? scoringDraft : scoringToPct(scoring) - const total = Object.values(visibleWeights).reduce((a, b) => a + b, 0) - return ( -
-
- {Object.keys(scoring).map(key => ( - setScoringDraft(prev => ({ ...prev, [key]: Math.max(0, value) }))} - /> - ))} -
-
-
- 总和 {editingScoring ? total : 100} - 保存时自动归一化计算 -
-
- {editingScoring && ( - - )} - -
-
-
- ) - })() : ( -
当前策略没有评分权重。
- )} -
-
- 评分过滤 - 留空 = 不过滤;命中范围后按评分从高到低买入 -
-
- - -
-
例如最小值 71 表示只把评分 ≥ 71 的股票放入下一交易日买入预选池。
-
-
- )} - - {settingsTab === 'risk' && ( - -
- - - - - - -
-
- )} -
- -
- - -
-
- - )} - - setSelectedTrade(null)} /> -
- ) -} diff --git a/serve/frontend/src/pages/backtest/charts/FactorGroupNavChart.tsx b/serve/frontend/src/pages/backtest/charts/FactorGroupNavChart.tsx deleted file mode 100644 index 98e618b..0000000 --- a/serve/frontend/src/pages/backtest/charts/FactorGroupNavChart.tsx +++ /dev/null @@ -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 = `
${date}
` - for (const p of params) { - if (p.value == null) continue - html += `
- - - ${p.seriesName} - - ${(p.value as number).toFixed(4)} -
` - } - 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 ( -
-
- {groupCols.map((col, i) => ( - - - {col} - - ))} - {result.long_short_nav?.length > 0 && ( - - - 多空 - - )} -
-
-
- ) -} diff --git a/serve/frontend/src/pages/backtest/charts/FactorICChart.tsx b/serve/frontend/src/pages/backtest/charts/FactorICChart.tsx deleted file mode 100644 index c66b161..0000000 --- a/serve/frontend/src/pages/backtest/charts/FactorICChart.tsx +++ /dev/null @@ -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 = `
${date}
` - for (const p of params) { - if (p.value == null) continue - html += `
- ${p.seriesName} - ${(p.value * 100).toFixed(2)}% -
` - } - 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
-} diff --git a/serve/frontend/src/pages/backtest/charts/ReturnDistributionChart.tsx b/serve/frontend/src/pages/backtest/charts/ReturnDistributionChart.tsx deleted file mode 100644 index 382fcb3..0000000 --- a/serve/frontend/src/pages/backtest/charts/ReturnDistributionChart.tsx +++ /dev/null @@ -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(() => { - 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}
数量: ${bin.count}
占比: ${(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
-} diff --git a/serve/frontend/src/pages/backtest/charts/StrategyNavChart.tsx b/serve/frontend/src/pages/backtest/charts/StrategyNavChart.tsx deleted file mode 100644 index a6c808e..0000000 --- a/serve/frontend/src/pages/backtest/charts/StrategyNavChart.tsx +++ /dev/null @@ -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 = `
${date}
` - for (const p of params) { - if (p.value == null) continue - const isDrawdown = p.seriesName === '回撤' - const isBenchmark = p.seriesName === '同期上证指数' - html += `
- ${p.seriesName} - ${ - isDrawdown - ? `${(p.value as number).toFixed(2)}%` - : isBenchmark - ? `${valueFmt.format(p.value as number)} 点` - : moneyFmt.format(p.value as number) - } -
` - } - 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 ( -
-
- - - 策略净值 - - - - 回撤 - - {(result.benchmark_curve?.length ?? 0) > 0 && ( - - - 同期上证指数 - - )} - 滚轮缩放 · 拖动平移 -
-
-
- ) -} diff --git a/serve/frontend/src/pages/backtest/charts/useECharts.ts b/serve/frontend/src/pages/backtest/charts/useECharts.ts deleted file mode 100644 index 6d33c9d..0000000 --- a/serve/frontend/src/pages/backtest/charts/useECharts.ts +++ /dev/null @@ -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(null) - const instanceRef = useRef(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 -} diff --git a/serve/frontend/src/pages/backtest/components/TradeKlineModal.tsx b/serve/frontend/src/pages/backtest/components/TradeKlineModal.tsx deleted file mode 100644 index 6f964ff..0000000 --- a/serve/frontend/src/pages/backtest/components/TradeKlineModal.tsx +++ /dev/null @@ -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(() => { - 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(() => { - 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 ( - - {trade && dateRange && ( -
- - -
-
-
- {trade.symbol} - {trade.name || '交易回放'} - 交易回放 -
-
- {String(trade.entry_date).slice(0, 10)} 买入 → {String(trade.exit_date).slice(0, 10)} 卖出 · 持仓 {trade.duration ?? '—'} 天 -
-
-
-
-
买 / 卖
-
{fmtPrice(trade.entry_price)} / {fmtPrice(trade.exit_price)}
-
-
-
盈亏
-
- {fmtSignedMoney(trade.pnl_amount)} / {fmtPct(trade.pnl_pct)} -
-
- - -
-
- -
- { if (!showIntraday) setShowIntraday(true) }} - /> -
-
-
- )} -
- ) -} diff --git a/serve/frontend/src/pages/settings/MenuSettings.tsx b/serve/frontend/src/pages/settings/MenuSettings.tsx index 14d7303..3697ba5 100644 --- a/serve/frontend/src/pages/settings/MenuSettings.tsx +++ b/serve/frontend/src/pages/settings/MenuSettings.tsx @@ -32,9 +32,7 @@ interface NavEntry { const BUILTIN_PAGES: NavEntry[] = [ { id: '/', label: '看板', type: 'builtin', visible: true }, - { id: '/watchlist', 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: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true }, { id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true }, diff --git a/serve/frontend/src/router.tsx b/serve/frontend/src/router.tsx index ef4d6b1..abc41e8 100644 --- a/serve/frontend/src/router.tsx +++ b/serve/frontend/src/router.tsx @@ -1,8 +1,6 @@ import { createBrowserRouter, Navigate } from 'react-router-dom' import { Layout } from './components/Layout' -import { Watchlist } from './pages/Watchlist' import { Screener } from './pages/Screener' -import { Backtest } from './pages/Backtest' import { Financials } from './pages/Financials' import { Onboarding } from './pages/Onboarding' import { Auth } from './pages/Auth' @@ -70,9 +68,7 @@ export const router = createBrowserRouter([ { path: 'industry-analysis', element: }, { path: 'stock-analysis', element: }, { path: 'review', element: }, - { path: 'watchlist', element: }, { path: 'screener', element: }, - { path: 'backtest', element: }, { path: 'financials', element: }, { path: 'data', element: }, { path: 'monitor', element: }, diff --git a/serve/screenshots/backtest.png b/serve/screenshots/backtest.png deleted file mode 100644 index c4b55c8..0000000 Binary files a/serve/screenshots/backtest.png and /dev/null differ