feat(backtest): 分钟K精确回测 — 穿越价/VWAP 成交 + Pro+ 门控

- engine.py: MatcherConfig 加 minute_fill; _resolve_minute_fill (穿越价/VWAP/降级)
  + _load_minute_for_fills; simulate_portfolio/independent_candidates 接入
- strategy.py: StrategyBacktestConfig 加 minute_fill
- backtest.py: strategy_stream 加 minute_fill 参数 + Pro+ 门控 + 数据范围检查
- repository.py: 新增 get_minute_range (多symbol x 日期范围)
- backtestTask.ts/StrategyBacktest.tsx: 激活 highGranularity 开关 + Pro+ 门控
This commit is contained in:
shy3130
2026-07-12 11:04:43 +08:00
parent fc85d53bee
commit 20ef070099
6 changed files with 268 additions and 26 deletions
+21 -1
View File
@@ -294,8 +294,9 @@ def _make_job_key(
mode: str = "position", holding_days: int = 5,
commission_pct: float | None = None, stamp_tax_pct: float | None = None,
asset_type: str = "stock",
minute_fill: bool = False,
) -> str:
raw = f"{strategy_id}|{symbols}|{start}|{end}|{matching}|{entry_fill}|{exit_fill}|{fees_pct}|{slippage_bps}|{max_positions}|{max_exposure_pct}|{initial_capital}|{position_sizing}|{params}|{overrides}|{mode}|{holding_days}|{commission_pct}|{stamp_tax_pct}|{asset_type}"
raw = f"{strategy_id}|{symbols}|{start}|{end}|{matching}|{entry_fill}|{exit_fill}|{fees_pct}|{slippage_bps}|{max_positions}|{max_exposure_pct}|{initial_capital}|{position_sizing}|{params}|{overrides}|{mode}|{holding_days}|{commission_pct}|{stamp_tax_pct}|{asset_type}|{minute_fill}"
return hashlib.md5(raw.encode()).hexdigest()[:12]
@@ -322,6 +323,7 @@ async def strategy_stream(
mode: str = "position",
holding_days: int = 5,
asset_type: str = "stock",
minute_fill: bool = False,
):
"""SSE 流式策略回测: 实时推送进度, 完成后推送结果, 支持重连 (刷新/切页后恢复)。
@@ -363,6 +365,7 @@ async def strategy_stream(
mode, holding_days,
commission_pct, stamp_tax_pct,
asset_type=asset_type,
minute_fill=minute_fill,
)
_cleanup_stale_jobs()
@@ -383,6 +386,22 @@ async def strategy_stream(
yield f"event: error\ndata: {json.dumps({'message': BACKTEST_SERVER_GUARD_MESSAGE}, ensure_ascii=False)}\n\n"
return
# 分钟K精确回测: Pro+ 门控 + 数据范围检查
if minute_fill:
capset = request.app.state.capabilities
from app.tickflow.capabilities import Cap
if not capset.has(Cap.KLINE_MINUTE_BATCH):
yield f"event: error\ndata: {json.dumps({'message': '分钟K精确回测需要 Pro+ 权限 (kline.minute.batch)'}, ensure_ascii=False)}\n\n"
return
# 检查本地分钟K历史是否覆盖回测区间
repo = request.app.state.repo
earliest_minute = repo.earliest_minute_date() if hasattr(repo, "earliest_minute_date") else None
if earliest_minute is not None and start_date < earliest_minute:
msg = (f"本地分钟K历史最早到 {earliest_minute}, 无法覆盖回测起始日 {start_date}"
f"请先用「扩展分钟K历史」功能拉取更多数据, 或缩小回测区间。")
yield f"event: error\ndata: {json.dumps({'message': msg}, ensure_ascii=False)}\n\n"
return
# 如果是新任务, 启动回测线程
if is_new and not job.done:
cfg = StrategyBacktestConfig(
@@ -406,6 +425,7 @@ async def strategy_stream(
mode=mode,
holding_days=int(holding_days),
asset_type=asset_type,
minute_fill=minute_fill,
)
def _run_backtest():
+194 -4
View File
@@ -54,6 +54,9 @@ class MatcherConfig:
score_max: float | None = None
initial_capital: float = 1_000_000.0
position_sizing: Literal["equal", "score_weight"] = "equal"
# 分钟K精确成交: 开启后, 信号触发日的成交价用当日分钟K优化
# (有参考线→穿越价, 无参考线→VWAP)。数据缺失时降级为日K口径。
minute_fill: bool = False
def __post_init__(self) -> None:
# 解析最终口径: 优先 entry_fill/exit_fill, 否则回退到 matching (向后兼容)。
@@ -508,6 +511,45 @@ class BacktestEngine:
# 撮合价: 建仓/清仓各自独立选列。
entry_prices = open_prices if config.entry_fill == "open_t+1" else close_prices
exit_prices = open_prices if config.exit_fill == "open_t+1" else close_prices
# ── 分钟K精确成交预加载 (同 simulate_portfolio) ──
minute_cache: dict = {}
if config.minute_fill:
_trigger_dates: set[str] = set()
_trigger_symbols: set[str] = set()
for _idx in range(n):
if ent[_idx] or ext[_idx]:
_trigger_dates.add(self._date_str(panel_dates[_idx]))
_trigger_symbols.add(str(panel_symbols[_idx]))
if _trigger_dates and _trigger_symbols:
_loaded = self._load_minute_for_fills(
self.repo, list(_trigger_symbols), _trigger_dates, "stock",
)
for _key, _mdf in _loaded.items():
if not _mdf.is_empty():
minute_cache[_key] = _mdf.to_numpy()
def _refill_price(idx: int, side: str, daily_price: float) -> float:
if not config.minute_fill or not minute_cache:
return daily_price
_sym = str(panel_symbols[idx])
_d = self._date_str(panel_dates[idx])
_marr = minute_cache.get((_sym, _d))
if _marr is None:
return daily_price
_ref = None
for _col in ("ma5", "ma10", "ma20"):
if _col in panel.columns:
try:
_fv = float(panel[_col][idx])
if _fv > 0 and np.isfinite(_fv):
_ref = _fv
break
except (TypeError, ValueError):
pass
_precise = self._resolve_minute_fill(_marr, _ref, side)
return _precise if _precise is not None else daily_price
has_volume = "volume" in panel.columns
volumes = panel["volume"].fill_null(0).to_numpy() if has_volume else np.ones(n, dtype=float)
names = panel["name"].fill_null("").to_numpy() if "name" in panel.columns else np.array([""] * n)
@@ -665,7 +707,10 @@ class BacktestEngine:
_count(block_reason)
return False
exit_price = float(exit_price_override) if exit_price_override is not None else float(exit_prices[idx])
if exit_price_override is not None:
exit_price = float(exit_price_override)
else:
exit_price = _refill_price(idx, "sell", float(exit_prices[idx]))
shares = 100.0
entry_value = shares * float(pos["entry_price"]) * (1 + buy_cost_pct)
exit_value = shares * exit_price * (1 - sell_cost_pct)
@@ -729,7 +774,7 @@ class BacktestEngine:
_count("sell_no_future")
continue
entry_price = float(entry_prices[entry_idx])
entry_price = _refill_price(entry_idx, "buy", float(entry_prices[entry_idx]))
pos = {
"symbol": sym,
"name": str(names[entry_idx] or ""),
@@ -790,6 +835,102 @@ class BacktestEngine:
return self._calc_independent_candidate_result(trades, n_candidates, execution_stats)
# ── 分钟K精确成交 ──────────────────────────────────
@staticmethod
def _resolve_minute_fill(
minute_rows: np.ndarray,
ref_price: float | None,
side: str,
) -> float | None:
"""用当日分钟K确定精确成交价。
Args:
minute_rows: structured numpy array, 字段含 open/high/low/close/volume/amount
ref_price: 信号参考线价格 (如 MA5 值); None 表示无参考线
side: "buy""sell", 决定穿越方向
Returns:
精确成交价, 或 None (降级到日K口径)
"""
if minute_rows is None or len(minute_rows) == 0:
return None
opens = minute_rows["open"].astype(float)
highs = minute_rows["high"].astype(float)
lows = minute_rows["low"].astype(float)
closes = minute_rows["close"].astype(float)
volumes = minute_rows["volume"].astype(float) if "volume" in minute_rows.dtype.names else None
amounts = minute_rows["amount"].astype(float) if "amount" in minute_rows.dtype.names else None
# 有参考线 → 穿越价成交 (逻辑同止损: 找价格穿越参考线的时刻)
if ref_price is not None and ref_price > 0 and np.isfinite(ref_price):
if side == "sell":
# 卖出: 价格跌破参考线 → 开盘已低于则按开盘; 否则按参考线 (低点触及)
if np.isfinite(opens[0]) and opens[0] <= ref_price:
return float(opens[0])
if np.any(np.isfinite(lows) & (lows <= ref_price)):
return float(ref_price)
else:
# 买入: 价格涨破参考线 → 开盘已高于则按开盘; 否则按参考线 (高点触及)
if np.isfinite(opens[0]) and opens[0] >= ref_price:
return float(opens[0])
if np.any(np.isfinite(highs) & (highs >= ref_price)):
return float(ref_price)
# 参考线存在但当日分钟K未穿越 → 用收盘 (信号确认)
return float(closes[-1]) if np.isfinite(closes[-1]) else None
# 无参考线 → VWAP (成交额/成交量), 退化到收盘价
if volumes is not None and amounts is not None:
total_vol = float(np.nansum(volumes))
total_amt = float(np.nansum(amounts))
if total_vol > 0 and total_amt > 0:
return total_amt / total_vol
return float(closes[-1]) if np.isfinite(closes[-1]) else None
@staticmethod
def _load_minute_for_fills(
repo,
symbols: list[str],
dates_needed: set,
asset_type: str,
) -> dict:
"""批量加载回测区间内触发日的分钟K, 返回 {(symbol, date_str): minute_df}。
dates_needed: 需要分钟数据的日期集合 (set of date strings "YYYY-MM-DD")
"""
if not symbols or not dates_needed:
return {}
from datetime import date as _date
sorted_dates = sorted(dates_needed)
start = _date.fromisoformat(sorted_dates[0])
end = _date.fromisoformat(sorted_dates[-1])
try:
df = repo.get_minute_range(symbols, start, end, asset_type=asset_type)
except Exception as e: # noqa: BLE001
logger.warning("minute fill data load failed: %s", e)
return {}
if df.is_empty():
return {}
cache: dict = {}
for row in df.iter_rows(named=True):
dt = row.get("datetime")
if dt is None:
continue
d_str = str(dt)[:10]
sym = row["symbol"]
key = (sym, d_str)
if key not in cache:
cache[key] = []
cache[key].append(row)
# 转 DataFrame per key
result: dict = {}
for key, rows in cache.items():
result[key] = pl.DataFrame(rows)
return result
def simulate_portfolio(
self,
panel: pl.DataFrame,
@@ -891,6 +1032,52 @@ class BacktestEngine:
positions: dict[str, dict] = {}
last_close: dict[str, float] = {}
trades: list[TradeRecord] = []
# ── 分钟K精确成交预加载 ──
# 信号触发日加载分钟K, 成交时用穿越价/VWAP替代收盘价
minute_cache: dict = {} # {(symbol, date_str): structured ndarray}
if config.minute_fill:
trigger_dates: set[str] = set()
trigger_symbols: set[str] = set()
for idx in range(n):
if ent[idx] or ext[idx]:
trigger_dates.add(self._date_str(panel_dates[idx]))
trigger_symbols.add(str(panel_symbols[idx]))
if trigger_dates and trigger_symbols:
asset_type = "etf" if all(
str(s).endswith(".SH") and str(s).startswith("5") for s in list(trigger_symbols)[:5]
) else "stock"
loaded = self._load_minute_for_fills(
self.repo, list(trigger_symbols), trigger_dates, asset_type,
)
for key, mdf in loaded.items():
if not mdf.is_empty():
minute_cache[key] = mdf.to_numpy()
def _refill_price(idx: int, side: str, daily_price: float) -> float:
"""分钟K精确成交价; 无数据则降级为 daily_price。"""
if not config.minute_fill or not minute_cache:
return daily_price
sym = str(panel_symbols[idx])
d_str = self._date_str(panel_dates[idx])
marr = minute_cache.get((sym, d_str))
if marr is None:
return daily_price
# 参考线: 从 panel 取 ma5/ma10/ma20 作为近似 (均线类信号)
ref = None
for col in ("ma5", "ma10", "ma20"):
if col in panel.columns:
val = panel[col][idx]
try:
fv = float(val)
if fv > 0 and np.isfinite(fv):
ref = fv
break
except (TypeError, ValueError):
pass
precise = self._resolve_minute_fill(marr, ref, side)
return precise if precise is not None else daily_price
equity_curve: list[dict] = []
drawdown_curve: list[dict] = []
execution_stats: dict[str, int] = {
@@ -991,7 +1178,10 @@ class BacktestEngine:
) -> None:
nonlocal cash
pos = positions.pop(sym)
exit_price = float(exit_price_override) if exit_price_override is not None else float(exit_prices[idx])
if exit_price_override is not None:
exit_price = float(exit_price_override)
else:
exit_price = _refill_price(idx, "sell", float(exit_prices[idx]))
exit_value = pos["shares"] * exit_price * (1 - sell_cost_pct)
cash += exit_value
pnl_amount = exit_value - pos["entry_value"]
@@ -1192,7 +1382,7 @@ class BacktestEngine:
if allocation <= 0:
_count("buy_exposure")
continue
entry_price = float(entry_prices[idx])
entry_price = _refill_price(idx, "buy", float(entry_prices[idx]))
shares = np.floor(allocation / (entry_price * (1 + buy_cost_pct)) / 100) * 100
entry_value = shares * entry_price * (1 + buy_cost_pct)
if shares <= 0:
+3
View File
@@ -45,6 +45,8 @@ class StrategyBacktestConfig:
mode: Literal["position", "full"] = "position"
asset_type: str = "stock"
holding_days: int = 5
# 分钟K精确成交: 开启后用当日分钟K确定穿越价/VWAP (需 Pro+ 分钟K能力)
minute_fill: bool = False
def __post_init__(self) -> None:
if self.entry_fill is None:
@@ -216,6 +218,7 @@ class StrategyBacktestService:
score_max=score_max,
initial_capital=config.initial_capital,
position_sizing=config.position_sizing,
minute_fill=config.minute_fill,
)
# 撮合 — full 为全候选独立执行;position 为账户级仓位模拟。
if config.mode == "full":
+32
View File
@@ -1275,6 +1275,38 @@ class KlineRepository:
logger.warning("批量分钟K查询失败: %s", e)
return pl.DataFrame()
def get_minute_range(
self,
symbols: list[str],
start: date,
end: date,
asset_type: str = "stock",
) -> pl.DataFrame:
"""多 symbol × 日期范围的分钟K查询 (分钟K精确回测用)。
一次 scan_parquet + predicate pushdown 读多只股票在 [start, end] 内的所有分钟K。
返回列: symbol, datetime, open, high, low, close, volume, amount。
"""
if not symbols:
return pl.DataFrame()
try:
lf = pl.scan_parquet(self._minute_glob_for(asset_type))
available = set(lf.collect_schema().names())
select_cols = [c for c in ["symbol", "datetime", "open", "high", "low", "close", "volume", "amount"] if c in available]
return (
lf.select(select_cols)
.filter(
pl.col("symbol").is_in(symbols)
& (pl.col("datetime").dt.date() >= start)
& (pl.col("datetime").dt.date() <= end)
)
.sort(["symbol", "datetime"])
.collect(streaming=True)
)
except Exception as e: # noqa: BLE001
logger.warning("分钟K范围查询失败: %s", e)
return pl.DataFrame()
# ================================================================
# Polars 查询内部方法
# ================================================================
+2
View File
@@ -180,6 +180,7 @@ export function startBacktest(params: {
mode?: 'position' | 'full'
holding_days?: number
asset_type?: 'stock' | 'etf'
minute_fill?: boolean
}): void {
// 取消之前的任务状态
if (eventSource) {
@@ -212,6 +213,7 @@ export function startBacktest(params: {
mode: params.mode,
holding_days: params.holding_days,
asset_type: params.asset_type,
minute_fill: params.minute_fill,
})
// 存 reconnect 信息 (刷新后用)
@@ -10,7 +10,6 @@ import {
type StrategyParamDef,
} from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import { storage } from '@/lib/storage'
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
import { boardTag } from '@/lib/board'
@@ -728,10 +727,10 @@ export function StrategyBacktest() {
const [simMode, setSimMode] = useState<'position' | 'full'>(saved?.mode ?? 'position')
const [holdingDays, setHoldingDays] = useState(saved?.holdingDays ?? '5')
const [settingsOpen, setSettingsOpen] = useState(false)
// 高颗粒回测(分钟K精确回测)— 开发中,Starter+ 功能
// 分钟K精确回测: 用当日分钟K确定精确成交价 (穿越价/VWAP), 需 Pro+ 分钟K能力
const [highGranularity, setHighGranularity] = useState(false)
const { data: caps } = useCapabilities()
const isFreeTier = tierRank(caps?.label ?? '') < 1
const hasMinuteBatch = !!caps?.capabilities?.['kline.minute.batch']
const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false)
const [quickRanges, setQuickRanges] = useState(loadQuickRanges)
const [settingsTab, setSettingsTab] = useState<AdvancedSettingsTab>('params')
@@ -864,6 +863,7 @@ export function StrategyBacktest() {
overrides,
mode: simMode,
holding_days: Number(holdingDays) || 5,
minute_fill: highGranularity,
})
}
@@ -1093,22 +1093,18 @@ 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 (isFreeTier) return
// 功能开发中,暂不实际启用
setHighGranularity(v => !v)
}}
disabled={isFreeTier}
title={isFreeTier
? '高颗粒回测(分钟K精确回测):需 Starter+ 档位'
: '高颗粒回测(分钟K精确回测):切换后结合每日分钟K更精确回测。⚠️ 开发中,且会显著影响性能、回测很慢。'
onClick={() => { if (!hasMinuteBatch) return; setHighGranularity(v => !v) }}
disabled={!hasMinuteBatch}
title={!hasMinuteBatch
? '分钟K精确回测:需 Pro+ 权限 (分钟K批量)'
: '分钟K精确回测:用当日分钟K确定精确成交价(穿越价/VWAP),比收盘价更真实。⚠️ 回测速度会变慢。'
}
className={`group relative inline-flex h-3.5 w-6 items-center rounded-full shrink-0 transition-colors duration-200 ${
isFreeTier ? 'bg-elevated opacity-50 cursor-not-allowed'
!hasMinuteBatch ? 'bg-elevated opacity-50 cursor-not-allowed'
: highGranularity ? 'bg-amber-500 cursor-pointer'
: 'bg-elevated cursor-pointer'
}`}
@@ -1118,19 +1114,18 @@ export function StrategyBacktest() {
}`} />
</button>
<span className={`text-[9px] font-medium ${highGranularity ? 'text-amber-400' : 'text-muted/50'}`}>K</span>
{isFreeTier && (
<span className="text-[8px] text-accent/70 font-medium bg-accent/10 px-1 py-px rounded">Starter+</span>
{!hasMinuteBatch && (
<span className="text-[8px] text-accent/70 font-medium bg-accent/10 px-1 py-px rounded">Pro+</span>
)}
</div>
</div>
{/* 高颗粒开启时的警告条 */}
{highGranularity && !isFreeTier && (
{/* 分钟K开启时的提示条 */}
{highGranularity && hasMinuteBatch && (
<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"></span>
K进行更精确的回测
<span className="text-amber-400/70"> </span>
<span className="font-medium">K精确回测</span>
K确定成交价线穿, VWAP K历史,
</div>
</div>
)}