From 44ab51e65de95db0f373add9126ce150bb4152d6 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sat, 18 Jul 2026 16:59:53 +0800 Subject: [PATCH] feat: improve strategy scoring and backtest execution --- backend/app/api/backtest.py | 5 +- backend/app/api/strategy.py | 3 + backend/app/backtest/engine.py | 130 +++++++- backend/app/backtest/matrix.py | 29 ++ backend/app/backtest/minute_trigger.py | 49 +++ backend/app/backtest/strategy.py | 62 +++- backend/app/jobs/daily_pipeline.py | 4 +- backend/app/services/quote_service.py | 4 +- backend/app/strategy/ai_generator.py | 70 ++++- backend/app/strategy/engine.py | 22 +- .../prompts/strategy-builder-step1.md | 4 +- .../prompts/strategy-guide-compact.md | 6 +- backend/app/strategy/scoring.py | 37 +++ backend/tests/backtest/test_dependencies.py | 21 ++ .../tests/backtest/test_engine_portfolio.py | 99 +++++- .../tests/backtest/test_matrix_strategy.py | 13 + backend/tests/backtest/test_minute_fill.py | 35 +++ .../test_strategy_backtest_correctness.py | 56 ++++ .../tests/test_ai_strategy_meta_normalize.py | 36 +++ .../tests/test_pipeline_and_monitor_fixes.py | 57 +++- backend/tests/test_strategy_code_save.py | 13 + backend/tests/test_strategy_scoring.py | 39 +++ .../screener/StrategyBuilderDialog.tsx | 45 ++- .../screener/StrategySettingsDialog.tsx | 1 + frontend/src/lib/api.ts | 4 +- frontend/src/lib/storage.ts | 3 +- frontend/src/pages/Screener.tsx | 3 +- .../src/pages/backtest/StrategyBacktest.tsx | 293 ++++++++++++++---- 28 files changed, 1021 insertions(+), 122 deletions(-) create mode 100644 backend/app/backtest/minute_trigger.py create mode 100644 backend/app/strategy/scoring.py create mode 100644 backend/tests/test_strategy_scoring.py diff --git a/backend/app/api/backtest.py b/backend/app/api/backtest.py index 9f69da3..0dfe412 100644 --- a/backend/app/api/backtest.py +++ b/backend/app/api/backtest.py @@ -194,7 +194,7 @@ class StrategyBacktestRequest(BaseModel): # 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 + exit_fill: Literal["close_t", "open_t+1", "signal_next_minute"] | None = None fees_pct: float = 0.0002 commission_pct: float | None = None stamp_tax_pct: float | None = None @@ -206,6 +206,7 @@ class StrategyBacktestRequest(BaseModel): mode: Literal["position", "full"] = "position" holding_days: int = 5 asset_type: str = "stock" + minute_fill: bool = False @router.post("/strategy/run") @@ -239,6 +240,7 @@ def strategy_run(req: StrategyBacktestRequest, request: Request): mode=req.mode, holding_days=req.holding_days, asset_type=req.asset_type, + minute_fill=req.minute_fill, ) task = make_worker_task("backtest", settings.data_dir, cfg) return run_worker_task(task) @@ -1006,4 +1008,3 @@ async def walkforward_cancel(request: Request): job.cancel_event.set() return {"ok": True} return {"ok": False, "message": "任务不存在或已完成"} - diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index 142d9c0..c651ae2 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -17,6 +17,7 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel +from app.backtest.minute_trigger import MINUTE_EXIT_TRIGGER_SIGNALS from app.strategy import config as strategy_config from app.strategy.ai_generator import AIStrategyGenerator, find_meta_assignment from app.strategy.engine import StrategyDef, StrategyEngine @@ -99,6 +100,7 @@ def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict: "scoring": scoring, "entry_signals": overrides.get("entry_signals", s.entry_signals) if overrides else s.entry_signals, "exit_signals": overrides.get("exit_signals", s.exit_signals) if overrides else s.exit_signals, + "minute_exit_trigger_supported_signals": sorted(MINUTE_EXIT_TRIGGER_SIGNALS), "stop_loss": overrides.get("stop_loss", s.stop_loss) if overrides else s.stop_loss, "take_profit": getattr(s, "take_profit", None), "trailing_stop": getattr(s, "trailing_stop", None), @@ -504,6 +506,7 @@ def _prepare_strategy_code(req: StrategyCodeValidateRequest | StrategyCodeSaveRe # 安全校验始终执行 (此前 strict 字段可被客户端设 false 绕过, 已移除) AIStrategyGenerator._validate_safety(code) meta = AIStrategyGenerator._extract_meta(code) + AIStrategyGenerator._validate_meta_semantics(code, meta) return {"code": code, "meta": meta} diff --git a/backend/app/backtest/engine.py b/backend/app/backtest/engine.py index 9bcc25d..be839db 100644 --- a/backend/app/backtest/engine.py +++ b/backend/app/backtest/engine.py @@ -49,7 +49,7 @@ class MatcherConfig: # 显式传入 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 + exit_fill: Literal["close_t", "open_t+1", "signal_next_minute"] | None = None # 成本模型: 优先使用拆分口径 (佣金双边 + 印花税仅卖出 + 滑点双边)。 # 未设 commission_pct 时回退到 fees_pct 作为双边佣金 (向后兼容, 无印花税)。 fees_pct: float = 0.0002 @@ -694,6 +694,7 @@ class BacktestEngine: exit_delay_bars=1 if config.exit_fill == "open_t+1" else 0, entry_signal_ids=entry_signal_ids, exit_signal_ids=exit_signal_ids, + minute_exit_trigger=config.exit_fill == "signal_next_minute", ) return self._simulate_independent_matrix( matrix, raw_candidates, config, progress_cb, cancel_event, @@ -766,6 +767,18 @@ class BacktestEngine: ) return precise if precise is not None else daily_price + def _minute_trigger_price(time_id: int, asset_id: int) -> float | None: + if not config.minute_fill or not minute_cache: + return None + rows = minute_cache.get((matrix.symbols[asset_id], matrix.timestamp_labels[time_id][:10])) + if rows is None: + return None + reference = float(matrix.reference_price[time_id, asset_id]) + return self._resolve_minute_exit_trigger( + rows, + reference if _valid_price(reference) else None, + ) + def _one_price_limit(time_id: int, asset_id: int, direction: str) -> bool: if not matrix.tradable[time_id, asset_id]: return False @@ -839,12 +852,36 @@ class BacktestEngine: signal_date: str, override: float | None = None, ) -> bool: + signal_id = ( + _signal_id(int(matrix.exit_signal_code[time_id, asset_id]), matrix.exit_signal_ids) + if reason == "signal" else None + ) + minute_trigger = config.exit_fill == "signal_next_minute" and reason == "signal" + if minute_trigger and override is None: + if pos.get("pending_exit_next_open"): + open_price = float(matrix.open[time_id, asset_id]) + override = open_price if _valid_price(open_price) else None + else: + override = _minute_trigger_price(time_id, asset_id) + if override is None: + if not pos.get("pending_exit_reason"): + pos["pending_exit_reason"] = reason + pos["pending_exit_signal_date"] = signal_date + pos["pending_exit_signal_id"] = signal_id + pos["pending_exit_next_open"] = True + _count("pending_exit") + pos["blocked_exit_days"] += 1 + _count("sell_minute_trigger_fallback") + return False ok, blocked = _can_sell(time_id, asset_id, override) if not ok: if not pos.get("pending_exit_reason"): pos["pending_exit_reason"] = reason pos["pending_exit_signal_date"] = signal_date + pos["pending_exit_signal_id"] = signal_id _count("pending_exit") + if minute_trigger: + pos["pending_exit_next_open"] = True pos["blocked_exit_days"] += 1 _count(blocked) return False @@ -875,9 +912,7 @@ class BacktestEngine: exit_signal_date=signal_date, blocked_exit_days=int(pos["blocked_exit_days"]), entry_signal_id=pos["entry_signal_id"], - exit_signal_id=_signal_id( - int(matrix.exit_signal_code[time_id, asset_id]), matrix.exit_signal_ids - ) if reason == "signal" else None, + exit_signal_id=(pos.get("pending_exit_signal_id") or signal_id) if reason == "signal" else None, )) return True @@ -933,6 +968,8 @@ class BacktestEngine: "max_high": max(entry_price, float(matrix.high[time_id, asset_id])), "pending_exit_reason": None, "pending_exit_signal_date": None, + "pending_exit_signal_id": None, + "pending_exit_next_open": False, "blocked_exit_days": 0, } closed = False @@ -1437,6 +1474,34 @@ class BacktestEngine: return float(closes[-1]) if np.isfinite(closes[-1]) else None + @staticmethod + def _resolve_minute_exit_trigger( + minute_arr: np.ndarray, + ref_price: float | None, + ) -> float | None: + """分钟收盘确认向下穿越后,返回下一分钟开盘价。""" + if minute_arr is None or len(minute_arr) < 2: + return None + if ref_price is None or not np.isfinite(ref_price) or ref_price <= 0: + return None + + ncols = minute_arr.shape[1] if minute_arr.ndim == 2 else 1 + if ncols < 4: + return None + opens = minute_arr[:, 0] + closes = minute_arr[:, 3] + below = np.isfinite(closes) & (closes < ref_price) + previous_above = np.empty(len(closes), dtype=bool) + previous_above[0] = True + previous_above[1:] = np.isfinite(closes[:-1]) & (closes[:-1] >= ref_price) + crossings = np.flatnonzero(below & previous_above) + if crossings.size == 0: + return None + next_idx = int(crossings[0]) + 1 + if next_idx >= len(opens) or not np.isfinite(opens[next_idx]) or opens[next_idx] <= 0: + return None + return float(opens[next_idx]) + # 分钟K cache 存储的数值列及固定顺序 (_resolve_minute_fill 按此顺序整数索引)。 _MINUTE_NUMERIC_COLS = ["open", "high", "low", "close", "volume", "amount"] @@ -1478,7 +1543,7 @@ class BacktestEngine: if df.is_empty(): continue # 按 (symbol, 日期) 分组, 每组转紧凑 float64 数组存入 cache - df = df.with_columns( + df = df.sort(["symbol", "datetime"]).with_columns( pl.col("datetime").dt.strftime("%Y-%m-%d").alias("_d_str") ) for sub in df.partition_by(["symbol", "_d_str"], as_dict=False): @@ -1515,6 +1580,7 @@ class BacktestEngine: exit_delay_bars=1 if config.exit_fill == "open_t+1" else 0, entry_signal_ids=entry_signal_ids, exit_signal_ids=exit_signal_ids, + minute_exit_trigger=config.exit_fill == "signal_next_minute", ) if not matrix.entry.any(): return self._empty_result() @@ -1625,6 +1691,19 @@ class BacktestEngine: ) return precise if precise is not None else daily_price + def _minute_trigger_price(time_id: int, asset_id: int) -> float | None: + if not config.minute_fill or not minute_cache: + return None + key = (matrix.symbols[asset_id], matrix.timestamp_labels[time_id][:10]) + minute_rows = minute_cache.get(key) + if minute_rows is None: + return None + reference = float(matrix.reference_price[time_id, asset_id]) + return self._resolve_minute_exit_trigger( + minute_rows, + reference if _valid_price(reference) else None, + ) + def _one_price_limit(time_id: int, asset_id: int, direction: str) -> bool: if not matrix.tradable[time_id, asset_id]: return False @@ -1659,12 +1738,21 @@ class BacktestEngine: return False, "sell_limit_down" return True, "" - def _mark_pending(asset_id: int, reason: str, signal_date: str) -> None: + def _mark_pending( + asset_id: int, + reason: str, + signal_date: str, + signal_id: str | None = None, + next_open: bool = False, + ) -> None: pos = positions[asset_id] if not pos.get("pending_exit_reason"): pos["pending_exit_reason"] = reason pos["pending_exit_signal_date"] = signal_date + pos["pending_exit_signal_id"] = signal_id _count("pending_exit") + if next_open: + pos["pending_exit_next_open"] = True pos["blocked_exit_days"] += 1 def _sell( @@ -1706,8 +1794,9 @@ class BacktestEngine: exit_signal_date=signal_date, blocked_exit_days=int(pos["blocked_exit_days"]), entry_signal_id=pos["entry_signal_id"], - exit_signal_id=_signal_id( - int(matrix.exit_signal_code[time_id, asset_id]), matrix.exit_signal_ids + exit_signal_id=( + pos.get("pending_exit_signal_id") + or _signal_id(int(matrix.exit_signal_code[time_id, asset_id]), matrix.exit_signal_ids) ) if reason == "signal" else None, )) @@ -1719,9 +1808,30 @@ class BacktestEngine: sold_today: set[int], override: float | None = None, ) -> bool: + signal_id = ( + _signal_id(int(matrix.exit_signal_code[time_id, asset_id]), matrix.exit_signal_ids) + if reason == "signal" else None + ) + minute_trigger = config.exit_fill == "signal_next_minute" and reason == "signal" + if minute_trigger and override is None: + pos = positions[asset_id] + if pos.get("pending_exit_next_open"): + override = float(matrix.open[time_id, asset_id]) + else: + override = _minute_trigger_price(time_id, asset_id) + if override is None: + _mark_pending(asset_id, reason, signal_date, signal_id, next_open=True) + _count("sell_minute_trigger_fallback") + return False ok, blocked = _can_sell(time_id, asset_id, override) if not ok: - _mark_pending(asset_id, reason, signal_date) + _mark_pending( + asset_id, + reason, + signal_date, + signal_id, + next_open=minute_trigger, + ) _count(blocked) return False _sell(time_id, asset_id, reason, signal_date, sold_today, override) @@ -1890,6 +2000,8 @@ class BacktestEngine: "hold_days": 0, "pending_exit_reason": None, "pending_exit_signal_date": None, + "pending_exit_signal_id": None, + "pending_exit_next_open": False, "blocked_exit_days": 0, } diff --git a/backend/app/backtest/matrix.py b/backend/app/backtest/matrix.py index ef71ecd..6dbcac7 100644 --- a/backend/app/backtest/matrix.py +++ b/backend/app/backtest/matrix.py @@ -26,6 +26,8 @@ import pyarrow as pa import pyarrow.compute as pc import pyarrow.dataset as pads +from app.backtest.minute_trigger import build_minute_exit_reference + try: from numba import njit, prange except ImportError: @@ -2233,6 +2235,7 @@ def build_market_matrix_from_signals( entry_delay_bars: int = 0, exit_delay_bars: int = 0, reference_price: np.ndarray | None = None, + minute_exit_trigger: bool = False, ) -> MarketMatrix: """Combine base data and strategy signals into the matcher input matrix.""" if entry_delay_bars not in (0, 1) or exit_delay_bars not in (0, 1): @@ -2266,6 +2269,16 @@ def build_market_matrix_from_signals( use = ~np.isfinite(resolved_reference_price) & np.isfinite(values) & (values > 0) resolved_reference_price[use] = values[use] + if minute_exit_trigger: + trigger_reference = build_minute_exit_reference( + market.close, + market.fields, + signals.exit_signal_code, + signals.exit_signal_ids, + ) + trigger_mask = signals.exit != 0 + resolved_reference_price[trigger_mask] = trigger_reference[trigger_mask] + _make_read_only( entry, exit_, @@ -2312,6 +2325,7 @@ def build_market_matrix( exit_delay_bars: int = 0, entry_signal_ids: list[str] | None = None, exit_signal_ids: list[str] | None = None, + minute_exit_trigger: bool = False, ) -> MarketMatrix: """Backward-compatible long-panel boundary used by legacy/Polars strategies.""" if panel.is_empty(): @@ -2355,6 +2369,7 @@ def build_market_matrix( signals, entry_delay_bars=entry_delay_bars, exit_delay_bars=exit_delay_bars, + minute_exit_trigger=minute_exit_trigger, ) @@ -3458,6 +3473,8 @@ def _estimate_pipeline_cache_bytes( continue if name == "vol_ratio_5d": estimated += 2 * float_bytes + elif name == "ma20_bias": + estimated += 2 * float_bytes elif name == "change_pct" or ( name.startswith("momentum_") and name.endswith("d") ): @@ -3631,6 +3648,7 @@ def matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray: "high_60d", "low_60d", "annual_vol_20d", + "ma20_bias", } or (name.startswith("ma") and name[2:].isdigit()) or (name.startswith("rsi_") and name[4:].isdigit()) @@ -3696,6 +3714,17 @@ def _compute_matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray: where=volume_valid & np.isfinite(previous_mean) & (previous_mean != 0), ) return out + if name == "ma20_bias": + ma20 = valid_rolling_mean(market.close, close_valid, 20) + out = np.full(market.shape, np.nan, dtype=np.float32) + np.divide( + market.close, + ma20, + out=out, + where=close_valid & np.isfinite(ma20) & (ma20 != 0), + ) + out -= np.float32(1.0) + return out if name.startswith("ma") and name[2:].isdigit(): return valid_rolling_mean(market.close, close_valid, int(name[2:])) if name == "boll_upper" or name == "boll_lower": diff --git a/backend/app/backtest/minute_trigger.py b/backend/app/backtest/minute_trigger.py new file mode 100644 index 0000000..4969d08 --- /dev/null +++ b/backend/app/backtest/minute_trigger.py @@ -0,0 +1,49 @@ +"""分钟级卖出信号回放的支持范围与参考价计算。""" +from __future__ import annotations + +import numpy as np + +MINUTE_EXIT_TRIGGER_SIGNALS = frozenset({ + "signal_ma5_breakdown", + "signal_ma10_breakdown", + "signal_ma20_breakdown", + "signal_ma_dead_5_20", +}) + + +def unsupported_minute_exit_signals(signals: list[str] | tuple[str, ...]) -> list[str]: + return sorted(set(signals) - MINUTE_EXIT_TRIGGER_SIGNALS) + + +def build_minute_exit_reference( + close: np.ndarray, + fields: dict[str, np.ndarray], + exit_signal_code: np.ndarray, + exit_signal_ids: tuple[str, ...], +) -> np.ndarray: + """为可回放的卖出信号计算当日已知的价格触发线。""" + result = np.full(close.shape, np.nan, dtype=np.float32) + + def _apply(code: int, value: np.ndarray) -> None: + mask = (exit_signal_code == code) & np.isfinite(value) & (value > 0) + result[mask] = value[mask].astype(np.float32) + + with np.errstate(divide="ignore", invalid="ignore"): + for code, signal_id in enumerate(exit_signal_ids): + if signal_id == "signal_ma5_breakdown" and "ma5" in fields: + _apply(code, (5.0 * fields["ma5"] - close) / 4.0) + elif signal_id == "signal_ma10_breakdown" and "ma10" in fields: + _apply(code, (10.0 * fields["ma10"] - close) / 9.0) + elif signal_id == "signal_ma20_breakdown" and "ma20" in fields: + _apply(code, (20.0 * fields["ma20"] - close) / 19.0) + elif ( + signal_id == "signal_ma_dead_5_20" + and "ma5" in fields + and "ma20" in fields + ): + sum4 = 5.0 * fields["ma5"] - close + sum19 = 20.0 * fields["ma20"] - close + _apply(code, (sum19 - 4.0 * sum4) / 3.0) + + result.setflags(write=False) + return result diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py index 9817f56..ce17fa4 100644 --- a/backend/app/backtest/strategy.py +++ b/backend/app/backtest/strategy.py @@ -32,6 +32,7 @@ from app.backtest.matrix import ( slice_market_data_matrix, slice_signal_matrix, ) +from app.backtest.minute_trigger import unsupported_minute_exit_signals from app.config import settings from app.indicators.pipeline import ( ENRICHED_STORAGE_COLS, @@ -40,6 +41,7 @@ from app.indicators.pipeline import ( get_signal_dependencies, ) from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine +from app.strategy.scoring import scoring_dependencies, scoring_value_expr logger = logging.getLogger(__name__) @@ -133,7 +135,7 @@ class StrategyDependencyResolver: scoring = dict(strategy.meta.get("scoring", {}) or {}) scoring.update(overrides.get("scoring") or {}) - required_features.update(str(column) for column, weight in scoring.items() if weight) + required_features.update(scoring_dependencies(scoring)) order_by = strategy.meta.get("order_by") if order_by and order_by != "score": required_features.add(str(order_by)) @@ -217,7 +219,7 @@ class StrategyDependencyResolver: required_features.update(_basic_filter_dependencies(basic_filter)) scoring = dict(strategy.meta.get("scoring", {}) or {}) scoring.update(overrides.get("scoring") or {}) - required_features.update(str(name) for name, weight in scoring.items() if weight) + required_features.update(scoring_dependencies(scoring)) order_by = strategy.meta.get("order_by") if order_by and order_by != "score": required_features.add(str(order_by)) @@ -457,7 +459,7 @@ class StrategyBacktestConfig: # 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 + exit_fill: Literal["close_t", "open_t+1", "signal_next_minute"] | None = None fees_pct: float = 0.0002 commission_pct: float | None = None stamp_tax_pct: float | None = None @@ -536,6 +538,7 @@ class BacktestResultPolicy: "error", "timing_ms", "execution", + "selection", "execution_backend", "shared_market_data", "shared_market_data_bytes", @@ -798,6 +801,14 @@ class StrategyBacktestService: 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) + if config.exit_fill == "signal_next_minute": + if not config.minute_fill: + return _err("触发后下一分钟成交需要先开启分钟成交") + if not exit_signals: + return _err("当前策略没有卖出信号,无法使用触发后下一分钟成交") + unsupported = unsupported_minute_exit_signals(exit_signals) + if unsupported: + return _err(f"以下卖出信号暂不支持分钟触发回放: {', '.join(unsupported)}") 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)), @@ -972,6 +983,7 @@ class StrategyBacktestService: minute_fill=config.minute_fill, ) t_signal = time.perf_counter() + selection_stats: dict[str, int | bool] if s.execution_backend == "matrix_native": if s.matrix_strategy is None: @@ -1061,6 +1073,12 @@ class StrategyBacktestService: return _err("在指定区间内未产生买入信号") raw_candidates = int(sim_signal_matrix.entry.sum()) + selection_stats = { + "strategy_matches": raw_candidates, + "entry_candidates": raw_candidates, + "entry_trigger_filtered": 0, + "entry_trigger_enabled": False, + } del market_data, signal_matrix t_matrix = time.perf_counter() @@ -1070,6 +1088,7 @@ class StrategyBacktestService: entry_delay_bars=1 if matcher_config.entry_fill == "open_t+1" else 0, exit_delay_bars=1 if matcher_config.exit_fill == "open_t+1" else 0, reference_price=reference_price, + minute_exit_trigger=matcher_config.exit_fill == "signal_next_minute", ) timing_ms["matrix_build"] = round((time.perf_counter() - t_matrix) * 1000, 1) del sim_market_data, sim_signal_matrix @@ -1090,6 +1109,7 @@ class StrategyBacktestService: 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) + formal_candidate_mask = candidate_mask & formal_range 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") @@ -1109,6 +1129,13 @@ class StrategyBacktestService: panel_rows = int(sim_panel.height) panel_columns = int(sim_panel.width) raw_candidates = int(sim_entry_mask.sum()) + strategy_matches = int(formal_candidate_mask.sum()) + selection_stats = { + "strategy_matches": strategy_matches, + "entry_candidates": raw_candidates, + "entry_trigger_filtered": max(strategy_matches - raw_candidates, 0), + "entry_trigger_enabled": bool(entry_signals), + } t_matrix = time.perf_counter() market_matrix = build_market_matrix( @@ -1119,6 +1146,7 @@ class StrategyBacktestService: exit_delay_bars=1 if matcher_config.exit_fill == "open_t+1" else 0, entry_signal_ids=entry_signals, exit_signal_ids=exit_signals, + minute_exit_trigger=matcher_config.exit_fill == "signal_next_minute", ) timing_ms["matrix_build"] = round((time.perf_counter() - t_matrix) * 1000, 1) del panel, sim_panel, sim_entry_mask, sim_exit_mask @@ -1165,6 +1193,7 @@ class StrategyBacktestService: result.stats["feature_columns"] = feature_width result.stats["full_feature_fallback"] = feature_plan.full_feature_fallback result.stats["execution_backend"] = s.execution_backend + result.stats["selection"] = selection_stats result.stats["shared_market_data"] = prepared is not None result.stats["matrix_data_cache_hit"] = matrix_data_cache_hit result.stats["matrix_data_cache_status"] = matrix_data_cache_status @@ -1631,6 +1660,11 @@ class StrategyBacktestService: "matching": c.matching, "entry_fill": c.entry_fill, "exit_fill": c.exit_fill, + "timing_mode": ( + "strict" + if c.entry_fill == "open_t+1" and c.exit_fill == "open_t+1" + else "custom" + ), "fees_pct": c.fees_pct, "commission_pct": c.commission_pct, "stamp_tax_pct": c.stamp_tax_pct, @@ -1641,6 +1675,7 @@ class StrategyBacktestService: "position_sizing": c.position_sizing, "mode": c.mode, "holding_days": c.holding_days, + "minute_fill": c.minute_fill, } @staticmethod @@ -1660,28 +1695,31 @@ class StrategyBacktestService: if has_universe: work = work.with_columns(universe_mask.rename("_score_universe")) - def _value_in_universe(col: str) -> pl.Expr: + def _value_in_universe(value: pl.Expr) -> pl.Expr: if has_universe: - return pl.when(pl.col("_score_universe")).then(pl.col(col)).otherwise(None) - return pl.col(col) + return pl.when(pl.col("_score_universe")).then(value).otherwise(None) + return value 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()) + executable = [ + (value, weight) + for col, weight in scoring.items() + if weight and (value := scoring_value_expr(work.columns, str(col))) is not None + ] + total_weight = sum(weight for _, weight in executable) if total_weight > 0: score_parts: list[pl.Expr] = [] - for col, weight in scoring.items(): - if col not in work.columns: - continue + for score_value, weight in executable: w = weight / total_weight - value = _value_in_universe(col) + value = _value_in_universe(score_value) 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 + (score_value - col_min) / col_range ).otherwise(pl.lit(0.5)) if has_universe: normalized = pl.when(pl.col("_score_universe")).then(normalized).otherwise(0.0) diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index 93b1dda..861997e 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -786,7 +786,7 @@ def _maybe_push_review(content: str, meta: dict) -> None: continue secret = preferences.get_feishu_webhook_secret() ok = webhook_adapter.send_feishu_card( - url, "TickFlow · 每日复盘", subtitle, content, secret + url, "每日复盘", subtitle, content, secret ) logger.info("review push(feishu) %s", "sent" if ok else "failed") elif ch == "wecom": @@ -797,7 +797,7 @@ def _maybe_push_review(content: str, meta: dict) -> None: # 企业微信 markdown 标题已含一级标题, subtitle 拼到正文首行 full_body = (f"**{subtitle}**\n\n{content}" if subtitle else content) ok = webhook_adapter.send_wecom_markdown( - url, "TickFlow · 每日复盘", full_body + url, "每日复盘", full_body ) logger.info("review push(wecom) %s", "sent" if ok else "failed") # 未来更多渠道在此追加分支 diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index b3deccb..81ecb8c 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -1225,7 +1225,7 @@ class QuoteService: # 反查规则, 过滤出启用推送的事件 source_labels = { "strategy": "策略", "signal": "信号", - "price": "价格", "market": "异动", + "price": "价格", "market": "异动", "ladder": "连板梯队", } rules = engine.rules if engine is not None else {} enqueued = 0 @@ -1241,7 +1241,7 @@ class QuoteService: symbol = ev.get("symbol") or "" name = ev.get("name") or "" message = ev.get("message") or "" - title = f"TickFlow · {source_label}" + title = source_label body = f"{symbol} {name} {message}".strip() if symbol else (message or name) # 提交到独立线程池, 不阻塞行情轮询线程 (webhook 慢/重试不拖累实时行情+告警)。 # 按渠道独立投递: 飞书 / 企业微信谁被勾选且已配置就推谁。 diff --git a/backend/app/strategy/ai_generator.py b/backend/app/strategy/ai_generator.py index 909d061..648cb69 100644 --- a/backend/app/strategy/ai_generator.py +++ b/backend/app/strategy/ai_generator.py @@ -7,9 +7,13 @@ from __future__ import annotations import ast import logging +import math import re from pathlib import Path +from app.indicators.pipeline import ENRICHED_COLUMNS +from app.strategy.scoring import VIRTUAL_SCORING_DEPENDENCIES + logger = logging.getLogger(__name__) # 策略开发精简指南路径 (随 backend/app 打包进 Docker, 避免 .dockerignore 排除 docs/ 导致运行时缺失) @@ -24,10 +28,10 @@ _SYSTEM_PREFIX = """你是A股量化策略设计专家。根据用户描述的 4. polars 策略只 import polars 和 datetime;matrix_native 策略只允许 import numpy 以及 from app.backtest.matrix import 所需矩阵协议和算子 要求: -1. 用户可能调整的策略阈值通过 META["params"] 暴露;公式常数、固定窗口边界、布尔开关不必强行参数化 +1. 用户可能调整的策略阈值通过 META["params"] 暴露,每项使用 id/label/type/default/min/max/step;公式常数、固定窗口边界、布尔开关不必强行参数化 2. 遵循指南中的文件结构,但优先贴合用户规则,不要为了套模板歪曲策略含义 3. ENTRY_SIGNALS/EXIT_SIGNALS 根据策略逻辑自行选择匹配的信号列,不要照搬示例 -4. scoring 权重根据策略核心逻辑定制,总和 = 1.0 +4. scoring 权重根据策略核心逻辑定制,总和 = 1.0;键只能使用指南中的真实数值字段或受控虚拟评分字段 ma20_bias,不得创造条件名称作为评分列 5. 优先使用 Polars 表达式、窗口函数、聚合和 with_columns/filter 实现,避免逐行/逐股 Python 循环;只有表达式难以描述的复杂状态机才使用 partition_by/to_dicts 6. 直接输出Python代码,不要输出其他内容 7. 元数据必须使用模块顶层的 META = {...} 或 META: dict = {...},不得省略或改名;并且必须定义所选执行后端要求的策略入口 @@ -44,6 +48,21 @@ _FENCED_CODE_RE = re.compile( _POLARS_ENTRYPOINT_ERROR = "找不到策略入口函数 filter() 或 filter_history()" _MATRIX_ENTRYPOINT_ERROR = "找不到 Matrix 策略入口 MATRIX_STRATEGY" +_POLARS_SCORING_FIELDS = frozenset( + name + for name in ENRICHED_COLUMNS + if name not in {"symbol", "date", "name"} and not name.startswith("signal_") +) | frozenset(VIRTUAL_SCORING_DEPENDENCIES) +_MATRIX_SCORING_FIELDS = frozenset({ + "open", "high", "low", "close", "volume", "amount", "turnover_rate", + "total_shares", "float_shares", "consecutive_limit_ups", + "consecutive_limit_downs", "prev_close", "change_pct", "change_amount", + "amplitude", "ma5", "ma10", "ma20", "ma30", "ma60", "boll_upper", + "boll_lower", "high_60d", "low_60d", "momentum_5d", "momentum_10d", + "momentum_20d", "momentum_30d", "momentum_60d", "annual_vol_20d", + "rsi_6", "rsi_14", "rsi_24", "vol_ratio_5d", "ma20_bias", +}) + def _top_level_assignment( tree: ast.Module, @@ -195,6 +214,16 @@ class AIStrategyGenerator: "error": entrypoint_error, } + try: + self._validate_meta_semantics(code, meta) + except ValueError as e: + return { + "code": code, + "meta": meta, + "valid": False, + "error": str(e), + } + return { "code": code, "meta": meta, @@ -208,7 +237,42 @@ class AIStrategyGenerator: return error.startswith("解析META失败:") or error in { _POLARS_ENTRYPOINT_ERROR, _MATRIX_ENTRYPOINT_ERROR, - } + } or error.startswith(("META.params", "META.scoring")) + + @staticmethod + def _validate_meta_semantics(code: str, meta: dict) -> None: + params = meta.get("params", []) + if isinstance(params, (list, tuple)): + for index, item in enumerate(params): + if isinstance(item, dict) and not str(item.get("id") or "").strip(): + raise ValueError(f"META.params[{index}] 缺少非空 id") + + scoring = meta.get("scoring", {}) + if not isinstance(scoring, dict): + raise ValueError("META.scoring 必须是字典") + if not scoring: + return + + for name, weight in scoring.items(): + if not isinstance(name, str) or not name: + raise ValueError("META.scoring 字段名必须是非空字符串") + if isinstance(weight, bool) or not isinstance(weight, (int, float)) \ + or not math.isfinite(float(weight)) or weight < 0: + raise ValueError(f"META.scoring[{name!r}] 权重必须是非负有限数值") + total_weight = sum(float(weight) for weight in scoring.values()) + if not math.isclose(total_weight, 1.0, rel_tol=0.0, abs_tol=1e-6): + raise ValueError("META.scoring 权重总和必须为 1.0") + + backend = _strategy_execution_backend(ast.parse(code), meta) + if backend == "python_history_legacy": + return + allowed = _MATRIX_SCORING_FIELDS if backend == "matrix_native" else _POLARS_SCORING_FIELDS + unknown = sorted(set(scoring) - set(allowed)) + if unknown: + raise ValueError( + f"META.scoring 引用了不可用字段: {unknown}; " + "请使用真实数值字段或受控虚拟字段 ma20_bias" + ) async def repair_code(self, code: str, error: str) -> dict: """Ask the model once for a complete replacement after a structural error.""" diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 1220681..6bca5f8 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -19,6 +19,8 @@ from typing import Any, Callable import numpy as np import polars as pl +from app.strategy.scoring import scoring_dependencies, scoring_value_expr + logger = logging.getLogger(__name__) # 引擎级默认基础过滤 — 策略未定义 BASIC_FILTER 时兜底 @@ -792,7 +794,7 @@ class StrategyEngine: fields.add(field_name) scoring = dict(strategy.meta.get("scoring", {}) or {}) scoring.update((overrides or {}).get("scoring") or {}) - fields.update(scoring) + fields.update(scoring_dependencies(scoring)) order_by = strategy.meta.get("order_by") if order_by and order_by != "score": fields.add(str(order_by)) @@ -1003,19 +1005,23 @@ class StrategyEngine: """通用评分: min-max 归一化 → 加权求和 → 0~100 分""" if not weights: return df - total_weight = sum(weights.values()) + + executable = [ + (value, weight) + for col, weight in weights.items() + if weight and (value := scoring_value_expr(df.columns, str(col))) is not None + ] + total_weight = sum(weight for _, weight in executable) if total_weight <= 0: return df score_parts: list[pl.Expr] = [] - for col, weight in weights.items(): - if col not in df.columns: - continue + for value, weight in executable: w = weight / total_weight - col_min = pl.col(col).min() - col_range = pl.col(col).max() - col_min + col_min = value.min() + col_range = value.max() - col_min normalized = pl.when(col_range > 0).then( - (pl.col(col) - col_min) / col_range + (value - col_min) / col_range ).otherwise(pl.lit(0.5)) score_parts.append(normalized * w) diff --git a/backend/app/strategy/prompts/strategy-builder-step1.md b/backend/app/strategy/prompts/strategy-builder-step1.md index 96aa663..a11c5f5 100644 --- a/backend/app/strategy/prompts/strategy-builder-step1.md +++ b/backend/app/strategy/prompts/strategy-builder-step1.md @@ -69,7 +69,7 @@ META = { # 只把用户可能调节的阈值放这里;每个参数含 id/label/type/default/min/max/step ], "scoring": { - # 根据策略核心逻辑定制权重,总和 = 1.0 + # 只使用真实数值字段或 ma20_bias,总和 = 1.0 }, "order_by": "score", "descending": True, @@ -199,7 +199,7 @@ def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: 1. 用户可能调节的阈值才放 `params`;公式常数、固定窗口边界不必参数化 2. 信号列使用 `.fill_null(False)` 处理空值 3. `filter()` 只返回 `pl.Expr`,`filter_history()` 返回筛选后的 `DataFrame` -4. scoring 权重总和 = 1.0 +4. scoring 权重总和 = 1.0,键只能使用可用数值列或临时评分字段 `ma20_bias`,不要使用 `close_above_ma20` 等条件名称 5. **必须生成 RULES**:用中文逐条列出核心逻辑(至少 3 条),准确完整 6. **贴合用户需求**:不为了用已有字段而改变用户本意。用户说"前高"就自己算前高 7. **输出前自我检查**:确认 RULES 完整、语法正确、括号匹配、引号闭合 diff --git a/backend/app/strategy/prompts/strategy-guide-compact.md b/backend/app/strategy/prompts/strategy-guide-compact.md index b82f640..d02fdff 100644 --- a/backend/app/strategy/prompts/strategy-guide-compact.md +++ b/backend/app/strategy/prompts/strategy-guide-compact.md @@ -7,8 +7,8 @@ 1. Polars/历史策略允许 `polars`、`datetime`;矩阵策略允许 `numpy`、`app.backtest.matrix`。 2. AI 策略只属于 `data/strategies/ai/`,`META.id` 使用用户给定的 `ai_` ID。 3. 不要读写文件,不要使用 `open/exec/eval/compile/__import__/globals/locals/vars/dir/getattr/setattr/delattr/type/input`。 -4. `META.params` 只放用户可能调整的阈值;公式常数和固定窗口边界不必参数化。 -5. `META.scoring` 权重总和必须为 1.0。 +4. `META.params` 每项使用 `id/label/type/default/min/max/step`,只放可调阈值。 +5. `META.scoring` 仅使用真实数值字段或 `ma20_bias`,权重和为 1.0。 6. `ENTRY_SIGNALS` / `EXIT_SIGNALS` 只选和策略逻辑直接相关的信号,不要凑数。 7. `RULES` 用中文逐条列出核心逻辑,至少 3 条。 8. 优先 Polars 表达式、`with_columns`、`over("symbol")`、`group_by`、`join`、`filter`,避免逐行循环。 @@ -154,6 +154,8 @@ anchor_date = _date.fromisoformat(anchor_raw) if isinstance(anchor_raw, str) els 动量与波动:`momentum_5d`, `momentum_10d`, `momentum_20d`, `momentum_30d`, `momentum_60d`, `annual_vol_20d`, `high_60d`, `low_60d` +虚拟评分:`ma20_bias = close / ma20 - 1`(仅内存计算)。 + 涨跌停:`consecutive_limit_ups`, `consecutive_limit_downs` 市值相关:`total_shares`, `float_shares`,可用 `close * total_shares` 估算总市值。 diff --git a/backend/app/strategy/scoring.py b/backend/app/strategy/scoring.py new file mode 100644 index 0000000..8bff57a --- /dev/null +++ b/backend/app/strategy/scoring.py @@ -0,0 +1,37 @@ +"""策略评分字段解析。""" +from __future__ import annotations + +from collections.abc import Collection, Mapping +from typing import Any + +import polars as pl + + +VIRTUAL_SCORING_DEPENDENCIES: dict[str, frozenset[str]] = { + "ma20_bias": frozenset({"close", "ma20"}), +} + + +def scoring_dependencies(scoring: Mapping[str, Any]) -> set[str]: + """把受控虚拟评分字段展开为实际数据依赖。""" + dependencies: set[str] = set() + for name, weight in scoring.items(): + if not weight: + continue + dependencies.update(VIRTUAL_SCORING_DEPENDENCIES.get(str(name), {str(name)})) + return dependencies + + +def scoring_value_expr(columns: Collection[str], name: str) -> pl.Expr | None: + """返回评分值表达式;依赖不完整时返回 None。""" + available = set(columns) + if name in available: + return pl.col(name) + dependencies = VIRTUAL_SCORING_DEPENDENCIES.get(name) + if dependencies is None or not dependencies.issubset(available): + return None + if name == "ma20_bias": + return pl.when(pl.col("ma20") != 0).then( + pl.col("close") / pl.col("ma20") - 1.0 + ).otherwise(None) + return None diff --git a/backend/tests/backtest/test_dependencies.py b/backend/tests/backtest/test_dependencies.py index 2c3c424..891dbe9 100644 --- a/backend/tests/backtest/test_dependencies.py +++ b/backend/tests/backtest/test_dependencies.py @@ -44,6 +44,27 @@ def test_resolver_merges_signals_scoring_filter_and_execution_columns(): assert plan.full_feature_fallback is False +def test_resolver_expands_virtual_scoring_dependencies(): + strategy = _strategy(meta={ + "id": "deps", + "scoring": {"ma20_bias": 0.6, "vol_ratio_5d": 0.4}, + "order_by": "score", + }) + + plan = StrategyDependencyResolver().resolve( + strategy, + params={"rsi_max": 30}, + basic_filter={"enabled": False}, + entry_signals=[], + exit_signals=[], + ) + + assert {"ma20", "vol_ratio_5d"} <= set(plan.indicator_columns) + assert "close" in plan.base_columns + assert "ma20_bias" not in plan.base_columns + assert "ma20_bias" not in plan.indicator_columns + + def test_history_strategy_without_required_features_falls_back_to_full(caplog): strategy = _strategy( filter_fn=None, diff --git a/backend/tests/backtest/test_engine_portfolio.py b/backend/tests/backtest/test_engine_portfolio.py index 03108a2..7af229d 100644 --- a/backend/tests/backtest/test_engine_portfolio.py +++ b/backend/tests/backtest/test_engine_portfolio.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import date, timedelta +from datetime import date, datetime, timedelta import polars as pl @@ -421,3 +421,100 @@ def test_default_fill_is_buy_open_sell_close(): assert trade.entry_price == 10.0 # 次日开盘 assert trade.exit_price == 10.8 # 到期日收盘 assert trade.exit_reason == "max_hold" + + +class _MinuteRepo: + def __init__(self, rows: pl.DataFrame) -> None: + self.rows = rows + + def get_minute_by_dates(self, symbols, dates, asset_type="stock"): + return self.rows.filter(pl.col("symbol").is_in(symbols)) + + +def _minute_trigger_panel() -> tuple[pl.DataFrame, pl.Series, pl.Series]: + panel = _panel( + ["A"], + days=4, + overrides={ + ("A", 2): {"open": 10.1, "high": 10.3, "low": 8.9, "close": 9.0}, + ("A", 3): {"open": 8.8, "high": 9.0, "low": 8.7, "close": 8.9}, + }, + ).with_columns([ + pl.Series("ma20", [10.0, 10.0, 9.95, 9.9]), + pl.Series("signal_ma20_breakdown", [False, False, True, False]), + ]) + return panel, _mask(panel, {("A", 0)}), _mask(panel, {("A", 2)}) + + +def test_minute_signal_exit_fills_at_next_minute_open(): + panel, entries, exits = _minute_trigger_panel() + minute = pl.DataFrame({ + "symbol": ["A", "A", "A"], + "datetime": [ + datetime(2024, 1, 3, 9, 31), + datetime(2024, 1, 3, 9, 32), + datetime(2024, 1, 3, 9, 33), + ], + "open": [10.2, 10.1, 9.7], + "high": [10.3, 10.2, 9.8], + "low": [10.1, 9.8, 9.6], + "close": [10.2, 9.9, 9.7], + "volume": [100.0, 100.0, 100.0], + "amount": [1020.0, 990.0, 970.0], + }) + + result = BacktestEngine(repo=_MinuteRepo(minute)).simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + entry_fill="open_t+1", + exit_fill="signal_next_minute", + minute_fill=True, + fees_pct=0, + slippage_bps=0, + max_positions=1, + initial_capital=100_000, + ), + exit_signal_ids=["signal_ma20_breakdown"], + ) + + assert len(result.trades) == 1 + assert result.trades[0].exit_date == "2024-01-03" + assert result.trades[0].exit_price == 9.7 + assert result.trades[0].exit_signal_id == "signal_ma20_breakdown" + + +def test_minute_signal_exit_without_next_bar_falls_back_to_next_open(): + panel, entries, exits = _minute_trigger_panel() + minute = pl.DataFrame({ + "symbol": ["A"], + "datetime": [datetime(2024, 1, 3, 15, 0)], + "open": [9.9], + "high": [10.0], + "low": [8.9], + "close": [9.0], + "volume": [100.0], + "amount": [900.0], + }) + + result = BacktestEngine(repo=_MinuteRepo(minute)).simulate_portfolio( + panel, + entries, + exits, + MatcherConfig( + entry_fill="open_t+1", + exit_fill="signal_next_minute", + minute_fill=True, + fees_pct=0, + slippage_bps=0, + max_positions=1, + initial_capital=100_000, + ), + exit_signal_ids=["signal_ma20_breakdown"], + ) + + assert len(result.trades) == 1 + assert result.trades[0].exit_date == "2024-01-04" + assert result.trades[0].exit_price == 8.8 + assert result.stats["execution"]["sell_minute_trigger_fallback"] == 1 diff --git a/backend/tests/backtest/test_matrix_strategy.py b/backend/tests/backtest/test_matrix_strategy.py index 99e9d6f..ab92e82 100644 --- a/backend/tests/backtest/test_matrix_strategy.py +++ b/backend/tests/backtest/test_matrix_strategy.py @@ -66,6 +66,19 @@ def test_common_matrix_features_match_polars_indicator_pipeline(): actual = matrix_feature(market, name)[:, 0] np.testing.assert_allclose(actual, expected, rtol=2e-5, atol=2e-5, equal_nan=True) + expected_bias = ( + enriched.sort(["date", "symbol"])["close"].to_numpy() + / enriched.sort(["date", "symbol"])["ma20"].to_numpy() + - 1.0 + ) + np.testing.assert_allclose( + matrix_feature(market, "ma20_bias")[:, 0], + expected_bias, + rtol=2e-5, + atol=2e-5, + equal_nan=True, + ) + def _panel_with_missing_asset_bar() -> pl.DataFrame: rows = [] diff --git a/backend/tests/backtest/test_minute_fill.py b/backend/tests/backtest/test_minute_fill.py index bda4086..ccae5d3 100644 --- a/backend/tests/backtest/test_minute_fill.py +++ b/backend/tests/backtest/test_minute_fill.py @@ -19,6 +19,7 @@ import numpy as np import polars as pl from app.backtest.engine import BacktestEngine +from app.backtest.minute_trigger import build_minute_exit_reference NUMERIC_COLS = BacktestEngine._MINUTE_NUMERIC_COLS # open/high/low/close/volume/amount @@ -86,6 +87,40 @@ def test_resolve_minute_fill_empty_returns_none(): assert BacktestEngine._resolve_minute_fill(None, None, "buy") is None +def test_resolve_minute_exit_trigger_uses_next_minute_open(): + arr = np.array([ + [10.2, 10.3, 10.1, 10.2, 100, 1020], + [10.1, 10.2, 9.8, 9.9, 100, 990], + [9.7, 9.8, 9.6, 9.7, 100, 970], + ], dtype=np.float64) + + assert BacktestEngine._resolve_minute_exit_trigger(arr, 10.0) == 9.7 + + +def test_resolve_minute_exit_trigger_without_next_bar_returns_none(): + arr = np.array([ + [10.2, 10.3, 10.1, 10.2, 100, 1020], + [10.1, 10.2, 9.8, 9.9, 100, 990], + ], dtype=np.float64) + + assert BacktestEngine._resolve_minute_exit_trigger(arr, 10.0) is None + + +def test_minute_exit_reference_removes_current_close_from_ma20(): + close = np.array([[9.0]], dtype=np.float32) + fields = {"ma20": np.array([[9.95]], dtype=np.float32)} + codes = np.array([[0]], dtype=np.int16) + + result = build_minute_exit_reference( + close, + fields, + codes, + ("signal_ma20_breakdown",), + ) + + assert result[0, 0] == 10.0 + + class _FakeRepo: """最小 repo 桩: get_minute_by_dates 直接返回预构造的混合列 DataFrame。""" diff --git a/backend/tests/backtest/test_strategy_backtest_correctness.py b/backend/tests/backtest/test_strategy_backtest_correctness.py index 5df49fd..d9d8489 100644 --- a/backend/tests/backtest/test_strategy_backtest_correctness.py +++ b/backend/tests/backtest/test_strategy_backtest_correctness.py @@ -154,6 +154,56 @@ def test_basic_filter_only_limits_entries_not_panel_rows(): assert engine.sim_matrix.entry[:, 0].tolist() == [1, 0, 1] assert engine.load_args is not None assert engine.load_args[1] < start # warmup 只用于计算, 不参与正式交易 + assert result.stats["selection"] == { + "strategy_matches": 2, + "entry_candidates": 2, + "entry_trigger_filtered": 0, + "entry_trigger_enabled": False, + } + + +def test_selection_stats_explain_entry_trigger_filtering(): + start = date(2024, 1, 1) + panel = pl.DataFrame([ + { + "symbol": symbol, + "name": symbol, + "date": start, + "open": 10.0, + "high": 10.0, + "low": 10.0, + "close": 10.0, + "volume": 1000.0, + "amount": 1000.0, + "signal_limit_up": symbol == "A", + "signal_limit_down": False, + } + for symbol in ("A", "B") + ]).sort(["symbol", "date"]) + engine = _EngineStub(panel) + service = StrategyBacktestService( + engine=engine, + strategy_engine=_StrategyEngineStub( + _strategy(entry_signals=["signal_limit_up"]), + ), + ) + + result = service.run(StrategyBacktestConfig( + strategy_id="test", + symbols=None, + start=start, + end=start, + matching="close_t", + mode="position", + )) + + assert result.error is None + assert result.stats["selection"] == { + "strategy_matches": 2, + "entry_candidates": 1, + "entry_trigger_filtered": 1, + "entry_trigger_enabled": True, + } def test_score_normalizes_inside_strategy_candidate_universe(): @@ -254,6 +304,12 @@ def test_matrix_native_strategy_uses_shared_orchestrator_path(): assert engine.sim_matrix is not None assert engine.sim_matrix.entry[:, 0].tolist() == [1, 1] assert result.stats["execution_backend"] == "matrix_native" + assert result.stats["selection"] == { + "strategy_matches": 2, + "entry_candidates": 2, + "entry_trigger_filtered": 0, + "entry_trigger_enabled": False, + } def test_matrix_native_accepts_legacy_default_signal_overrides_but_rejects_replacements(): diff --git a/backend/tests/test_ai_strategy_meta_normalize.py b/backend/tests/test_ai_strategy_meta_normalize.py index 791a8ff..95c031b 100644 --- a/backend/tests/test_ai_strategy_meta_normalize.py +++ b/backend/tests/test_ai_strategy_meta_normalize.py @@ -4,6 +4,7 @@ from __future__ import annotations import pytest from app.api.strategy import _normalize_build_result, _normalize_strategy_meta +from app.strategy.ai_generator import AIStrategyGenerator RAW_CODE = '''"""模型返回的策略""" import polars as pl @@ -210,6 +211,41 @@ MATRIX_STRATEGY = object() assert result["error"] is None +def test_validate_code_accepts_controlled_virtual_scoring_field(): + code = RAW_CODE.replace( + '"scoring": {},', + '"scoring": {"ma20_bias": 0.6, "vol_ratio_5d": 0.4},', + ) + + result = AIStrategyGenerator().validate_code(code) + + assert result["valid"] is True + + +def test_validate_code_rejects_unknown_scoring_field(): + code = RAW_CODE.replace( + '"scoring": {},', + '"scoring": {"close_above_ma20": 1.0},', + ) + + result = AIStrategyGenerator().validate_code(code) + + assert result["valid"] is False + assert "close_above_ma20" in result["error"] + + +def test_validate_code_rejects_param_without_id(): + code = RAW_CODE.replace( + '"params": [],', + '"params": [{"name": "volume_ratio", "default": 1.5}],', + ) + + result = AIStrategyGenerator().validate_code(code) + + assert result["valid"] is False + assert result["error"] == "META.params[0] 缺少非空 id" + + def test_validate_code_rejects_missing_matrix_strategy_entrypoint(): from app.strategy.ai_generator import AIStrategyGenerator diff --git a/backend/tests/test_pipeline_and_monitor_fixes.py b/backend/tests/test_pipeline_and_monitor_fixes.py index 4329dc5..a3dd75e 100644 --- a/backend/tests/test_pipeline_and_monitor_fixes.py +++ b/backend/tests/test_pipeline_and_monitor_fixes.py @@ -7,12 +7,13 @@ from __future__ import annotations import polars as pl import pytest -from app.services import pipeline_jobs +from app.jobs import daily_pipeline +from app.services import pipeline_jobs, quote_service from app.services.pipeline_jobs import JobStore +from app.services.quote_service import QuoteService from app.strategy import monitor_rules from app.strategy.monitor import MonitorRuleEngine - # ── JobStore 单飞 ──────────────────────────────────────────────────────── def test_create_singleflight_dedupes_pending_window(tmp_path): @@ -97,3 +98,55 @@ def test_apply_scope_sector_fails_closed(): df, {"scope": "symbols", "symbols": ["600000.SH"]} ) assert picked.height == 1 + + +def test_ladder_webhook_uses_chinese_title_without_brand(monkeypatch): + calls = [] + + class CaptureExecutor: + def submit(self, fn, *args): + calls.append((fn, args)) + + monkeypatch.setattr(quote_service, "_WEBHOOK_EXECUTOR", CaptureExecutor()) + monkeypatch.setattr("app.services.preferences.get_feishu_webhook_url", lambda: "https://open.feishu.cn/open-apis/bot/v2/hook/test") + monkeypatch.setattr("app.services.preferences.get_feishu_webhook_secret", lambda: "secret") + monkeypatch.setattr("app.services.preferences.get_wecom_webhook_url", lambda: "wecom-key") + + engine = type("Engine", (), { + "rules": {"r_ladder": {"webhook_channels": ["feishu", "wecom"]}}, + })() + QuoteService._maybe_send_webhook( + object.__new__(QuoteService), + [{ + "rule_id": "r_ladder", + "source": "ladder", + "symbol": "600000.SH", + "name": "浦发银行", + "message": "炸板预警", + }], + engine, + ) + + assert [args[1] for _, args in calls] == ["连板梯队", "连板梯队"] + assert all("TickFlow" not in args[1] for _, args in calls) + + +def test_review_webhooks_use_title_without_brand(monkeypatch): + calls = [] + monkeypatch.setattr("app.services.preferences.get_review_push_channels", lambda: ["feishu", "wecom"]) + monkeypatch.setattr("app.services.preferences.get_feishu_webhook_url", lambda: "feishu-url") + monkeypatch.setattr("app.services.preferences.get_feishu_webhook_secret", lambda: "secret") + monkeypatch.setattr("app.services.preferences.get_wecom_webhook_url", lambda: "wecom-url") + monkeypatch.setattr( + "app.services.webhook_adapter.send_feishu_card", + lambda *args: calls.append(("feishu", args)) or True, + ) + monkeypatch.setattr( + "app.services.webhook_adapter.send_wecom_markdown", + lambda *args: calls.append(("wecom", args)) or True, + ) + + daily_pipeline._maybe_push_review("复盘正文", {"as_of": "2026-07-18"}) + + assert [args[1] for _, args in calls] == ["每日复盘", "每日复盘"] + assert all("TickFlow" not in args[1] for _, args in calls) diff --git a/backend/tests/test_strategy_code_save.py b/backend/tests/test_strategy_code_save.py index dc1c2aa..1816220 100644 --- a/backend/tests/test_strategy_code_save.py +++ b/backend/tests/test_strategy_code_save.py @@ -61,6 +61,19 @@ def test_prepare_strategy_code_rejects_forbidden_import(): _prepare_strategy_code(req) +def test_prepare_strategy_code_rejects_unknown_scoring_field(): + req = StrategyCodeValidateRequest( + strategy_id="custom_bad_score", + code=_code("custom_bad_score").replace( + '"scoring": {},', + '"scoring": {"volume_surge": 1.0},', + ), + ) + + with pytest.raises(ValueError, match="volume_surge"): + _prepare_strategy_code(req) + + def test_save_strategy_code_creates_ai_strategy_in_ai_dir(tmp_path): request = _request(tmp_path) req = StrategyCodeSaveRequest( diff --git a/backend/tests/test_strategy_scoring.py b/backend/tests/test_strategy_scoring.py new file mode 100644 index 0000000..b565b05 --- /dev/null +++ b/backend/tests/test_strategy_scoring.py @@ -0,0 +1,39 @@ +from datetime import date +from types import SimpleNamespace + +import polars as pl +import pytest + +from app.backtest.strategy import StrategyBacktestService +from app.strategy.engine import StrategyEngine + + +def _candidates() -> pl.DataFrame: + return pl.DataFrame({ + "symbol": ["A", "B"], + "date": [date(2024, 1, 2)] * 2, + "close": [11.0, 12.0], + "ma20": [10.0, 10.0], + "vol_ratio_5d": [2.0, 1.0], + }) + + +def test_virtual_scoring_is_shared_and_does_not_add_virtual_column(): + weights = {"ma20_bias": 0.6, "vol_ratio_5d": 0.4} + realtime = StrategyEngine._apply_scoring(_candidates(), weights) + strategy = SimpleNamespace(meta={"scoring": weights, "order_by": "score"}) + backtest = StrategyBacktestService._apply_score(_candidates(), strategy, None) + + assert realtime["score"].to_list() == pytest.approx([40.0, 60.0]) + assert backtest["score"].to_list() == pytest.approx([40.0, 60.0]) + assert "ma20_bias" not in realtime.columns + assert "ma20_bias" not in backtest.columns + + +def test_scoring_reweights_only_available_fields(): + scored = StrategyEngine._apply_scoring( + _candidates().drop("ma20"), + {"ma20_bias": 0.6, "vol_ratio_5d": 0.4}, + ) + + assert scored["score"].to_list() == pytest.approx([100.0, 0.0]) diff --git a/frontend/src/components/screener/StrategyBuilderDialog.tsx b/frontend/src/components/screener/StrategyBuilderDialog.tsx index ee196e6..68d22fb 100644 --- a/frontend/src/components/screener/StrategyBuilderDialog.tsx +++ b/frontend/src/components/screener/StrategyBuilderDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { Modal } from '@/components/Modal' import { X, Sparkles, Save, Loader2, ChevronLeft, ChevronRight, AlertTriangle, Settings2, FileText, Copy, Check, Terminal } from 'lucide-react' import { api } from '@/lib/api' @@ -166,9 +166,15 @@ class CustomMatrixStrategy: MATRIX_STRATEGY = CustomMatrixStrategy() ` -interface Props { open: boolean; onClose: () => void; onSavedId?: (id: string) => void | Promise; mode?: 'create' | 'modify' } +interface Props { + open: boolean + onClose: () => void + onSavedId?: (id: string) => void | Promise + mode?: 'create' | 'modify' + existingStrategyIds?: ReadonlySet +} -export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create' }: Props) { +export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create', existingStrategyIds }: Props) { // 根据 mode 选择存储 key const draftStore = mode === 'modify' ? storage.strategyModify : storage.strategyDraft const [step, setStep] = useState(1) @@ -192,6 +198,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create const [aiStatus, setAiStatus] = useState<{ configured: boolean } | null>(null) const [checkedAi, setCheckedAi] = useState(false) const [loaded, setLoaded] = useState(false) + const suppressPersistRef = useRef(false) const resetDraftState = useCallback(() => { setStep(1); setTab('ai'); setName(''); setDescription(''); setDirection('long') @@ -203,25 +210,31 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create useEffect(() => { if (!open) { setLoaded(false); return } const d = draftStore.get(null) - if (d) { + const draftCodeId = d ? parseMetaField(d.code ?? '', 'id') : '' + const completedDraft = mode === 'create' && !!d && ( + (!!d.strategyId && existingStrategyIds?.has(d.strategyId)) + || (!!draftCodeId && existingStrategyIds?.has(draftCodeId)) + ) + if (completedDraft) { + draftStore.set(null) + resetDraftState() + } else if (d) { const restoredSource = d.source ?? (d.strategyId?.startsWith('custom_') ? 'custom' : 'ai') - const restoredId = mode === 'create' && d.strategyId - ? slugId(restoredSource) - : (d.strategyId ?? '') setStep(d.step ?? 1); setName(d.name ?? ''); setDescription(d.description ?? '') setDirection(d.direction ?? 'long') setExecutionBackend( (d as any).executionBackend ?? (String(d.code ?? '').includes('matrix_native') ? 'matrix_native' : 'polars_expr'), ) - setRules(d.rules ?? ''); setCode(d.code ?? ''); setStrategyId(restoredId) + setRules(d.rules ?? ''); setCode(d.code ?? ''); setStrategyId(d.strategyId ?? '') setSource(restoredSource) setTab(mode === 'modify' || restoredSource === 'custom' ? 'custom' : 'ai') } else { resetDraftState() } + suppressPersistRef.current = false setLoaded(true) - }, [open, mode, draftStore, resetDraftState]) + }, [open, mode, draftStore, existingStrategyIds, resetDraftState]) // 打开时检查 AI 状态 useEffect(() => { @@ -236,15 +249,20 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create } else { draftStore.set({ name, description, direction, executionBackend, rules, code, step, strategyId, source } as any) } - }, [name, description, direction, executionBackend, rules, code, step, strategyId, source]) - useEffect(() => { if (loaded) persist() }, [loaded, persist]) + }, [draftStore, name, description, direction, executionBackend, rules, code, step, strategyId, source]) + useEffect(() => { + if (loaded && !suppressPersistRef.current) persist() + }, [loaded, persist]) const clearDraft = () => { draftStore.set(null) resetDraftState() } - const handleClose = () => { if (name || rules || code) persist(); onClose() } + const handleClose = () => { + if (!suppressPersistRef.current && (name || rules || code)) persist() + onClose() + } const resolveStrategyId = (target: 'ai' | 'custom' = source) => { if (mode === 'modify' && strategyId) return strategyId @@ -353,6 +371,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create name: name.trim(), description: description.trim(), }) + suppressPersistRef.current = true clearDraft() const genRules = parseRules(draftCode) const finalRules = (genRules || rules).trim() @@ -396,7 +415,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create {/* 中间:标题 */} - {strategyId ? '修改策略' : '创建策略'} + {mode === 'modify' ? '修改策略' : '创建策略'} {/* 右侧:步骤 + 关闭 */}
diff --git a/frontend/src/components/screener/StrategySettingsDialog.tsx b/frontend/src/components/screener/StrategySettingsDialog.tsx index 9c3a065..f21db46 100644 --- a/frontend/src/components/screener/StrategySettingsDialog.tsx +++ b/frontend/src/components/screener/StrategySettingsDialog.tsx @@ -21,6 +21,7 @@ Object.assign(FIELD_LABEL, { vol_ratio_5d: '量比', vol_ratio_20d: '20日量比', macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱', boll_upper: '布林上轨', boll_lower: '布林下轨', + ma20_bias: 'MA20乖离率', }) interface Props { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 59958e9..f0beee6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -401,6 +401,7 @@ export interface StrategyDetail { scoring: Record entry_signals: string[] exit_signals: string[] + minute_exit_trigger_supported_signals: string[] stop_loss: number | null take_profit: number | null trailing_stop: number | null @@ -1455,7 +1456,7 @@ export const api = { 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 + exit_fill?: 'close_t' | 'open_t+1' | 'signal_next_minute' | null fees_pct?: number commission_pct?: number stamp_tax_pct?: number @@ -1464,6 +1465,7 @@ export const api = { initial_capital?: number position_sizing?: 'equal' | 'score_weight' asset_type?: 'stock' | 'etf' + minute_fill?: boolean }) => request('/api/backtest/strategy/run', { method: 'POST', diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 5b23ebd..cc19b6b 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -96,7 +96,7 @@ export const storage = { end: string matching: 'close_t' | 'open_t+1' entryFill: 'close_t' | 'open_t+1' - exitFill: 'close_t' | 'open_t+1' + exitFill: 'close_t' | 'open_t+1' | 'signal_next_minute' fees: string stampTax?: string slippage: string @@ -106,6 +106,7 @@ export const storage = { positionSizing: 'equal' | 'score_weight' mode: 'position' | 'full' holdingDays: string + minuteFill?: boolean params?: Record overrides?: Record result: any diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index ac4e30c..85426b5 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -900,7 +900,7 @@ export function Screener() {
- 可先在右上角切换日期,再点击策略卡片查看选股结果 + 点击策略卡片查看选股结果 若提示 enriched 表无数据,请先运行盘后管道
@@ -981,6 +981,7 @@ export function Screener() { open={showBuilder} onClose={() => setShowBuilder(false)} mode={builderMode} + existingStrategyIds={availableStrategyIds} onSavedId={async id => { const data = await qc.fetchQuery({ queryKey: QK.screenerStrategies('stock'), queryFn: () => api.screenerStrategies('stock'), staleTime: 0 }) if (!data.presets.some(s => s.id === id)) { diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index 12bce56..2e26393 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' -import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle } from 'lucide-react' +import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle, ChevronRight, AlertTriangle } from 'lucide-react' import { api, type StrategyBacktestResult, @@ -89,12 +89,12 @@ const quickRangeTitle = (range: QuickRangeConfig) => range.unit === 'all' 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` -/** 建仓/清仓口径说明 — 黄色问号图标, 点击弹出气泡。 +/** 成交时序说明 — 黄色问号图标, 点击弹出气泡。 * 用 fixed 定位脱离父容器 overflow 裁剪(左侧表单是 overflow-y-auto, absolute 气泡会被裁)。 */ function FillRuleHint() { const [open, setOpen] = useState(false) const [pos, setPos] = useState<{ top: number; left: number } | null>(null) - const iconRef = useRef(null) + const iconRef = useRef(null) const handleOpen = () => { if (!open && iconRef.current) { @@ -108,11 +108,18 @@ function FillRuleHint() { const bubbleLeft = pos ? Math.min(pos.left, window.innerWidth - 256 - 8) : 0 return ( -
- + {open && pos && ( <> @@ -126,11 +133,12 @@ function FillRuleHint() { className="fixed z-50 w-64 bg-surface border border-border rounded-md shadow-xl p-3 text-[11px] text-secondary leading-relaxed" onClick={e => e.stopPropagation()} > -
成交口径说明
+
成交时序说明
-
建仓默认次日开盘(避免未来函数)
-
清仓默认当日收盘(持仓中可盘中/收盘卖)
-
买卖点由策略触发器决定,这里只决定成交价。
+
建仓口径清仓口径分别控制买卖信号出现后的成交时点。
+
信号日收盘仅适用于收盘前可确认的信号;收盘后确认的信号应选择次日开盘
+
信号触发卖出仅在分钟成交开启且卖出信号支持分钟回放时可用;分钟收盘确认后按下一分钟开盘成交。
+
买卖信号由策略触发器决定,这里只控制信号出现后的成交时点。
@@ -158,6 +166,7 @@ Object.assign(FIELD_LABEL, { vol_ratio_5d: '量比', vol_ratio_20d: '20日量比', macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱', boll_upper: '布林上轨', boll_lower: '布林下轨', + ma20_bias: 'MA20乖离率', }) const BOARD_OPTIONS = ['沪主板', '深主板', '创业板', '科创板', '北交所'] const BASIC_FILTER_FIELDS = [ @@ -387,11 +396,12 @@ function TradeLegCell({ trade, side, signalNames }: { trade: StrategyBacktestTra const amount = isBuy ? trade.entry_value : trade.exit_value const signalId = isBuy ? trade.entry_signal_id : trade.exit_signal_id const signalLabel = signalId ? cnSignal(signalId, signalNames) : null + const signalDateLabel = isBuy || trade.exit_reason === 'signal' ? '信号' : '触发' return (
- {date} + 成交 {date} @@ -405,8 +415,8 @@ function TradeLegCell({ trade, side, signalNames }: { trade: StrategyBacktestTra {signalLabel && (
{signalLabel}
)} - {!signalLabel && signalDate && signalDate !== date && ( -
信号 {signalDate}
+ {signalDate && ( +
{signalDateLabel} {signalDate}
)}
) @@ -421,10 +431,96 @@ function fmtDuration(ms: number): string { return `${m}分${rest}秒` } -function SharpeLabel() { +const METRIC_HELP = { + avgReturn: { + title: '平均收益', + description: '所有已执行候选交易收益率的算术平均值。', + note: '容易受极端盈亏影响,建议与中位数一起看。', + }, + medianReturn: { + title: '中位数收益', + description: '将每笔收益排序后位于中间的值。', + note: '比平均收益更不容易被少数极端样本扭曲。', + }, + winRate: { + title: '胜率', + description: '盈利交易数占已完成交易数的比例。', + note: '胜率高不代表总收益一定高,还需结合盈亏比。', + }, + profitFactor: { + title: '盈亏比', + description: '平均盈利幅度 ÷ 平均亏损幅度的绝对值。', + note: '大于 1 表示平均单笔盈利大于平均单笔亏损。', + }, + totalReturn: { + title: '总收益', + description: '回测期末权益相对初始资金的累计收益率。', + note: '已反映回测中的仓位、费用、滑点和成交约束。', + }, + annualReturn: { + title: '年化收益', + description: '将回测期总收益按复利折算为一年的收益率。', + note: '短周期回测的年化结果可能被明显放大。', + }, + benchmarkReturn: { + title: '同期上证', + description: '同一回测区间内上证指数的累计收益率。', + note: '用于判断策略表现是否主要来自市场整体涨跌。', + }, + excessReturn: { + title: '超额收益', + description: '策略总收益率减去同期上证指数收益率。', + note: '正值表示跑赢基准,负值表示跑输基准。', + }, + sharpe: { + title: '夏普比率 (Sharpe Ratio)', + description: '收益序列的平均收益 ÷ 总波动,并按 252 期年化。', + note: '数值越高,单位波动获得的收益越多;小样本时仅供参考。', + }, + sortino: { + title: '索提诺比率 (Sortino Ratio)', + description: '收益序列的平均收益 ÷ 下行偏差,并按 252 期年化。', + note: '只惩罚负收益波动,不将向上波动视为风险。', + }, + maxDrawdown: { + title: '最大回撤', + description: '回测权益从历史高点到随后最低点的最大跌幅。', + note: '越接近 0 通常代表历史资金回撤越小。', + }, + mcDrawdownMedian: { + title: '蒙卡回撤中位数', + description: '对交易收益有放回重抽样,各自计算最大回撤后取中位数。', + note: '表示交易顺序变化时较典型的最大回撤场景。', + }, + mcDrawdown95: { + title: '蒙卡回撤 95% 边界', + description: '交易收益重抽样结果中偏悲观的最大回撤边界。', + note: '约有 95% 的模拟顺序回撤不劣于此值,但不是未来承诺。', + }, + tradeCount: { + title: '交易数', + description: '回测期内已完成建仓和清仓的交易笔数。', + note: '样本越少,胜率和风险指标的稳定性越低。', + }, + avgDuration: { + title: '平均持仓', + description: '所有已完成交易的平均持仓天数。', + note: '全量模式下每个候选独立执行后再汇总。', + }, + finalEquity: { + title: '最终权益', + description: '回测结束时账户现金与持仓市值的合计。', + note: '已反映成交费用、滑点和仓位约束。', + }, +} as const + +type MetricHelpKey = keyof typeof METRIC_HELP + +function MetricLabel({ label, metric }: { label: string; metric: MetricHelpKey }) { const [open, setOpen] = useState(false) const [alignRight, setAlignRight] = useState(false) const ref = useRef(null) + const help = METRIC_HELP[metric] useEffect(() => { if (!open) return const onClick = (e: MouseEvent) => { @@ -442,20 +538,22 @@ function SharpeLabel() { } return ( - 夏普 + {label} {open && ( - 夏普比率 (Sharpe Ratio) - 衡量单位波动风险换来的超额收益。 - 数值越高,收益相对波动越优秀; - 短周期或交易次数少时容易偏高,仅供参考。 + {help.title} + {help.description} + {help.note} )} @@ -747,7 +845,9 @@ export function StrategyBacktest() { // 成交口径: 建仓/清仓可独立配置。向后兼容老 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 [exitFill, setExitFill] = useState<'close_t' | 'open_t+1' | 'signal_next_minute'>( + saved ? (saved.exitFill ?? saved.matching ?? 'close_t') : 'open_t+1', + ) const [fees, setFees] = useState(saved?.fees ?? '2') const [stampTax, setStampTax] = useState(saved?.stampTax ?? '1') const [slippage, setSlippage] = useState(saved?.slippage ?? '5') @@ -757,11 +857,18 @@ export function StrategyBacktest() { 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 [highGranularity, setHighGranularity] = useState(saved?.minuteFill ?? false) const [settingsOpen, setSettingsOpen] = useState(false) - // 分钟K精确回测: 用当日分钟K确定精确成交价 (穿越价/VWAP), 需 Pro+ 分钟K能力 - const [highGranularity, setHighGranularity] = useState(false) + // 分钟K成交价细化: 不改变信号日或成交日, 需 Pro+ 分钟K能力 const { data: caps } = useCapabilities() const hasMinuteBatch = !!caps?.capabilities?.['kline.minute.batch'] + const toggleMinuteFill = () => { + if (!hasMinuteBatch) return + if (highGranularity) { + if (exitFill === 'signal_next_minute') setExitFill('close_t') + } + setHighGranularity(value => !value) + } const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false) const [quickRanges, setQuickRanges] = useState(loadQuickRanges) const [settingsTab, setSettingsTab] = useState('params') @@ -865,6 +972,7 @@ export function StrategyBacktest() { positionSizing, mode: simMode, holdingDays, + minuteFill: highGranularity, params: strategyParams, overrides, result: backtestTask.result, @@ -1051,6 +1159,15 @@ export function StrategyBacktest() { const basicFilter = (overrides.basic_filter ?? {}) as Record const entrySignals = (overrides.entry_signals ?? []) as string[] const exitSignals = (overrides.exit_signals ?? []) as string[] + const effectiveExitSignals = (overrides.exit_signals ?? detail?.exit_signals ?? []) as string[] + const minuteTriggerSignals = detail?.minute_exit_trigger_supported_signals ?? [] + const unsupportedMinuteExitSignals = effectiveExitSignals.filter(signal => !minuteTriggerSignals.includes(signal)) + const minuteExitTriggerSupported = effectiveExitSignals.length > 0 && unsupportedMinuteExitSignals.length === 0 + + useEffect(() => { + if (highGranularity && minuteExitTriggerSupported) return + if (exitFill === 'signal_next_minute') setExitFill('close_t') + }, [exitFill, highGranularity, minuteExitTriggerSupported]) const scoring = useMemo(() => (overrides.scoring ?? {}) as Record, [overrides.scoring]) const scoreMinValue = overrides.score_min == null ? '' : String(overrides.score_min) @@ -1119,6 +1236,20 @@ export function StrategyBacktest() { 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 selectionStats = result?.stats?.selection as Record | undefined + const selectionStages = selectionStats + ? [ + { + key: 'strategy', + label: result?.stats?.execution_backend === 'matrix_native' ? '策略信号' : '策略命中', + value: Number(selectionStats.strategy_matches ?? 0), + }, + ...(selectionStats.entry_trigger_enabled === true + ? [{ key: 'entry', label: '入场候选', value: Number(selectionStats.entry_candidates ?? 0) }] + : []), + { key: 'trades', label: '完成交易', value: Number(result?.stats?.n_trades ?? result?.trades.length ?? 0) }, + ] + : [] const executionStats = (result?.stats?.execution ?? {}) as Record const executionSummary = [ ['buy_no_slot', '满仓未买'], @@ -1129,6 +1260,7 @@ export function StrategyBacktest() { ['sell_limit_down', '跌停阻塞'], ['sell_suspended', '停牌阻塞'], ['pending_exit', '待卖阻塞'], + ['sell_minute_trigger_fallback', '分钟信号顺延'], ] .map(([key, label]) => ({ key, label, value: Number(executionStats[key] ?? 0) })) .filter(item => item.value > 0) @@ -1140,15 +1272,15 @@ export function StrategyBacktest() {
- {/* 分钟K精确回测 */} + {/* 分钟K成交 */}
- 分钟K + 分钟成交 {!hasMinuteBatch && ( Pro+ )} @@ -1171,8 +1303,8 @@ export function StrategyBacktest() {
- 分钟K精确回测 - :信号触发日用当日分钟K确定成交价(均线类信号按穿越价, 其他按 VWAP 均价)。需本地有足够的分钟K历史, 回测速度会变慢。 + 分钟K成交价 + :默认在成交日细化穿越价/VWAP;选择“信号触发卖出”时,会对兼容的卖出信号做分钟回放。需本地有足够的分钟K历史。
)} @@ -1360,22 +1492,46 @@ export function StrategyBacktest() {
-
+
- setEntryFill(e.target.value as 'close_t' | 'open_t+1')} className={INPUT_CLS}> + +
- - setExitFill(e.target.value as 'close_t' | 'open_t+1' | 'signal_next_minute')} + className={INPUT_CLS} + > + + + {highGranularity && minuteExitTriggerSupported && ( + + )}
+ {(entryFill === 'close_t' || exitFill === 'close_t') && ( +
+ + 信号日收盘仅适合收盘前已确认的信号 +
+ )} + {exitFill === 'signal_next_minute' && ( +
+ 分钟收盘确认卖出信号后,按下一分钟开盘成交;尾盘或分钟数据缺失时顺延到下一交易日开盘 +
+ )} + {highGranularity && effectiveExitSignals.length > 0 && !minuteExitTriggerSupported && ( +
+ + 当前卖出信号暂不支持分钟触发回放 +
+ )}
{simMode === 'position' && ( @@ -1596,14 +1752,14 @@ export function StrategyBacktest() { {/* 统计卡片 */}
- - - - - - - - + } value={fmtPct(result.stats.avg_return)} color={statValueColor(result.stats.avg_return)} /> + } value={fmtPct(result.stats.median_return)} color={statValueColor(result.stats.median_return)} /> + } value={fmtPct(result.stats.win_rate)} color={statValueColor(result.stats.win_rate)} /> + } value={result.stats.profit_factor != null ? Number(result.stats.profit_factor).toFixed(2) : '—'} /> + } value={fmtPct(result.stats.excess)} color={statValueColor(result.stats.excess)} /> + } value={result.stats.sharpe != null ? Number(result.stats.sharpe).toFixed(2) : '—'} /> + } value={fmtPct(result.stats.max_drawdown)} color={statValueColor(result.stats.max_drawdown)} /> + } value={fmtPct(result.stats.total_return)} color={statValueColor(result.stats.total_return)} />
@@ -1693,32 +1849,47 @@ export function StrategyBacktest() { {/* 统计卡片 */}
- } value={strategyReturn != null ? fmtPct(strategyReturn) : '—'} color={statValueColor(strategyReturn)} /> - } value={pick('annual_return') != null ? fmtPct(pick('annual_return') as number) : '—'} color={statValueColor(pick('annual_return') as number)} /> - } value={benchmarkReturn != null ? fmtPct(benchmarkReturn) : '—'} color={statValueColor(benchmarkReturn)} /> - } value={excessReturn != null ? fmtPct(excessReturn) : '—'} color={statValueColor(excessReturn)} /> - } value={pick('sharpe') != null ? Number(pick('sharpe')).toFixed(2) : '—'} /> - - } value={pick('sharpe') != null ? Number(pick('sharpe')).toFixed(2) : '—'} /> + } value={pick('sortino') != null ? Number(pick('sortino')).toFixed(2) : '—'} /> + } value={pick('max_drawdown') != null ? fmtPct(pick('max_drawdown') as number) : '—'} color="#34d399" /> - } value={pick('mc_maxdd_p50') != null ? fmtPct(pick('mc_maxdd_p50') as number) : '—'} color="#34d399" /> - } value={pick('mc_maxdd_p95') != null ? fmtPct(pick('mc_maxdd_p95') as number) : '—'} color="#34d399" /> - - + } value={pick('win_rate') != null ? fmtPct(pick('win_rate') as number) : '—'} /> + } value={pick('n_trades') != null ? String(pick('n_trades')) : '—'} /> {result.stats.full_kind === 'candidate_execution' ? ( - + } value={pick('avg_duration') != null ? `${Number(pick('avg_duration')).toFixed(1)}天` : '—'} /> ) : ( - + } value={pick('final_equity') != null ? fmtPrice(pick('final_equity') as number) : '—'} /> )}
+ {selectionStages.length > 0 && ( +
+ 选择漏斗 + {selectionStages.map((stage, index) => ( +
+ {index > 0 && } + {stage.label} {stage.value} +
+ ))} + {Number(selectionStats?.entry_trigger_filtered ?? 0) > 0 && ( + 入场触发器过滤 {Number(selectionStats?.entry_trigger_filtered)} 个 + )} +
+ )} + {executionSummary.length > 0 && (
成交约束: