mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat: improve strategy scoring and backtest execution
This commit is contained in:
@@ -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": "任务不存在或已完成"}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
# 未来更多渠道在此追加分支
|
||||
|
||||
@@ -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 慢/重试不拖累实时行情+告警)。
|
||||
# 按渠道独立投递: 飞书 / 企业微信谁被勾选且已配置就推谁。
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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` 估算总市值。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
:默认在成交日细化穿越价/VWAP;选择“信号触发卖出”时,会对兼容的卖出信号做分钟回放。需本地有足够的分钟K历史。
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user