feat: improve strategy scoring and backtest execution

This commit is contained in:
shy3130
2026-07-18 16:59:53 +08:00
parent a1e8be58db
commit 44ab51e65d
28 changed files with 1021 additions and 122 deletions
+3 -2
View File
@@ -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": "任务不存在或已完成"}
+3
View File
@@ -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}
+121 -9
View File
@@ -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,
}
+29
View File
@@ -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":
+49
View File
@@ -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
+50 -12
View File
@@ -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)
+2 -2
View File
@@ -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")
# 未来更多渠道在此追加分支
+2 -2
View File
@@ -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 慢/重试不拖累实时行情+告警)。
# 按渠道独立投递: 飞书 / 企业微信谁被勾选且已配置就推谁。
+67 -3
View File
@@ -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 datetimematrix_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."""
+14 -8
View File
@@ -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)
@@ -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 完整、语法正确、括号匹配、引号闭合
@@ -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` 估算总市值。
+37
View File
@@ -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
@@ -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,
@@ -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
@@ -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 = []
@@ -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。"""
@@ -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():
@@ -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
@@ -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)
+13
View File
@@ -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(
+39
View File
@@ -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])
@@ -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<void>; mode?: 'create' | 'modify' }
interface Props {
open: boolean
onClose: () => void
onSavedId?: (id: string) => void | Promise<void>
mode?: 'create' | 'modify'
existingStrategyIds?: ReadonlySet<string>
}
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
</div>
{/* 中间:标题 */}
<span id="strategy-builder-title" className="text-sm font-semibold text-foreground">
{strategyId ? '修改策略' : '创建策略'}
{mode === 'modify' ? '修改策略' : '创建策略'}
</span>
{/* 右侧:步骤 + 关闭 */}
<div className="flex items-center justify-end gap-2">
@@ -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 {
+3 -1
View File
@@ -401,6 +401,7 @@ export interface StrategyDetail {
scoring: Record<string, number>
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<string, any> | null
matching?: 'close_t' | 'open_t+1'
entry_fill?: 'close_t' | 'open_t+1' | null
exit_fill?: 'close_t' | 'open_t+1' | null
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<StrategyBacktestResult>('/api/backtest/strategy/run', {
method: 'POST',
+2 -1
View File
@@ -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<string, any>
overrides?: Record<string, any>
result: any
+2 -1
View File
@@ -900,7 +900,7 @@ export function Screener() {
<ScanSearch className="h-7 w-7 text-accent/40" />
</div>
<div className="flex flex-col items-center gap-1.5">
<span className="text-sm text-secondary"></span>
<span className="text-sm text-secondary"></span>
<span className="text-[11px] text-muted"> enriched </span>
</div>
</div>
@@ -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)) {
+232 -61
View File
@@ -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<HTMLDivElement>(null)
const iconRef = useRef<HTMLButtonElement>(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 (
<div ref={iconRef} className="relative inline-flex items-center">
<HelpCircle
className="h-3.5 w-3.5 text-yellow-500/80 hover:text-yellow-500 cursor-help transition-colors"
<div className="relative inline-flex items-center">
<button
ref={iconRef}
type="button"
onClick={handleOpen}
/>
aria-label="查看成交时序说明"
aria-expanded={open}
title="查看成交时序说明"
className="inline-flex h-3.5 w-3.5 items-center justify-center text-yellow-500/80 transition-colors hover:text-yellow-500"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
<AnimatePresence>
{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()}
>
<div className="font-medium text-foreground mb-1.5"></div>
<div className="font-medium text-foreground mb-1.5"></div>
<div className="space-y-1">
<div><b className="text-foreground"></b><b className="text-foreground"></b>()</div>
<div><b className="text-foreground"></b><b className="text-foreground"></b>(/)</div>
<div><b className="text-foreground"></b>,</div>
<div><b className="text-foreground"></b><b className="text-foreground"></b></div>
<div><b className="text-foreground"></b><b className="text-foreground"></b></div>
<div><b className="text-foreground"></b></div>
<div><b className="text-foreground"></b></div>
</div>
</motion.div>
</>
@@ -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 (
<div className="min-w-[8.25rem] rounded-btn border border-border/60 bg-base/35 px-2 py-1 text-xs leading-4">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-secondary">{date}</span>
<span className="font-mono text-secondary"> {date}</span>
<span className={`rounded px-1.5 py-px text-[10px] font-medium ${
isBuy ? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary'
}`}>
@@ -405,8 +415,8 @@ function TradeLegCell({ trade, side, signalNames }: { trade: StrategyBacktestTra
{signalLabel && (
<div className="mt-0.5 text-[10px] text-accent/80 truncate" title={signalLabel}>{signalLabel}</div>
)}
{!signalLabel && signalDate && signalDate !== date && (
<div className="mt-0.5 text-[10px] text-muted"> {signalDate}</div>
{signalDate && (
<div className="mt-0.5 text-[10px] text-muted">{signalDateLabel} {signalDate}</div>
)}
</div>
)
@@ -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<HTMLSpanElement>(null)
const help = METRIC_HELP[metric]
useEffect(() => {
if (!open) return
const onClick = (e: MouseEvent) => {
@@ -442,20 +538,22 @@ function SharpeLabel() {
}
return (
<span className="relative inline-flex items-center gap-1" ref={ref}>
{label}
<button
type="button"
onClick={toggle}
className="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full border border-border bg-base text-[10px] text-muted transition-colors hover:border-accent/50 hover:text-accent"
aria-label={`查看${label}说明`}
aria-expanded={open}
title={`查看${label}说明`}
className="inline-flex h-3.5 w-3.5 items-center justify-center text-muted transition-colors hover:text-accent"
>
?
<HelpCircle className="h-3.5 w-3.5" />
</button>
{open && (
<span className={`absolute top-full z-50 mt-1.5 w-60 max-w-[calc(100vw-1.5rem)] rounded-lg border border-border bg-elevated px-3 py-2.5 text-[11px] leading-relaxed text-secondary shadow-xl ${alignRight ? 'right-0' : 'left-0'}`}>
<span className="block font-medium text-foreground"> (Sharpe Ratio)</span>
<span className="mt-1 block"><b className="text-foreground"></b></span>
<span className="mt-0.5 block"></span>
<span className="mt-0.5 block text-warning"></span>
<span className="block font-medium text-foreground">{help.title}</span>
<span className="mt-1 block">{help.description}</span>
<span className="mt-0.5 block text-warning">{help.note}</span>
</span>
)}
</span>
@@ -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<AdvancedSettingsTab>('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<string, any>
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<string, number>, [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<string, number | boolean> | 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<string, number>
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() {
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-medium text-secondary"></label>
{/* 分钟K精确回测 */}
{/* 分钟K成交 */}
<div className="flex items-center gap-1">
<Gauge className={`h-3 w-3 ${highGranularity ? 'text-amber-400' : 'text-muted/50'}`} />
<button
onClick={() => { if (!hasMinuteBatch) return; setHighGranularity(v => !v) }}
onClick={toggleMinuteFill}
disabled={!hasMinuteBatch}
title={!hasMinuteBatch
? '分钟K精确回测:需 Pro+ 权限 (分钟K批量)'
: '分钟K精确回测:用当日分钟K确定精确成交价(穿越价/VWAP),比收盘价更真实。⚠️ 回测速度会变慢。'
? '分钟K成交价:需 Pro+ 权限 (分钟K批量)'
: '分钟K成交:细化成交价,并为兼容的卖出信号提供下一分钟成交。'
}
className={`group relative inline-flex h-3.5 w-6 items-center rounded-full shrink-0 transition-colors duration-200 ${
!hasMinuteBatch ? 'bg-elevated opacity-50 cursor-not-allowed'
@@ -1160,7 +1292,7 @@ export function StrategyBacktest() {
highGranularity ? 'translate-x-[13px]' : 'translate-x-0.5'
}`} />
</button>
<span className={`text-[9px] font-medium ${highGranularity ? 'text-amber-400' : 'text-muted/50'}`}>K</span>
<span className={`text-[9px] font-medium ${highGranularity ? 'text-amber-400' : 'text-muted/50'}`}></span>
{!hasMinuteBatch && (
<span className="text-[8px] text-accent/70 font-medium bg-accent/10 px-1 py-px rounded">Pro+</span>
)}
@@ -1171,8 +1303,8 @@ export function StrategyBacktest() {
<div className="mb-2 flex items-start gap-1.5 rounded-btn border border-amber-400/30 bg-amber-400/5 px-2 py-1.5">
<Zap className="h-3 w-3 text-amber-400 shrink-0 mt-px" />
<div className="text-[10px] leading-snug text-amber-400/90">
<span className="font-medium">K精确回测</span>
K确定成交价线穿, VWAP K历史,
<span className="font-medium">K成交价</span>
穿/VWAPK历史
</div>
</div>
)}
@@ -1360,22 +1492,46 @@ export function StrategyBacktest() {
<div className="grid grid-cols-2 gap-2">
<div>
<div className="flex items-center gap-1 mb-1.5">
<div className="mb-1.5 flex items-center gap-1">
<label className="text-xs font-medium text-secondary"></label>
<FillRuleHint />
</div>
<select value={entryFill} onChange={e => setEntryFill(e.target.value as any)} className={INPUT_CLS}>
<option value="open_t+1"></option>
<option value="close_t"></option>
<select value={entryFill} onChange={e => setEntryFill(e.target.value as 'close_t' | 'open_t+1')} className={INPUT_CLS}>
<option value="open_t+1"></option>
<option value="close_t"></option>
</select>
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5"></label>
<select value={exitFill} onChange={e => setExitFill(e.target.value as any)} className={INPUT_CLS}>
<option value="close_t">/</option>
<option value="open_t+1"></option>
<label className="mb-1.5 block text-xs font-medium text-secondary"></label>
<select
value={exitFill}
onChange={e => setExitFill(e.target.value as 'close_t' | 'open_t+1' | 'signal_next_minute')}
className={INPUT_CLS}
>
<option value="close_t"></option>
<option value="open_t+1"></option>
{highGranularity && minuteExitTriggerSupported && (
<option value="signal_next_minute"> BETA</option>
)}
</select>
</div>
{(entryFill === 'close_t' || exitFill === 'close_t') && (
<div className="col-span-2 flex items-start gap-1 text-[10px] leading-4 text-warning">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span></span>
</div>
)}
{exitFill === 'signal_next_minute' && (
<div className="col-span-2 text-[10px] leading-4 text-accent">
</div>
)}
{highGranularity && effectiveExitSignals.length > 0 && !minuteExitTriggerSupported && (
<div className="col-span-2 flex items-start gap-1 text-[10px] leading-4 text-muted">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span></span>
</div>
)}
</div>
{simMode === 'position' && (
@@ -1596,14 +1752,14 @@ export function StrategyBacktest() {
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<Stat label="平均收益" value={fmtPct(result.stats.avg_return)} color={statValueColor(result.stats.avg_return)} />
<Stat label="中位数" value={fmtPct(result.stats.median_return)} color={statValueColor(result.stats.median_return)} />
<Stat label="胜率" value={fmtPct(result.stats.win_rate)} color={statValueColor(result.stats.win_rate)} />
<Stat label="盈亏比" value={result.stats.profit_factor != null ? Number(result.stats.profit_factor).toFixed(2) : '—'} />
<Stat label="超额(vs基准)" value={fmtPct(result.stats.excess)} color={statValueColor(result.stats.excess)} />
<Stat label="夏普" value={result.stats.sharpe != null ? Number(result.stats.sharpe).toFixed(2) : '—'} />
<Stat label="最大回撤" value={fmtPct(result.stats.max_drawdown)} color={statValueColor(result.stats.max_drawdown)} />
<Stat label="累计收益" value={fmtPct(result.stats.total_return)} color={statValueColor(result.stats.total_return)} />
<Stat label={<MetricLabel label="平均收益" metric="avgReturn" />} value={fmtPct(result.stats.avg_return)} color={statValueColor(result.stats.avg_return)} />
<Stat label={<MetricLabel label="中位数" metric="medianReturn" />} value={fmtPct(result.stats.median_return)} color={statValueColor(result.stats.median_return)} />
<Stat label={<MetricLabel label="胜率" metric="winRate" />} value={fmtPct(result.stats.win_rate)} color={statValueColor(result.stats.win_rate)} />
<Stat label={<MetricLabel label="盈亏比" metric="profitFactor" />} value={result.stats.profit_factor != null ? Number(result.stats.profit_factor).toFixed(2) : '—'} />
<Stat label={<MetricLabel label="超额(vs基准)" metric="excessReturn" />} value={fmtPct(result.stats.excess)} color={statValueColor(result.stats.excess)} />
<Stat label={<MetricLabel label="夏普" metric="sharpe" />} value={result.stats.sharpe != null ? Number(result.stats.sharpe).toFixed(2) : '—'} />
<Stat label={<MetricLabel label="最大回撤" metric="maxDrawdown" />} value={fmtPct(result.stats.max_drawdown)} color={statValueColor(result.stats.max_drawdown)} />
<Stat label={<MetricLabel label="累计收益" metric="totalReturn" />} value={fmtPct(result.stats.total_return)} color={statValueColor(result.stats.total_return)} />
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-muted">
@@ -1693,32 +1849,47 @@ export function StrategyBacktest() {
{/* 统计卡片 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
<Stat label="总收益" value={strategyReturn != null ? fmtPct(strategyReturn) : '—'}
<Stat label={<MetricLabel label="总收益" metric="totalReturn" />} value={strategyReturn != null ? fmtPct(strategyReturn) : '—'}
color={statValueColor(strategyReturn)} />
<Stat label="年化" value={pick('annual_return') != null ? fmtPct(pick('annual_return') as number) : '—'}
<Stat label={<MetricLabel label="年化" metric="annualReturn" />} value={pick('annual_return') != null ? fmtPct(pick('annual_return') as number) : '—'}
color={statValueColor(pick('annual_return') as number)} />
<Stat label="同期上证" value={benchmarkReturn != null ? fmtPct(benchmarkReturn) : '—'}
<Stat label={<MetricLabel label="同期上证" metric="benchmarkReturn" />} value={benchmarkReturn != null ? fmtPct(benchmarkReturn) : '—'}
color={statValueColor(benchmarkReturn)} />
<Stat label="超额收益" value={excessReturn != null ? fmtPct(excessReturn) : '—'}
<Stat label={<MetricLabel label="超额收益" metric="excessReturn" />} value={excessReturn != null ? fmtPct(excessReturn) : '—'}
color={statValueColor(excessReturn)} />
<Stat label={<SharpeLabel />} value={pick('sharpe') != null ? Number(pick('sharpe')).toFixed(2) : '—'} />
<Stat label="索提诺" value={pick('sortino') != null ? Number(pick('sortino')).toFixed(2) : '—'} />
<Stat label="最大回撤" value={pick('max_drawdown') != null ? fmtPct(pick('max_drawdown') as number) : '—'}
<Stat label={<MetricLabel label="夏普" metric="sharpe" />} value={pick('sharpe') != null ? Number(pick('sharpe')).toFixed(2) : '—'} />
<Stat label={<MetricLabel label="索提诺" metric="sortino" />} value={pick('sortino') != null ? Number(pick('sortino')).toFixed(2) : '—'} />
<Stat label={<MetricLabel label="最大回撤" metric="maxDrawdown" />} value={pick('max_drawdown') != null ? fmtPct(pick('max_drawdown') as number) : '—'}
color="#34d399" />
<Stat label="蒙卡回撤(中位)" value={pick('mc_maxdd_p50') != null ? fmtPct(pick('mc_maxdd_p50') as number) : '—'}
<Stat label={<MetricLabel label="蒙卡回撤(中位)" metric="mcDrawdownMedian" />} value={pick('mc_maxdd_p50') != null ? fmtPct(pick('mc_maxdd_p50') as number) : '—'}
color="#34d399" />
<Stat label="蒙卡回撤(95%置信不差于此)" value={pick('mc_maxdd_p95') != null ? fmtPct(pick('mc_maxdd_p95') as number) : '—'}
<Stat label={<MetricLabel label="蒙卡回撤(95%边界)" metric="mcDrawdown95" />} value={pick('mc_maxdd_p95') != null ? fmtPct(pick('mc_maxdd_p95') as number) : '—'}
color="#34d399" />
<Stat label="胜率" value={pick('win_rate') != null ? fmtPct(pick('win_rate') as number) : '—'} />
<Stat label="交易数" value={pick('n_trades') != null ? String(pick('n_trades')) : '—'} />
<Stat label={<MetricLabel label="胜率" metric="winRate" />} value={pick('win_rate') != null ? fmtPct(pick('win_rate') as number) : '—'} />
<Stat label={<MetricLabel label="交易数" metric="tradeCount" />} value={pick('n_trades') != null ? String(pick('n_trades')) : '—'} />
{result.stats.full_kind === 'candidate_execution' ? (
<Stat label="平均持仓" value={pick('avg_duration') != null ? `${Number(pick('avg_duration')).toFixed(1)}` : '—'} />
<Stat label={<MetricLabel label="平均持仓" metric="avgDuration" />} value={pick('avg_duration') != null ? `${Number(pick('avg_duration')).toFixed(1)}` : '—'} />
) : (
<Stat label="最终权益" value={pick('final_equity') != null ? fmtPrice(pick('final_equity') as number) : '—'} />
<Stat label={<MetricLabel label="最终权益" metric="finalEquity" />} value={pick('final_equity') != null ? fmtPrice(pick('final_equity') as number) : '—'} />
)}
</div>
</div>
{selectionStages.length > 0 && (
<div className="flex flex-wrap items-center gap-y-2 rounded-card border border-border bg-base/35 px-3 py-2 text-[11px] text-secondary">
<span className="mr-2 font-medium text-foreground"></span>
{selectionStages.map((stage, index) => (
<div key={stage.key} className="flex items-center">
{index > 0 && <ChevronRight className="mx-1.5 h-3 w-3 text-muted/60" />}
<span>{stage.label} <b className="font-mono text-foreground">{stage.value}</b></span>
</div>
))}
{Number(selectionStats?.entry_trigger_filtered ?? 0) > 0 && (
<span className="ml-auto text-amber-400"> {Number(selectionStats?.entry_trigger_filtered)} </span>
)}
</div>
)}
{executionSummary.length > 0 && (
<div className="rounded-card border border-amber-400/25 bg-amber-400/5 px-3 py-2 text-[11px] leading-5 text-secondary">
<span className="font-medium text-amber-300"></span>