mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
Merge pull request #104 from im47cn/feat/wf-optimization
feat(walkforward): 滚动窗口 walk-forward 优化 — IS 优化 + OOS 验证
This commit is contained in:
+200
-1
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from dataclasses import asdict
|
||||
@@ -21,6 +22,8 @@ from app.services.backtest import (
|
||||
is_available,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/backtest", tags=["backtest"])
|
||||
|
||||
FACTOR_DEFAULT_DAYS = 180
|
||||
@@ -536,6 +539,20 @@ async def strategy_cancel(request: Request):
|
||||
# 参数网格优化器 — 复用 _BacktestJob SSE 框架 (多组参数并行回测 + 排序)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _json_safe(obj):
|
||||
"""递归把 nan/inf 置 None —— json.dumps(default=str) 处理不了它们, 会输出非法 JSON
|
||||
字面量 NaN/Infinity 让前端 JSON.parse 崩。优化器/WF 结果嵌套深 (逐组/逐折的
|
||||
sortino 等零波动场景可能算出 nan), 序列化前统一清洗。"""
|
||||
import math
|
||||
if isinstance(obj, float):
|
||||
return obj if math.isfinite(obj) else None
|
||||
if isinstance(obj, dict):
|
||||
return {k: _json_safe(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_json_safe(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
# 透传给每组回测的 StrategyBacktestConfig 字段 (作为 backtest_kwargs)。
|
||||
_OPT_BT_FIELDS = [
|
||||
"matching", "fees_pct", "commission_pct", "stamp_tax_pct", "slippage_bps",
|
||||
@@ -707,7 +724,7 @@ async def optimize_stream(
|
||||
# 取消时优化器把每组记为 cancelled 并正常返回, 需在此分流为取消提示而非"完成"。
|
||||
yield f"event: error\ndata: {json.dumps({'message': '优化已取消'}, ensure_ascii=False)}\n\n"
|
||||
elif job.result is not None:
|
||||
yield f"event: done\ndata: {json.dumps(job.result, ensure_ascii=False, default=str)}\n\n"
|
||||
yield f"event: done\ndata: {json.dumps(_json_safe(job.result), ensure_ascii=False, default=str)}\n\n"
|
||||
return
|
||||
tick += 1
|
||||
if tick % 4 == 0 and await request.is_disconnected():
|
||||
@@ -739,3 +756,185 @@ async def optimize_cancel(request: Request):
|
||||
return {"ok": True}
|
||||
return {"ok": False, "message": "任务不存在或已完成"}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Walk-forward 优化 — 每折训练区间优化 + 测试区间 OOS 验证 (复用优化器 + job_key 回吐)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _make_wf_job_key(strategy_id, symbols, start, end, param_grid, objective, direction, windows, bt_sig, params=None, overrides=None) -> str:
|
||||
raw = f"WF|{strategy_id}|{symbols}|{start}|{end}|{param_grid}|{objective}|{direction}|{windows}|{bt_sig}|{params}|{overrides}"
|
||||
return hashlib.md5(raw.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
@router.get("/walkforward/stream")
|
||||
async def walkforward_stream(
|
||||
request: Request,
|
||||
strategy_id: str,
|
||||
param_grid: str,
|
||||
objective: str = "sortino",
|
||||
direction: str | None = None,
|
||||
train_days: int = 252,
|
||||
test_days: int = 63,
|
||||
step_days: int = 63,
|
||||
max_workers: int = 4,
|
||||
params: str | None = None, # JSON: 未扫描参数固定为用户当前值 (base_params)
|
||||
overrides: str | None = None, # JSON: 策略当前的 basic_filter/signals/风控等覆盖
|
||||
symbols: str | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
matching: str = "open_t+1",
|
||||
fees_pct: float = 0.0002,
|
||||
commission_pct: float | None = None,
|
||||
stamp_tax_pct: float | None = None,
|
||||
slippage_bps: float = 5.0,
|
||||
max_positions: int = 10,
|
||||
max_exposure_pct: float = 1.0,
|
||||
initial_capital: float = 1_000_000.0,
|
||||
position_sizing: str = "equal",
|
||||
mode: str = "position",
|
||||
holding_days: int = 5,
|
||||
):
|
||||
"""SSE 流式 walk-forward: 每折训练区间网格优化 -> 测试区间 OOS 回测。
|
||||
|
||||
事件: job {key} / progress {type:walkforward_progress,done,total,fold} / done {result} / error {message}
|
||||
"""
|
||||
from app.backtest.optimizer import StrategyOptimizer
|
||||
from app.backtest.strategy import StrategyBacktestService
|
||||
from app.backtest.walkforward import WalkForwardConfig, WalkForwardService
|
||||
|
||||
direction = direction or None
|
||||
engine = _get_engine(request)
|
||||
strategy_engine = request.app.state.strategy_engine
|
||||
svc = StrategyBacktestService(engine, strategy_engine)
|
||||
optimizer = StrategyOptimizer(svc, strategy_engine)
|
||||
|
||||
end_date = date.fromisoformat(end) if end else date.today()
|
||||
if start:
|
||||
start_date = date.fromisoformat(start)
|
||||
else:
|
||||
earliest = request.app.state.repo.earliest_daily_date()
|
||||
start_date = earliest or (end_date - timedelta(days=STRATEGY_DEFAULT_DAYS))
|
||||
|
||||
bt_kwargs = _opt_backtest_kwargs(
|
||||
matching, fees_pct, commission_pct, stamp_tax_pct, slippage_bps,
|
||||
max_positions, max_exposure_pct, initial_capital, position_sizing, mode, holding_days,
|
||||
)
|
||||
bt_sig = "|".join(f"{k}={bt_kwargs[k]}" for k in _OPT_BT_FIELDS)
|
||||
windows = f"{train_days}/{test_days}/{step_days}"
|
||||
job_key = _make_wf_job_key(strategy_id, symbols, start, end, param_grid, objective, direction, windows, bt_sig, params, overrides)
|
||||
|
||||
# guard 作用于单折窗口 (每折训练/测试各是一次回测), 而非总区间 —— WF 总区间可长达数年,
|
||||
# 按总区间拦会误杀; 真正的 OOM 风险在单折窗口过大。
|
||||
wf_guard_violated = (
|
||||
settings.backtest_range_guard
|
||||
and max(int(train_days), int(test_days)) > BACKTEST_MAX_SERVER_DAYS
|
||||
)
|
||||
|
||||
_cleanup_stale_jobs()
|
||||
with _jobs_lock:
|
||||
job = _running_jobs.get(job_key)
|
||||
if job is None:
|
||||
job = _BacktestJob(job_key)
|
||||
_running_jobs[job_key] = job
|
||||
is_new = True
|
||||
else:
|
||||
is_new = False
|
||||
|
||||
async def event_generator():
|
||||
yield f"event: job\ndata: {json.dumps({'key': job_key}, ensure_ascii=False)}\n\n"
|
||||
|
||||
if wf_guard_violated:
|
||||
msg = f"单折窗口最多 {BACKTEST_MAX_SERVER_DAYS} 天 (当前 train/test 更大), 请减小训练/测试窗口或在更大内存环境运行。"
|
||||
yield f"event: error\ndata: {json.dumps({'message': msg}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
if is_new and not job.done:
|
||||
try:
|
||||
grid = json.loads(param_grid)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
grid = None
|
||||
if not isinstance(grid, dict) or not grid:
|
||||
job.error = "param_grid 必须是非空的参数网格对象"
|
||||
job.done = True
|
||||
job.finish_ts = time.time()
|
||||
grid = None
|
||||
|
||||
if grid is not None:
|
||||
try:
|
||||
base_params = json.loads(params) if params else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# 静默降级会让"用户配置丢失"变成无声 bug: 至少 warn 供诊断 (前端应传合法 JSON)。
|
||||
logger.warning("walkforward: params JSON 解析失败, 降级为空 params: %r", params)
|
||||
base_params = {}
|
||||
try:
|
||||
ov = json.loads(overrides) if overrides else None
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("walkforward: overrides JSON 解析失败, 降级为 None: %r", overrides)
|
||||
ov = None
|
||||
wf_cfg = WalkForwardConfig(
|
||||
strategy_id=strategy_id,
|
||||
symbols=[s.strip() for s in symbols.split(",") if s.strip()] if symbols else None,
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
param_grid=grid,
|
||||
objective=objective,
|
||||
direction=direction,
|
||||
train_days=int(train_days),
|
||||
test_days=int(test_days),
|
||||
step_days=int(step_days),
|
||||
max_workers=int(max_workers),
|
||||
base_params=base_params if isinstance(base_params, dict) else {},
|
||||
overrides=ov if isinstance(ov, dict) else None,
|
||||
backtest_kwargs=bt_kwargs,
|
||||
)
|
||||
|
||||
def _run_wf():
|
||||
try:
|
||||
wf = WalkForwardService(optimizer, svc, strategy_engine)
|
||||
job.result = wf.run(wf_cfg, lambda d: job.progress.append(d), job.cancel_event)
|
||||
job.done = True
|
||||
job.finish_ts = time.time()
|
||||
except Exception as e:
|
||||
job.error = str(e)
|
||||
job.done = True
|
||||
job.finish_ts = time.time()
|
||||
|
||||
threading.Thread(target=_run_wf, daemon=True).start()
|
||||
|
||||
cursor = 0
|
||||
tick = 0
|
||||
try:
|
||||
while True:
|
||||
if job.done:
|
||||
if job.error:
|
||||
yield f"event: error\ndata: {json.dumps({'message': job.error}, ensure_ascii=False)}\n\n"
|
||||
elif job.cancel_event.is_set():
|
||||
yield f"event: error\ndata: {json.dumps({'message': 'walk-forward 已取消'}, ensure_ascii=False)}\n\n"
|
||||
elif job.result is not None:
|
||||
yield f"event: done\ndata: {json.dumps(_json_safe(job.result), ensure_ascii=False, default=str)}\n\n"
|
||||
return
|
||||
tick += 1
|
||||
if tick % 4 == 0 and await request.is_disconnected():
|
||||
break
|
||||
while cursor < len(job.progress):
|
||||
msg = job.progress[cursor]
|
||||
cursor += 1
|
||||
yield f"event: progress\ndata: {json.dumps(msg, ensure_ascii=False, default=str)}\n\n"
|
||||
await asyncio.sleep(0.5)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/walkforward/cancel")
|
||||
async def walkforward_cancel(request: Request):
|
||||
"""取消 walk-forward 任务 — 传 stream 首事件回吐的 job_key。"""
|
||||
body = await request.json()
|
||||
job_key = body.get("job_key", "")
|
||||
job = _running_jobs.get(job_key)
|
||||
if job and not job.done:
|
||||
job.cancel_event.set()
|
||||
return {"ok": True}
|
||||
return {"ok": False, "message": "任务不存在或已完成"}
|
||||
|
||||
|
||||
@@ -171,8 +171,14 @@ class PanelCache:
|
||||
# 用实例锁守护所有 OrderedDict 变更; compute_fn (重扫盘) 放锁外避免串行化。
|
||||
self._lock = threading.Lock()
|
||||
# single-flight: 同 key 只让一个线程 compute, 其余等其结果复用。
|
||||
# 否则优化器等场景下 max_workers 个线程冷启动同时 miss, 会并行加载 N 份同一面板。
|
||||
# 否则优化器/walk-forward 的 max_workers 个线程冷启动同时 miss, 会并行加载 N 份同一面板。
|
||||
self._inflight: dict[str, _InFlight] = {}
|
||||
# 轻量遥测: 累加真实扫盘耗时与命中/复用次数, 用于量化 IO 占比 (是否值得进一步优化)。
|
||||
# compute_seconds 只计 leader 的实际 compute_fn 耗时, follower 复用不计 —— 反映真实 IO。
|
||||
self._compute_seconds = 0.0
|
||||
self._compute_count = 0 # 实际扫盘次数
|
||||
self._hit_count = 0 # 缓存命中 (未扫盘) 次数
|
||||
self._reuse_count = 0 # single-flight 跟随者复用次数
|
||||
|
||||
def get_or_compute(
|
||||
self,
|
||||
@@ -191,6 +197,7 @@ class PanelCache:
|
||||
if entry is not None:
|
||||
if now - entry.ts < self._ttl:
|
||||
self._cache.move_to_end(key)
|
||||
self._hit_count += 1
|
||||
return entry.df
|
||||
del self._cache[key] # 过期, 丢弃后重算
|
||||
# single-flight: 同 key 若已有线程在算, 登记为跟随者; 否则本线程当 leader。
|
||||
@@ -203,21 +210,28 @@ class PanelCache:
|
||||
if not leader:
|
||||
# 跟随者: 等 leader 算完直接复用, 不重复 compute (消除缓存踩踏)。
|
||||
flight.done.wait()
|
||||
with self._lock:
|
||||
self._reuse_count += 1
|
||||
if flight.error is not None:
|
||||
raise flight.error
|
||||
return flight.df
|
||||
|
||||
# leader: compute 放锁外 (不同 key 仍可并发, 保留原设计优点)。
|
||||
t_compute = time.perf_counter()
|
||||
try:
|
||||
df = compute_fn(symbols, start, end, columns, asset_type)
|
||||
except BaseException as e:
|
||||
# 失败不缓存: 摘除 inflight 让后续线程重试, 并把异常透传给已在等的跟随者。
|
||||
with self._lock:
|
||||
self._compute_seconds += time.perf_counter() - t_compute # 失败也花了 IO, 计入
|
||||
self._compute_count += 1
|
||||
self._inflight.pop(key, None)
|
||||
flight.error = e
|
||||
flight.done.set()
|
||||
raise
|
||||
with self._lock:
|
||||
self._compute_seconds += time.perf_counter() - t_compute
|
||||
self._compute_count += 1
|
||||
self._cache[key] = _CacheEntry(df=df, ts=now)
|
||||
if len(self._cache) > self._max_size:
|
||||
self._cache.popitem(last=False)
|
||||
@@ -226,6 +240,16 @@ class PanelCache:
|
||||
flight.done.set()
|
||||
return df
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""遥测快照: 累计扫盘耗时/次数与命中/复用次数。首尾快照取差即区间内 IO 开销。"""
|
||||
with self._lock:
|
||||
return {
|
||||
"compute_seconds": round(self._compute_seconds, 4),
|
||||
"compute_count": self._compute_count,
|
||||
"hit_count": self._hit_count,
|
||||
"reuse_count": self._reuse_count,
|
||||
}
|
||||
|
||||
def invalidate(self) -> None:
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
@@ -264,6 +288,10 @@ class BacktestEngine:
|
||||
"""加载 enriched 数据面板,带缓存。asset_type='etf' 时读 ETF enriched。"""
|
||||
return self._cache.get_or_compute(symbols, start, end, columns, self._load_panel_inner, asset_type=asset_type)
|
||||
|
||||
def cache_stats(self) -> dict:
|
||||
"""暴露 PanelCache 遥测快照 (扫盘耗时/次数/命中/复用), 供上层量化 IO 占比。"""
|
||||
return self._cache.stats()
|
||||
|
||||
def _load_panel_inner(
|
||||
self,
|
||||
symbols: list[str] | None,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Walk-forward 优化 — 滚动窗口的样本内优化 + 样本外验证。
|
||||
|
||||
每折在训练区间用参数网格优化选出最优参数, 再在紧邻的测试区间用该参数做样本外(OOS)
|
||||
回测。滚动前移。核心产出是 OOS 拼接净值 + 每折 IS-vs-OOS 退化 —— 样本内漂亮、样本外
|
||||
崩溃即过拟合信号, 单次样本内回测看不到。
|
||||
|
||||
依赖 PR2a 的 StrategyOptimizer 做每折训练区间的网格优化。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fold:
|
||||
index: int
|
||||
train_start: date
|
||||
train_end: date
|
||||
test_start: date
|
||||
test_end: date
|
||||
|
||||
|
||||
def generate_folds(
|
||||
start: date,
|
||||
end: date,
|
||||
train_days: int,
|
||||
test_days: int,
|
||||
step_days: int,
|
||||
) -> list[Fold]:
|
||||
"""滚动窗口 fold 切分: 训练窗口固定长度, 测试窗口紧接其后, 按 step 前移。
|
||||
|
||||
测试区间超出 end 即停止。数据区间放不下一折则抛错。
|
||||
"""
|
||||
if train_days <= 0 or test_days <= 0 or step_days <= 0:
|
||||
raise ValueError("train_days / test_days / step_days 必须为正")
|
||||
|
||||
folds: list[Fold] = []
|
||||
i = 0
|
||||
train_start = start
|
||||
while True:
|
||||
train_end = train_start + timedelta(days=train_days)
|
||||
# 测试区间从训练末日的次日开始: 回测区间是闭区间, 若 test_start==train_end 则
|
||||
# 该日 K 线同时进训练优化与 OOS 首日, 构成前视泄漏。后移一天隔断。
|
||||
test_start = train_end + timedelta(days=1)
|
||||
test_end = test_start + timedelta(days=test_days)
|
||||
if test_end > end:
|
||||
break
|
||||
folds.append(Fold(i, train_start, train_end, test_start, test_end))
|
||||
i += 1
|
||||
train_start = train_start + timedelta(days=step_days)
|
||||
|
||||
if not folds:
|
||||
raise ValueError(
|
||||
f"数据区间不足以切出至少一折 (需 train+test={train_days + test_days}天, "
|
||||
f"实有 {(end - start).days}天)"
|
||||
)
|
||||
return folds
|
||||
|
||||
|
||||
def _norm(v: float, direction: str) -> float:
|
||||
"""把目标值归一到"越大越好"空间, 以便跨目标一致地算退化 (min 类目标取负)。"""
|
||||
return -v if direction == "min" else v
|
||||
|
||||
|
||||
def aggregate_oos(fold_records: list[dict], objective: str, direction: str = "max") -> dict:
|
||||
"""从**有效折** (IS 与 OOS 都成功) 聚合: 复利净值 / IS-OOS 退化 / 一致性。
|
||||
|
||||
调用方只传有效折 (best_params 非空且 OOS 未 error), 故此处每折 is_score/oos_objective
|
||||
均有值, 无需 .get 默认兜底 —— 无效折被伪装成 0 收益混入曾是 H1/H2 的根因。
|
||||
|
||||
- compounded_oos_return: 各折 OOS 总收益复利
|
||||
- degradation: 归一空间下 IS 目标均值 - OOS 目标均值, 正值 = 样本外退化 (过拟合信号),
|
||||
对"越小越好"目标 (max_drawdown 等) 方向也正确
|
||||
- consistency: OOS 总收益 > 0 的折占比 (与目标方向无关, 直观)
|
||||
"""
|
||||
n = len(fold_records)
|
||||
if n == 0:
|
||||
return {
|
||||
"n_folds": 0,
|
||||
"compounded_oos_return": 0.0,
|
||||
"avg_is_objective": None,
|
||||
"avg_oos_objective": None,
|
||||
"degradation": None,
|
||||
"consistency": 0.0,
|
||||
"oos_equity_curve": [],
|
||||
}
|
||||
|
||||
equity = 1.0
|
||||
curve: list[dict] = []
|
||||
n_positive = 0
|
||||
for f in fold_records:
|
||||
r = float(f["oos_stats"].get("total_return", 0.0) or 0.0)
|
||||
equity *= (1 + r)
|
||||
if r > 0:
|
||||
n_positive += 1
|
||||
curve.append({"fold": f["index"], "date": str(f["test_end"]), "value": round(equity, 4)})
|
||||
|
||||
is_vals = [f["is_score"] for f in fold_records if f["is_score"] is not None]
|
||||
oos_vals = [f["oos_objective"] for f in fold_records if f["oos_objective"] is not None]
|
||||
avg_is = round(float(sum(is_vals) / len(is_vals)), 4) if is_vals else None
|
||||
avg_oos = round(float(sum(oos_vals) / len(oos_vals)), 4) if oos_vals else None
|
||||
degradation = (
|
||||
round(_norm(avg_is, direction) - _norm(avg_oos, direction), 4)
|
||||
if (avg_is is not None and avg_oos is not None) else None
|
||||
)
|
||||
|
||||
return {
|
||||
"n_folds": n,
|
||||
"compounded_oos_return": round(equity - 1.0, 4),
|
||||
"avg_is_objective": avg_is,
|
||||
"avg_oos_objective": avg_oos,
|
||||
"degradation": degradation,
|
||||
"consistency": round(n_positive / n, 4),
|
||||
"oos_equity_curve": curve,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalkForwardConfig:
|
||||
strategy_id: str
|
||||
symbols: list[str] | None
|
||||
start: date
|
||||
end: date
|
||||
param_grid: dict
|
||||
objective: str = "sortino"
|
||||
train_days: int = 252
|
||||
test_days: int = 63
|
||||
step_days: int = 63
|
||||
direction: str | None = None
|
||||
max_workers: int = 4
|
||||
base_params: dict = field(default_factory=dict)
|
||||
overrides: dict | None = None
|
||||
backtest_kwargs: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class WalkForwardService:
|
||||
"""滚动窗口 walk-forward: 每折训练区间优化 -> 测试区间 OOS 验证 -> 聚合。"""
|
||||
|
||||
def __init__(self, optimizer, service, strategy_engine) -> None:
|
||||
self.optimizer = optimizer
|
||||
self.service = service
|
||||
self.strategy_engine = strategy_engine
|
||||
|
||||
def run(
|
||||
self,
|
||||
cfg: WalkForwardConfig,
|
||||
progress_cb=None,
|
||||
cancel_event=None,
|
||||
) -> dict:
|
||||
from app.backtest.optimizer import OptimizeConfig, default_direction
|
||||
from app.backtest.strategy import StrategyBacktestConfig
|
||||
|
||||
t0 = time.perf_counter()
|
||||
direction = cfg.direction or default_direction(cfg.objective)
|
||||
folds = generate_folds(cfg.start, cfg.end, cfg.train_days, cfg.test_days, cfg.step_days)
|
||||
n_total = len(folds)
|
||||
|
||||
# 遥测: 首尾快照 PanelCache, 量化跨折重叠区间重复扫盘的 IO 占比 (是否值得进一步优化)。
|
||||
cache_before = self.service.engine.cache_stats()
|
||||
|
||||
valid_records: list[dict] = [] # IS 与 OOS 都成功, 计入聚合
|
||||
skipped: list[dict] = [] # 无优化结果 或 OOS 失败, 不计入聚合 (避免伪装成有效折)
|
||||
done = 0
|
||||
|
||||
# IS 训练区间强制 position 模式: full 模式会让训练折未平仓持仓用 train_end 之后
|
||||
# (即 OOS 区间) 的真实 K 线平仓, IS 分数被未来数据污染 -> 优化选参乐观偏移, 使过拟合
|
||||
# 被掩盖。OOS 回测保留用户所选 mode。参数扫描优化只看正式区间内的表现即可。
|
||||
is_backtest_kwargs = {**cfg.backtest_kwargs, "mode": "position"}
|
||||
|
||||
for f in folds:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
break
|
||||
|
||||
# 训练区间: 网格优化选最优参数
|
||||
opt_cfg = OptimizeConfig(
|
||||
strategy_id=cfg.strategy_id,
|
||||
symbols=cfg.symbols,
|
||||
start=f.train_start,
|
||||
end=f.train_end,
|
||||
param_grid=cfg.param_grid,
|
||||
objective=cfg.objective,
|
||||
direction=cfg.direction,
|
||||
max_workers=cfg.max_workers,
|
||||
base_params=cfg.base_params,
|
||||
overrides=cfg.overrides,
|
||||
backtest_kwargs=is_backtest_kwargs, # IS 强制 position, 堵前视泄漏
|
||||
)
|
||||
opt_res = self.optimizer.optimize(opt_cfg, cancel_event=cancel_event)
|
||||
best_params = opt_res.get("best_params")
|
||||
is_score = opt_res.get("best_score")
|
||||
done += 1
|
||||
|
||||
base = {
|
||||
"index": f.index,
|
||||
"train_start": str(f.train_start),
|
||||
"train_end": str(f.train_end),
|
||||
"test_start": str(f.test_start),
|
||||
"test_end": str(f.test_end),
|
||||
}
|
||||
|
||||
# 训练区间没优化出参数 (全组失败/取消) -> 跳过, 不用默认参数硬跑 OOS 伪装成有效折
|
||||
if best_params is None:
|
||||
skipped.append({**base, "reason": "训练区间未优化出参数"})
|
||||
if progress_cb is not None:
|
||||
progress_cb({"type": "walkforward_progress", "done": done, "total": n_total, "fold": f.index})
|
||||
continue
|
||||
|
||||
# 测试区间: 用最优参数做样本外回测
|
||||
merged = {**cfg.base_params, **best_params}
|
||||
oos_cfg = StrategyBacktestConfig(
|
||||
strategy_id=cfg.strategy_id,
|
||||
symbols=cfg.symbols,
|
||||
start=f.test_start,
|
||||
end=f.test_end,
|
||||
params=merged,
|
||||
overrides=cfg.overrides,
|
||||
**cfg.backtest_kwargs,
|
||||
)
|
||||
oos_res = self.service.run(oos_cfg, cancel_event=cancel_event)
|
||||
|
||||
# OOS 失败 (含 cancelled) -> 跳过, 不把空/0 收益混入复利曲线
|
||||
if oos_res.error:
|
||||
skipped.append({**base, "best_params": best_params, "reason": f"OOS 回测失败: {oos_res.error}"})
|
||||
if progress_cb is not None:
|
||||
progress_cb({"type": "walkforward_progress", "done": done, "total": n_total, "fold": f.index})
|
||||
continue
|
||||
|
||||
oos_objective = oos_res.stats.get(cfg.objective)
|
||||
# 该折 OOS 是否较 IS 退化 (方向感知: min 类目标数值更大才是退化)
|
||||
oos_degraded = (
|
||||
_norm(oos_objective, direction) < _norm(is_score, direction)
|
||||
if (oos_objective is not None and is_score is not None) else None
|
||||
)
|
||||
valid_records.append({
|
||||
**base,
|
||||
"best_params": best_params,
|
||||
"is_score": is_score,
|
||||
"oos_objective": oos_objective,
|
||||
"oos_degraded": oos_degraded,
|
||||
"oos_stats": oos_res.stats,
|
||||
})
|
||||
|
||||
if progress_cb is not None:
|
||||
progress_cb({"type": "walkforward_progress", "done": done, "total": n_total, "fold": f.index})
|
||||
|
||||
summary = aggregate_oos(valid_records, cfg.objective, direction)
|
||||
|
||||
# 遥测收尾: 本次 WF 累计扫盘耗时 / 命中 / 复用, 与总耗时对比得出 load_panel 占比。
|
||||
cache_after = self.service.engine.cache_stats()
|
||||
elapsed_ms = round((time.perf_counter() - t0) * 1000, 1)
|
||||
io_seconds = round(cache_after["compute_seconds"] - cache_before["compute_seconds"], 4)
|
||||
io_pct = round(io_seconds * 1000 / elapsed_ms * 100, 1) if elapsed_ms > 0 else 0.0
|
||||
cache_telemetry = {
|
||||
"load_panel_seconds": io_seconds,
|
||||
"load_panel_pct": io_pct, # 扫盘耗时 / WF 总耗时
|
||||
"scans": cache_after["compute_count"] - cache_before["compute_count"],
|
||||
"hits": cache_after["hit_count"] - cache_before["hit_count"],
|
||||
"single_flight_reuses": cache_after["reuse_count"] - cache_before["reuse_count"],
|
||||
}
|
||||
logger.info(
|
||||
"walk-forward IO 占比: load_panel %.3fs (%.1f%% of %.1fms) | 扫盘 %d 次 命中 %d 复用 %d",
|
||||
io_seconds, io_pct, elapsed_ms,
|
||||
cache_telemetry["scans"], cache_telemetry["hits"], cache_telemetry["single_flight_reuses"],
|
||||
)
|
||||
|
||||
return {
|
||||
"objective": cfg.objective,
|
||||
"direction": direction,
|
||||
"n_folds": len(valid_records),
|
||||
"n_skipped": len(skipped),
|
||||
"n_planned_folds": n_total,
|
||||
"folds": valid_records,
|
||||
"skipped": skipped,
|
||||
"summary": summary,
|
||||
"cache_telemetry": cache_telemetry,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Walk-forward 核心测试 — 滚动窗口 fold 生成 + OOS 聚合 + 编排。
|
||||
|
||||
被测:
|
||||
- generate_folds: 滚动训练/测试窗口切分
|
||||
- aggregate_oos: 从各折 OOS 结果聚合 (复利净值/IS-OOS 退化/一致性)
|
||||
- WalkForwardService.run: 每折 训练区间优化 -> 测试区间 OOS 验证
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from app.backtest.walkforward import (
|
||||
WalkForwardConfig,
|
||||
WalkForwardService,
|
||||
aggregate_oos,
|
||||
generate_folds,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# fold 生成
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def test_folds_rolling_windows():
|
||||
# 1 年数据, 训练 90d / 测试 30d / 步进 30d
|
||||
folds = generate_folds(date(2024, 1, 1), date(2024, 12, 31), train_days=90, test_days=30, step_days=30)
|
||||
assert len(folds) > 0
|
||||
f0 = folds[0]
|
||||
assert f0.train_start == date(2024, 1, 1)
|
||||
assert f0.train_end == date(2024, 3, 31) # +90d (2024 闰年)
|
||||
assert f0.test_start == date(2024, 4, 1) # train_end + 1天 (隔断前视泄漏)
|
||||
assert f0.test_end == date(2024, 5, 1) # +30d
|
||||
# 滚动: 下一折训练起点 +step
|
||||
assert folds[1].train_start == date(2024, 1, 31) # +30d
|
||||
|
||||
|
||||
def test_folds_test_starts_day_after_train_end():
|
||||
"""无前视泄漏: 每折 test_start 严格晚于 train_end (不共享同一天)。"""
|
||||
folds = generate_folds(date(2024, 1, 1), date(2024, 12, 31), train_days=90, test_days=30, step_days=30)
|
||||
for f in folds:
|
||||
assert f.test_start > f.train_end
|
||||
|
||||
|
||||
def test_folds_no_test_beyond_end():
|
||||
folds = generate_folds(date(2024, 1, 1), date(2024, 12, 31), train_days=90, test_days=30, step_days=30)
|
||||
for f in folds:
|
||||
assert f.test_end <= date(2024, 12, 31)
|
||||
|
||||
|
||||
def test_folds_insufficient_span_raises():
|
||||
# 训练90+测试30=120d, 但只有 100d 数据 -> 0 折
|
||||
with pytest.raises(ValueError, match=r"数据区间不足|至少"):
|
||||
generate_folds(date(2024, 1, 1), date(2024, 4, 10), train_days=90, test_days=30, step_days=30)
|
||||
|
||||
|
||||
def test_folds_reject_nonpositive_windows():
|
||||
with pytest.raises(ValueError, match=r"必须为正"):
|
||||
generate_folds(date(2024, 1, 1), date(2024, 12, 31), train_days=0, test_days=30, step_days=30)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# OOS 聚合
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _rec(index, is_score, total_return, obj):
|
||||
return {
|
||||
"index": index,
|
||||
"test_end": date(2024, 1, 1),
|
||||
"best_params": {"p": index},
|
||||
"is_score": is_score,
|
||||
"oos_objective": obj,
|
||||
"oos_stats": {"total_return": total_return, "sortino": obj},
|
||||
}
|
||||
|
||||
|
||||
def test_aggregate_compounds_oos_returns():
|
||||
recs = [_rec(0, 2.0, 0.10, 1.5), _rec(1, 2.0, -0.05, 0.8), _rec(2, 2.0, 0.08, 1.2)]
|
||||
agg = aggregate_oos(recs, objective="sortino")
|
||||
# 复利: 1.1 * 0.95 * 1.08 - 1
|
||||
assert abs(agg["compounded_oos_return"] - (1.10 * 0.95 * 1.08 - 1)) < 1e-9
|
||||
assert len(agg["oos_equity_curve"]) == 3
|
||||
|
||||
|
||||
def test_aggregate_is_oos_degradation():
|
||||
# IS 目标平均远高于 OOS -> 退化为正 (过拟合信号)
|
||||
recs = [_rec(0, 3.0, 0.05, 0.5), _rec(1, 3.0, 0.02, 0.3)]
|
||||
agg = aggregate_oos(recs, objective="sortino")
|
||||
assert agg["avg_is_objective"] == 3.0
|
||||
assert abs(agg["avg_oos_objective"] - 0.4) < 1e-9
|
||||
assert agg["degradation"] > 0 # IS 3.0 - OOS 0.4 = 2.6
|
||||
|
||||
|
||||
def test_aggregate_consistency_fraction_positive():
|
||||
# consistency 按 OOS 总收益 > 0 的折占比: total_return 0.1>0, -0.1<=0, 0.1>0 -> 2/3
|
||||
recs = [_rec(0, 1, 0.1, 1.5), _rec(1, 1, -0.1, -0.2), _rec(2, 1, 0.1, 0.8)]
|
||||
agg = aggregate_oos(recs, objective="sortino")
|
||||
assert agg["consistency"] == round(2 / 3, 4) # 0.6667
|
||||
|
||||
|
||||
def test_aggregate_degradation_direction_aware_for_min_objective():
|
||||
"""min 类目标 (avg_holding_days, 越小越好): OOS 持仓天数更大 = 退化, degradation>0。"""
|
||||
# IS 持仓 3 天, OOS 持仓 5 天 (更长=更差) -> 退化
|
||||
recs = [{"index": 0, "test_end": date(2024, 1, 1), "is_score": 3.0,
|
||||
"oos_objective": 5.0, "oos_stats": {"total_return": 0.05}}]
|
||||
agg = aggregate_oos(recs, objective="avg_holding_days", direction="min")
|
||||
# 归一空间: norm(3)=-3, norm(5)=-5 -> degradation = -3 - (-5) = 2 > 0 = 退化
|
||||
assert agg["degradation"] == round(2.0, 4)
|
||||
|
||||
|
||||
def test_aggregate_empty_folds():
|
||||
agg = aggregate_oos([], objective="sortino")
|
||||
assert agg["n_folds"] == 0
|
||||
assert agg["compounded_oos_return"] == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 编排 (假 optimizer / service)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class _FakeResult:
|
||||
stats: dict
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class _FakeOptimizer:
|
||||
"""optimize 返回受控 best_params/best_score, 记录被优化的训练区间。"""
|
||||
def __init__(self):
|
||||
self.train_ranges = []
|
||||
self.opt_kwargs = [] # 记录每折 IS 优化收到的 backtest_kwargs (验证 mode 强制)
|
||||
|
||||
def optimize(self, cfg, progress_cb=None, cancel_event=None):
|
||||
self.train_ranges.append((cfg.start, cfg.end))
|
||||
self.opt_kwargs.append(dict(cfg.backtest_kwargs))
|
||||
# best_params 随训练起点变化, best_score 固定
|
||||
return {"best_params": {"p": cfg.start.month}, "best_score": 2.0, "results": [], "n_completed": 1}
|
||||
|
||||
|
||||
# 从真实 PanelCache 取字段模板 —— 字段被重命名时本桩自动跟随, 避免 test 绿而生产 KeyError。
|
||||
from app.backtest.engine import PanelCache
|
||||
|
||||
_ZERO_CACHE_STATS = {k: type(v)() for k, v in PanelCache().stats().items()}
|
||||
|
||||
|
||||
class _FakeEngine:
|
||||
"""最小引擎桩: 仅提供 WF 遥测所需的 cache_stats (字段同源自 PanelCache.stats)。"""
|
||||
def cache_stats(self):
|
||||
return dict(_ZERO_CACHE_STATS)
|
||||
|
||||
|
||||
class _FakeService:
|
||||
"""run 返回受控 OOS stats, 记录测试区间 + 收到的 params。"""
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.engine = _FakeEngine()
|
||||
|
||||
def run(self, config, progress_cb=None, cancel_event=None):
|
||||
self.calls.append({"start": config.start, "end": config.end,
|
||||
"params": dict(config.params or {}), "mode": config.mode})
|
||||
return _FakeResult(stats={"total_return": 0.05, "sortino": 1.0})
|
||||
|
||||
|
||||
def _wf_cfg(**kw):
|
||||
base = dict(
|
||||
strategy_id="s", symbols=None, start=date(2024, 1, 1), end=date(2024, 12, 31),
|
||||
param_grid={"p": [1, 2]}, objective="sortino",
|
||||
train_days=90, test_days=30, step_days=30,
|
||||
)
|
||||
base.update(kw)
|
||||
return WalkForwardConfig(**base)
|
||||
|
||||
|
||||
def test_walkforward_optimizes_train_applies_oos():
|
||||
opt, svc = _FakeOptimizer(), _FakeService()
|
||||
wf = WalkForwardService(opt, svc, strategy_engine=None)
|
||||
out = wf.run(_wf_cfg())
|
||||
|
||||
assert out["n_folds"] > 0
|
||||
# 每折: optimizer 在训练区间跑, service 在测试区间用最优参数跑
|
||||
assert len(opt.train_ranges) == out["n_folds"]
|
||||
assert len(svc.calls) == out["n_folds"]
|
||||
# OOS 回测用的是该折优化出的 best_params (来自训练起点月份)
|
||||
first_fold = out["folds"][0]
|
||||
assert svc.calls[0]["params"] == first_fold["best_params"]
|
||||
# 训练区间与测试区间不重叠 (测试在训练之后)
|
||||
assert svc.calls[0]["start"] >= opt.train_ranges[0][1]
|
||||
|
||||
|
||||
class _CountingEngine:
|
||||
"""首尾两次 cache_stats 返回不同值, 用于验证 WF 遥测差值/顺序计算 (非全零掩盖)。"""
|
||||
def __init__(self):
|
||||
self._n = 0
|
||||
|
||||
def cache_stats(self):
|
||||
self._n += 1
|
||||
if self._n == 1: # run 开头快照 (before)
|
||||
return {"compute_seconds": 1.0, "compute_count": 2, "hit_count": 0, "reuse_count": 0}
|
||||
return {"compute_seconds": 3.5, "compute_count": 7, "hit_count": 4, "reuse_count": 3} # after
|
||||
|
||||
|
||||
def test_walkforward_cache_telemetry_computes_deltas():
|
||||
"""cache_telemetry 用首尾快照差值: scans/hits/reuses/秒数 = after - before, 且方向正确。"""
|
||||
opt, svc = _FakeOptimizer(), _FakeService()
|
||||
svc.engine = _CountingEngine()
|
||||
wf = WalkForwardService(opt, svc, strategy_engine=None)
|
||||
out = wf.run(_wf_cfg())
|
||||
|
||||
tel = out["cache_telemetry"]
|
||||
assert tel["scans"] == 5 # 7 - 2, 顺序写反会得 -5
|
||||
assert tel["hits"] == 4 # 4 - 0
|
||||
assert tel["single_flight_reuses"] == 3 # 3 - 0
|
||||
assert abs(tel["load_panel_seconds"] - 2.5) < 1e-9 # 3.5 - 1.0
|
||||
assert tel["load_panel_pct"] >= 0.0 # 扫盘耗时 / WF总耗时, 非负
|
||||
|
||||
|
||||
def test_walkforward_forces_position_mode_for_is_optimization():
|
||||
"""训练折(IS)强制 position 防前视泄漏(full 会用 OOS 区间 K 线平仓污染 IS);
|
||||
OOS 回测保留用户所选 mode。"""
|
||||
opt, svc = _FakeOptimizer(), _FakeService()
|
||||
wf = WalkForwardService(opt, svc, strategy_engine=None)
|
||||
wf.run(_wf_cfg(backtest_kwargs={"mode": "full"}))
|
||||
|
||||
assert len(opt.opt_kwargs) > 0 and len(svc.calls) > 0
|
||||
# 用户选了 full, 但每折 IS 优化都被强制 position
|
||||
assert all(kw["mode"] == "position" for kw in opt.opt_kwargs), "IS 优化未强制 position"
|
||||
# OOS 回测保留用户的 full
|
||||
assert all(c["mode"] == "full" for c in svc.calls), "OOS 未保留用户 mode"
|
||||
|
||||
|
||||
def test_walkforward_reports_degradation():
|
||||
opt, svc = _FakeOptimizer(), _FakeService()
|
||||
wf = WalkForwardService(opt, svc, strategy_engine=None)
|
||||
out = wf.run(_wf_cfg())
|
||||
# IS best_score=2.0, OOS sortino=1.0 -> 退化 1.0
|
||||
assert out["summary"]["avg_is_objective"] == 2.0
|
||||
assert out["summary"]["avg_oos_objective"] == 1.0
|
||||
assert abs(out["summary"]["degradation"] - 1.0) < 1e-9
|
||||
|
||||
|
||||
class _NoParamsOptimizer(_FakeOptimizer):
|
||||
"""模拟训练区间全组失败: best_params=None。"""
|
||||
def optimize(self, cfg, progress_cb=None, cancel_event=None):
|
||||
self.train_ranges.append((cfg.start, cfg.end))
|
||||
return {"best_params": None, "best_score": None, "results": [], "n_completed": 0}
|
||||
|
||||
|
||||
class _ErrorService(_FakeService):
|
||||
"""模拟 OOS 回测失败。"""
|
||||
def run(self, config, progress_cb=None, cancel_event=None):
|
||||
self.calls.append({"start": config.start, "end": config.end, "params": dict(config.params or {})})
|
||||
return _FakeResult(stats={}, error="no data")
|
||||
|
||||
|
||||
def test_walkforward_skips_folds_without_optimized_params():
|
||||
"""训练区间没优化出参数 (best_params=None) -> 跳过, 不用默认参数硬跑 OOS 伪装成有效折。"""
|
||||
opt, svc = _NoParamsOptimizer(), _FakeService()
|
||||
wf = WalkForwardService(opt, svc, strategy_engine=None)
|
||||
out = wf.run(_wf_cfg())
|
||||
assert out["n_folds"] == 0 # 无有效折
|
||||
assert out["n_skipped"] > 0 # 全部跳过
|
||||
assert svc.calls == [] # 不跑 OOS
|
||||
assert out["summary"]["compounded_oos_return"] == 0.0 # 无效折不污染净值
|
||||
|
||||
|
||||
def test_walkforward_skips_oos_error_folds():
|
||||
"""OOS 回测失败的折 -> 跳过, 不把空/0 收益混入复利曲线。"""
|
||||
opt, svc = _FakeOptimizer(), _ErrorService()
|
||||
wf = WalkForwardService(opt, svc, strategy_engine=None)
|
||||
out = wf.run(_wf_cfg())
|
||||
assert out["n_folds"] == 0
|
||||
assert out["n_skipped"] > 0
|
||||
assert len(svc.calls) > 0 # OOS 跑了但失败
|
||||
assert out["summary"]["compounded_oos_return"] == 0.0 # 失败折不计入
|
||||
|
||||
|
||||
def test_walkforward_cancel_stops():
|
||||
import threading
|
||||
ev = threading.Event()
|
||||
ev.set()
|
||||
opt, svc = _FakeOptimizer(), _FakeService()
|
||||
wf = WalkForwardService(opt, svc, strategy_engine=None)
|
||||
out = wf.run(_wf_cfg(), cancel_event=ev)
|
||||
# 取消 -> 不跑任何折
|
||||
assert svc.calls == []
|
||||
assert out["n_folds"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# API: job_key 回吐 + cancel 按 key 查表
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def test_wf_job_key_distinguishes_windows():
|
||||
from app.api.backtest import _make_wf_job_key
|
||||
base = _make_wf_job_key("s", None, None, None, '{"p":[1]}', "sortino", None, "252/63/63", "sig")
|
||||
assert base != _make_wf_job_key("s", None, None, None, '{"p":[1]}', "sortino", None, "120/30/30", "sig")
|
||||
|
||||
|
||||
def test_wf_job_key_distinguishes_params_and_overrides():
|
||||
"""params/overrides 不同必须产出不同 job_key —— 否则 stream 与 cancel 会错配到别的任务。"""
|
||||
from app.api.backtest import _make_wf_job_key
|
||||
base = _make_wf_job_key("s", None, None, None, '{"p":[1]}', "sortino", None, "252/63/63", "sig")
|
||||
# params 不同 (未扫描参数固定值不同 -> 优化的策略不同)
|
||||
assert base != _make_wf_job_key(
|
||||
"s", None, None, None, '{"p":[1]}', "sortino", None, "252/63/63", "sig", params='{"x":1}')
|
||||
# overrides 不同 (basic_filter/信号/风控 不同)
|
||||
assert base != _make_wf_job_key(
|
||||
"s", None, None, None, '{"p":[1]}', "sortino", None, "252/63/63", "sig", overrides='{"score_min":5}')
|
||||
# 相同 params/overrides 必须稳定一致 (stream 端与 cancel 端对齐前提)
|
||||
k = _make_wf_job_key("s", None, None, None, '{"p":[1]}', "sortino", None, "252/63/63", "sig", params='{"x":1}')
|
||||
assert k == _make_wf_job_key(
|
||||
"s", None, None, None, '{"p":[1]}', "sortino", None, "252/63/63", "sig", params='{"x":1}')
|
||||
|
||||
|
||||
def test_wf_cancel_by_echoed_key():
|
||||
import asyncio
|
||||
|
||||
from app.api.backtest import _BacktestJob, _running_jobs, walkforward_cancel
|
||||
|
||||
class _Req:
|
||||
def __init__(self, body):
|
||||
self._body = body
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
key = "wfkey_test_1"
|
||||
_running_jobs[key] = _BacktestJob(key)
|
||||
try:
|
||||
res = asyncio.run(walkforward_cancel(_Req({"job_key": key})))
|
||||
assert res["ok"] is True
|
||||
assert _running_jobs[key].cancel_event.is_set()
|
||||
res2 = asyncio.run(walkforward_cancel(_Req({"job_key": "nope"})))
|
||||
assert res2["ok"] is False
|
||||
finally:
|
||||
_running_jobs.pop(key, None)
|
||||
@@ -150,6 +150,50 @@ def test_panel_cache_single_flight_error_propagates_and_retries():
|
||||
assert got is df
|
||||
|
||||
|
||||
def test_panel_cache_stats_counts_scans_hits_reuses():
|
||||
"""遥测计数: 首次 miss 计扫盘, 二次同 key 计命中, 并发同 key 跟随者计复用。"""
|
||||
import threading
|
||||
|
||||
cache = PanelCache()
|
||||
df = pl.DataFrame({"symbol": ["510300"]})
|
||||
args = (["510300"], date(2026, 1, 1), date(2026, 1, 2), None)
|
||||
|
||||
# 1) 首次: 冷 miss -> 扫盘 1 次
|
||||
cache.get_or_compute(*args, lambda *a: df, "stock")
|
||||
s = cache.stats()
|
||||
assert s["compute_count"] == 1 and s["hit_count"] == 0
|
||||
|
||||
# 2) 二次同 key: 命中缓存, 不扫盘
|
||||
cache.get_or_compute(*args, lambda *a: df, "stock")
|
||||
s = cache.stats()
|
||||
assert s["compute_count"] == 1 and s["hit_count"] == 1
|
||||
|
||||
# 3) 新 key 并发踩踏: 1 次扫盘 + N-1 次 single-flight 复用
|
||||
barrier = threading.Barrier(5)
|
||||
args2 = (["600000"], date(2026, 2, 1), date(2026, 2, 2), None)
|
||||
|
||||
def slow(*a):
|
||||
time.sleep(0.04)
|
||||
return df
|
||||
|
||||
def worker():
|
||||
barrier.wait()
|
||||
cache.get_or_compute(*args2, slow, "stock")
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
s = cache.stats()
|
||||
# 核心不变量: 5 线程并发同 key 只扫盘 1 次 (args1 首次 + args2 一次 = 2)。
|
||||
assert s["compute_count"] == 2, "并发同 key 应只扫盘 1 次"
|
||||
# 其余 4 线程要么 single-flight 复用, 要么(慢调度下 leader 已写缓存)命中 ——
|
||||
# 二者之和恒为 4。不锁定 reuse/hit 具体分配, 避免时序 flaky。
|
||||
assert s["reuse_count"] + (s["hit_count"] - 1) == 4, "4 个非 leader 线程应复用或命中"
|
||||
|
||||
|
||||
def test_job_key_includes_asset_type_and_is_consistent():
|
||||
"""stream 与 cancel 必须用同一 job_key: asset_type 进 key 且相同入参产出相同 key。"""
|
||||
from app.api.backtest import _make_job_key
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
/** Walk-forward 任务管理 (SSE + job_key 回吐 + 重连)。镜像 optimizerTask。 */
|
||||
|
||||
export interface WFProgress {
|
||||
type: string
|
||||
done: number
|
||||
total: number
|
||||
fold: number
|
||||
}
|
||||
|
||||
export interface WFFold {
|
||||
index: number
|
||||
train_start: string
|
||||
train_end: string
|
||||
test_start: string
|
||||
test_end: string
|
||||
best_params: Record<string, any> | null
|
||||
is_score: number | null
|
||||
oos_objective: number | null
|
||||
oos_degraded: boolean | null
|
||||
oos_stats: Record<string, any>
|
||||
}
|
||||
|
||||
export interface WFSummary {
|
||||
n_folds: number
|
||||
compounded_oos_return: number
|
||||
avg_is_objective: number | null
|
||||
avg_oos_objective: number | null
|
||||
degradation: number | null
|
||||
consistency: number
|
||||
oos_equity_curve: { fold: number; date: string; value: number }[]
|
||||
}
|
||||
|
||||
export interface WalkForwardResult {
|
||||
objective: string
|
||||
direction: string
|
||||
n_folds: number
|
||||
n_skipped: number
|
||||
n_planned_folds: number
|
||||
folds: WFFold[]
|
||||
skipped: { index: number; test_start: string; test_end: string; reason: string }[]
|
||||
summary: WFSummary
|
||||
elapsed_ms: number
|
||||
}
|
||||
|
||||
export interface WalkForwardTask {
|
||||
id: number
|
||||
isPending: boolean
|
||||
result: WalkForwardResult | null
|
||||
progress: WFProgress | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface StartWalkForwardParams {
|
||||
strategy_id: string
|
||||
param_grid: Record<string, any>
|
||||
objective: string
|
||||
train_days: number
|
||||
test_days: number
|
||||
step_days: number
|
||||
params?: Record<string, any> | null // 未扫描参数固定为用户当前值
|
||||
overrides?: Record<string, any> | null // 策略当前的 basic_filter/信号/风控覆盖
|
||||
symbols?: string[] | null
|
||||
start?: string | null
|
||||
end?: string | null
|
||||
mode?: 'position' | 'full'
|
||||
}
|
||||
|
||||
let current: WalkForwardTask | null = null
|
||||
const listeners = new Set<() => void>()
|
||||
let taskSeq = 0
|
||||
let eventSource: EventSource | null = null
|
||||
let currentJobKey: string | null = null
|
||||
let cancelRequested = false
|
||||
let reconnectAttempts = 0
|
||||
const MAX_RECONNECT = 5
|
||||
|
||||
const RECONNECT_KEY = 'walkforward_reconnect'
|
||||
const JOB_KEY_KEY = 'walkforward_job_key'
|
||||
|
||||
function emit() {
|
||||
listeners.forEach(fn => fn())
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void) {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
|
||||
const sp = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== '') sp.set(k, String(v))
|
||||
}
|
||||
return sp.toString()
|
||||
}
|
||||
|
||||
function connectSSE(url: string): void {
|
||||
const id = current?.id ?? ++taskSeq
|
||||
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
|
||||
const es = new EventSource(url)
|
||||
eventSource = es
|
||||
|
||||
es.addEventListener('job', (e: MessageEvent) => {
|
||||
reconnectAttempts = 0
|
||||
try {
|
||||
const key = JSON.parse(e.data)?.key
|
||||
if (key) {
|
||||
currentJobKey = key
|
||||
localStorage.setItem(JOB_KEY_KEY, key)
|
||||
// 竞态: stop 在拿到 key 前被点过 -> 补发 cancel 真正停后端任务, 再收尾关闭。
|
||||
if (cancelRequested) {
|
||||
postCancel(key)
|
||||
es.close()
|
||||
eventSource = null
|
||||
currentJobKey = null
|
||||
localStorage.removeItem(RECONNECT_KEY)
|
||||
localStorage.removeItem(JOB_KEY_KEY)
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
es.addEventListener('progress', (e: MessageEvent) => {
|
||||
if (current?.id !== id) return
|
||||
reconnectAttempts = 0
|
||||
try {
|
||||
const prog = JSON.parse(e.data) as WFProgress
|
||||
current = { ...current, progress: prog }
|
||||
emit()
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
es.addEventListener('done', (e: MessageEvent) => {
|
||||
if (current?.id !== id) return
|
||||
try {
|
||||
const result = JSON.parse(e.data) as WalkForwardResult
|
||||
current = { ...current, isPending: false, result, error: null }
|
||||
emit()
|
||||
} catch {
|
||||
current = { ...current, isPending: false, error: '结果解析失败' }
|
||||
emit()
|
||||
}
|
||||
es.close()
|
||||
eventSource = null
|
||||
currentJobKey = null
|
||||
localStorage.removeItem(RECONNECT_KEY)
|
||||
localStorage.removeItem(JOB_KEY_KEY)
|
||||
})
|
||||
|
||||
es.addEventListener('error', (e: MessageEvent) => {
|
||||
if (current?.id !== id) return
|
||||
if (e.data) {
|
||||
try {
|
||||
const msg = JSON.parse(e.data)?.message ?? 'walk-forward 出错'
|
||||
current = { ...current, isPending: false, error: msg }
|
||||
emit()
|
||||
} catch {
|
||||
current = { ...current, isPending: false, error: 'walk-forward 出错' }
|
||||
emit()
|
||||
}
|
||||
es.close()
|
||||
eventSource = null
|
||||
currentJobKey = null
|
||||
localStorage.removeItem(RECONNECT_KEY)
|
||||
localStorage.removeItem(JOB_KEY_KEY)
|
||||
return
|
||||
}
|
||||
// 无 data: 连接异常断开。EventSource 自动重连, 设上限避免网络长断时无限 pending。
|
||||
if (current?.id === id) {
|
||||
reconnectAttempts += 1
|
||||
if (reconnectAttempts > MAX_RECONNECT) {
|
||||
es.close()
|
||||
eventSource = null
|
||||
// 清 localStorage: 否则刷新页面 tryReconnect 会重连到这个已放弃的任务。
|
||||
localStorage.removeItem(RECONNECT_KEY)
|
||||
localStorage.removeItem(JOB_KEY_KEY)
|
||||
current = { ...current, isPending: false, error: '连接中断, 重连多次失败' }
|
||||
emit()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 调后端 cancel (按回吐的 job_key)。 */
|
||||
function postCancel(jobKey: string): void {
|
||||
fetch('/api/backtest/walkforward/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ job_key: jobKey }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
export function startWalkForward(params: StartWalkForwardParams): void {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
|
||||
cancelRequested = false
|
||||
currentJobKey = null
|
||||
reconnectAttempts = 0
|
||||
const id = ++taskSeq
|
||||
current = { id, isPending: true, result: null, progress: null, error: null }
|
||||
emit()
|
||||
|
||||
const qs = buildQuery({
|
||||
strategy_id: params.strategy_id,
|
||||
param_grid: JSON.stringify(params.param_grid),
|
||||
objective: params.objective,
|
||||
train_days: params.train_days,
|
||||
test_days: params.test_days,
|
||||
step_days: params.step_days,
|
||||
params: params.params ? JSON.stringify(params.params) : undefined,
|
||||
overrides: params.overrides ? JSON.stringify(params.overrides) : undefined,
|
||||
symbols: params.symbols?.join(','),
|
||||
start: params.start ?? undefined,
|
||||
end: params.end ?? undefined,
|
||||
mode: params.mode,
|
||||
})
|
||||
|
||||
localStorage.setItem(RECONNECT_KEY, qs)
|
||||
connectSSE(`/api/backtest/walkforward/stream?${qs}`)
|
||||
}
|
||||
|
||||
export function stopWalkForward(): void {
|
||||
// 竞态: job_key 未到手时保持 SSE 打开, 等 job 事件补发 cancel (关 SSE 不停后端 daemon 线程)。
|
||||
cancelRequested = true
|
||||
const jobKey = currentJobKey ?? localStorage.getItem(JOB_KEY_KEY)
|
||||
if (jobKey) {
|
||||
postCancel(jobKey)
|
||||
if (eventSource) { eventSource.close(); eventSource = null }
|
||||
currentJobKey = null
|
||||
localStorage.removeItem(RECONNECT_KEY)
|
||||
localStorage.removeItem(JOB_KEY_KEY)
|
||||
} else if (eventSource) {
|
||||
const es = eventSource
|
||||
// job_key 始终没到手(job 事件未达): 5 秒后放弃并清 localStorage, 避免刷新重连到未取消任务。
|
||||
// (若期间 job 到达, job handler 已 postCancel+清storage 并置 eventSource=null, 下面条件不成立跳过)
|
||||
setTimeout(() => {
|
||||
if (es === eventSource) {
|
||||
es.close(); eventSource = null
|
||||
localStorage.removeItem(RECONNECT_KEY)
|
||||
localStorage.removeItem(JOB_KEY_KEY)
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
if (current?.isPending) {
|
||||
current = { ...current, isPending: false, error: '已取消' }
|
||||
emit()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWalkForward(): void {
|
||||
current = null
|
||||
emit()
|
||||
}
|
||||
|
||||
export function tryReconnectWalkForward(): boolean {
|
||||
const qs = localStorage.getItem(RECONNECT_KEY)
|
||||
if (!qs) return false
|
||||
const id = ++taskSeq
|
||||
current = { id, isPending: true, result: null, progress: null, error: null }
|
||||
emit()
|
||||
connectSSE(`/api/backtest/walkforward/stream?${qs}`)
|
||||
return true
|
||||
}
|
||||
|
||||
export function useWalkForwardTask(): WalkForwardTask | null {
|
||||
return useSyncExternalStore(subscribe, () => current, () => null)
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import { PageHeader } from '@/components/PageHeader'
|
||||
import { FactorBacktest } from './backtest/FactorBacktest'
|
||||
import { StrategyBacktest } from './backtest/StrategyBacktest'
|
||||
import { StrategyOptimizer } from './backtest/StrategyOptimizer'
|
||||
import { BarChart3, FlaskConical, SlidersHorizontal } from 'lucide-react'
|
||||
import { StrategyWalkForward } from './backtest/StrategyWalkForward'
|
||||
import { BarChart3, FlaskConical, SlidersHorizontal, Waypoints } from 'lucide-react'
|
||||
|
||||
type Tab = 'factor' | 'strategy' | 'optimizer'
|
||||
type Tab = 'factor' | 'strategy' | 'optimizer' | 'walkforward'
|
||||
|
||||
const MODES: Record<Tab, { title: string; subtitle: string; hint: string }> = {
|
||||
factor: {
|
||||
@@ -23,12 +24,18 @@ const MODES: Record<Tab, { title: string; subtitle: string; hint: string }> = {
|
||||
subtitle: '网格搜索最优参数组合',
|
||||
hint: '并行回测所有参数组合,按夏普/索提诺等目标排序,找到最优参数。',
|
||||
},
|
||||
walkforward: {
|
||||
title: 'Walk-forward',
|
||||
subtitle: '滚动窗口样本外验证',
|
||||
hint: '每折训练区间优化、测试区间验证,看样本外是否退化以识别过拟合。',
|
||||
},
|
||||
}
|
||||
|
||||
const TAB_ICONS: Record<Tab, typeof BarChart3> = {
|
||||
factor: BarChart3,
|
||||
strategy: FlaskConical,
|
||||
optimizer: SlidersHorizontal,
|
||||
walkforward: Waypoints,
|
||||
}
|
||||
|
||||
export function Backtest() {
|
||||
@@ -36,7 +43,7 @@ export function Backtest() {
|
||||
|
||||
const modeSwitch = (
|
||||
<div className="inline-flex rounded-btn border border-border bg-surface/80 p-0.5 shadow-sm">
|
||||
{(['factor', 'strategy', 'optimizer'] as const).map(tab => {
|
||||
{(['factor', 'strategy', 'optimizer', 'walkforward'] as const).map(tab => {
|
||||
const Icon = TAB_ICONS[tab]
|
||||
const active = activeTab === tab
|
||||
return (
|
||||
@@ -77,6 +84,7 @@ export function Backtest() {
|
||||
{activeTab === 'factor' && <FactorBacktest />}
|
||||
{activeTab === 'strategy' && <StrategyBacktest />}
|
||||
{activeTab === 'optimizer' && <StrategyOptimizer />}
|
||||
{activeTab === 'walkforward' && <StrategyWalkForward />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Play, Square, Trophy } from 'lucide-react'
|
||||
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
|
||||
import { api, type StrategyDetail } from '@/lib/api'
|
||||
import { fmtPct } from '@/lib/format'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
@@ -13,65 +13,15 @@ import {
|
||||
useOptimizerTask,
|
||||
} from '@/lib/optimizerTask'
|
||||
import { buildDefaultOverrides } from '@/lib/strategyOverrides'
|
||||
|
||||
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'
|
||||
|
||||
// 可选优化目标 (对齐后端 VALID_OBJECTIVES) + 中文标签 + 是否越小越好
|
||||
const OBJECTIVES: { id: string; label: string; min?: boolean }[] = [
|
||||
{ id: 'sortino', label: '索提诺比率' },
|
||||
{ id: 'sharpe', label: '夏普比率' },
|
||||
{ id: 'calmar', label: 'Calmar 比率' },
|
||||
{ id: 'total_return', label: '总收益' },
|
||||
{ id: 'annual_return', label: '年化收益' },
|
||||
{ id: 'win_rate', label: '胜率' },
|
||||
{ id: 'profit_factor', label: '盈亏比' },
|
||||
{ id: 'max_drawdown', label: '最大回撤(越小越好)' },
|
||||
{ id: 'mc_maxdd_p95', label: '蒙卡回撤P95(越小越好)' },
|
||||
{ id: 'avg_holding_days', label: '平均持仓天数', min: true },
|
||||
]
|
||||
|
||||
// 单个可扫参数的网格配置
|
||||
interface Sweep {
|
||||
enabled: boolean
|
||||
min: string
|
||||
max: string
|
||||
step: string
|
||||
}
|
||||
|
||||
function defaultSweep(p: StrategyParamDef): Sweep {
|
||||
return {
|
||||
enabled: false,
|
||||
min: String(p.min ?? p.default ?? 0),
|
||||
max: String(p.max ?? p.default ?? 1),
|
||||
step: String(p.step ?? (p.type === 'int' ? 1 : 0.01)),
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 sweep 配置估算某参数候选值个数 (与后端整数计数一致) */
|
||||
function candidateCount(p: StrategyParamDef, s: Sweep): number {
|
||||
if (p.type === 'bool') return 2
|
||||
if (p.type === 'select') return p.options?.length ?? 1
|
||||
const lo = Number(s.min), hi = Number(s.max), step = Number(s.step)
|
||||
if (!(step > 0) || hi < lo) return 0
|
||||
return Math.round((hi - lo) / step) + 1
|
||||
}
|
||||
|
||||
/** 校验某数值参数的 sweep 是否会被后端拒绝 (与后端 _candidates_for 同口径)。
|
||||
* 后端按 lo+i*step 生成 (i=0..round((hi-lo)/step)), 任一值超出 [min,max] 即报错。 */
|
||||
function sweepError(p: StrategyParamDef, s: Sweep): string | null {
|
||||
if (p.type === 'bool' || p.type === 'select') return null
|
||||
const lo = Number(s.min), hi = Number(s.max), step = Number(s.step)
|
||||
if (Number.isNaN(lo) || Number.isNaN(hi) || Number.isNaN(step)) return `${p.label}: 范围/步长非法`
|
||||
if (!(step > 0)) return `${p.label}: 步长必须为正`
|
||||
if (hi < lo) return `${p.label}: max < min`
|
||||
if (p.min != null && lo < p.min - 1e-9) return `${p.label}: min 小于允许下限 ${p.min}`
|
||||
if (p.max != null && hi > p.max + 1e-9) return `${p.label}: max 超出允许上限 ${p.max}`
|
||||
// 后端生成的末值 lo + round((hi-lo)/step)*step 若 > max, 会被拒
|
||||
const nSteps = Math.round((hi - lo) / step)
|
||||
const last = lo + nSteps * step
|
||||
if (last > hi + 1e-9) return `${p.label}: 步长 ${step} 不整除区间, 末值 ${last.toFixed(4)} 超出 max ${hi}`
|
||||
return null
|
||||
}
|
||||
import {
|
||||
INPUT_CLS,
|
||||
OBJECTIVES,
|
||||
GRID_MAX_COMBINATIONS,
|
||||
useParamSweep,
|
||||
StrategySelect,
|
||||
SweepParamList,
|
||||
CombosHint,
|
||||
} from './components/paramSweep'
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10)
|
||||
const ONE_YEAR_AGO = new Date(Date.now() - 365 * 864e5).toISOString().slice(0, 10)
|
||||
@@ -81,15 +31,15 @@ export function StrategyOptimizer() {
|
||||
const { data: stratData } = useQuery({ queryKey: ['strategies'], queryFn: api.strategyList })
|
||||
const strategies: StrategyDetail[] = stratData?.strategies ?? []
|
||||
|
||||
const [strategyId, setStrategyId] = useState<string>('')
|
||||
// 切策略: 有任务在跑时先真正取消 (关 SSE + 后端 cancel + 清 localStorage), 不能静默丢
|
||||
const sweep = useParamSweep(strategies, () => {
|
||||
if (task?.isPending) stopOptimize()
|
||||
else clearOptimize()
|
||||
})
|
||||
const [objective, setObjective] = useState('sortino')
|
||||
const [start, setStart] = useState(ONE_YEAR_AGO)
|
||||
const [end, setEnd] = useState(TODAY)
|
||||
const [mode, setMode] = useState<'position' | 'full'>('position')
|
||||
const [sweeps, setSweeps] = useState<Record<string, Sweep>>({})
|
||||
|
||||
const selected = strategies.find(s => s.id === strategyId)
|
||||
const params = selected?.params ?? []
|
||||
|
||||
// 刷新/切页后: 恢复未完成的优化任务
|
||||
useEffect(() => {
|
||||
@@ -97,62 +47,20 @@ export function StrategyOptimizer() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 切策略: 若有任务在跑, 先真正取消 (关 SSE + 后端 cancel + 清 localStorage), 不能静默丢。
|
||||
const onSelectStrategy = (id: string) => {
|
||||
if (task?.isPending) stopOptimize()
|
||||
else clearOptimize()
|
||||
setStrategyId(id)
|
||||
const s = strategies.find(x => x.id === id)
|
||||
const init: Record<string, Sweep> = {}
|
||||
for (const p of s?.params ?? []) init[p.id] = defaultSweep(p)
|
||||
setSweeps(init)
|
||||
}
|
||||
|
||||
const updateSweep = (pid: string, patch: Partial<Sweep>) =>
|
||||
setSweeps(prev => ({ ...prev, [pid]: { ...prev[pid], ...patch } }))
|
||||
|
||||
// 组合数预估
|
||||
const combos = useMemo(() => {
|
||||
const enabled = params.filter(p => sweeps[p.id]?.enabled)
|
||||
if (!enabled.length) return 0
|
||||
return enabled.reduce((acc, p) => acc * candidateCount(p, sweeps[p.id]), 1)
|
||||
}, [params, sweeps])
|
||||
|
||||
// 网格合法性 (与后端展开同口径): 步长不整除/越界会被后端拒, 前端提前拦。
|
||||
const gridError = useMemo(() => {
|
||||
for (const p of params) {
|
||||
if (!sweeps[p.id]?.enabled) continue
|
||||
const err = sweepError(p, sweeps[p.id])
|
||||
if (err) return err
|
||||
}
|
||||
return null
|
||||
}, [params, sweeps])
|
||||
|
||||
const buildGrid = (): Record<string, any> => {
|
||||
const grid: Record<string, any> = {}
|
||||
for (const p of params) {
|
||||
const s = sweeps[p.id]
|
||||
if (!s?.enabled) continue
|
||||
if (p.type === 'bool') grid[p.id] = [true, false]
|
||||
else if (p.type === 'select') grid[p.id] = p.options ?? []
|
||||
else grid[p.id] = { min: Number(s.min), max: Number(s.max), step: Number(s.step) }
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
const canRun = strategyId && combos > 0 && combos <= 2000 && !gridError && !task?.isPending
|
||||
const canRun = sweep.strategyId && sweep.combos > 0 && sweep.combos <= GRID_MAX_COMBINATIONS
|
||||
&& !sweep.gridError && !task?.isPending
|
||||
|
||||
const onRun = () => {
|
||||
if (!canRun) return
|
||||
clearOptimize()
|
||||
startOptimize({
|
||||
strategy_id: strategyId,
|
||||
param_grid: buildGrid(),
|
||||
strategy_id: sweep.strategyId,
|
||||
param_grid: sweep.buildGrid(),
|
||||
objective,
|
||||
// 未扫描参数固定为策略当前默认值; overrides 让 basic_filter/信号/风控按当前策略参与,
|
||||
// 保证优化的就是用户实际回测的策略 (而非被剥离配置的裸策略)。
|
||||
params: selected?.params_defaults,
|
||||
overrides: selected ? buildDefaultOverrides(selected) : undefined,
|
||||
params: sweep.selected?.params_defaults,
|
||||
overrides: sweep.selected ? buildDefaultOverrides(sweep.selected) : undefined,
|
||||
start,
|
||||
end,
|
||||
mode,
|
||||
@@ -168,10 +76,7 @@ export function StrategyOptimizer() {
|
||||
<div className="space-y-3 rounded-card border border-border bg-surface p-4 overflow-y-auto min-h-0">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-secondary">策略</label>
|
||||
<select value={strategyId} onChange={e => onSelectStrategy(e.target.value)} className={INPUT_CLS}>
|
||||
<option value="">选择策略…</option>
|
||||
{strategies.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
<StrategySelect strategies={strategies} value={sweep.strategyId} onChange={sweep.selectStrategy} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -200,50 +105,8 @@ export function StrategyOptimizer() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 可扫参数 */}
|
||||
{params.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs font-medium text-secondary">扫描参数 (勾选后设范围)</div>
|
||||
<div className="space-y-2">
|
||||
{params.map(p => {
|
||||
const s = sweeps[p.id] ?? defaultSweep(p)
|
||||
const numeric = p.type === 'float' || p.type === 'int'
|
||||
return (
|
||||
<div key={p.id} className="rounded-input border border-border/60 p-2">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={s.enabled} onChange={e => updateSweep(p.id, { enabled: e.target.checked })} />
|
||||
<span className="font-medium text-foreground">{p.label}</span>
|
||||
<span className="text-secondary">({p.type})</span>
|
||||
</label>
|
||||
{s.enabled && numeric && (
|
||||
<div className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
<input type="number" value={s.min} onChange={e => updateSweep(p.id, { min: e.target.value })} placeholder="min" className={INPUT_CLS} />
|
||||
<input type="number" value={s.max} onChange={e => updateSweep(p.id, { max: e.target.value })} placeholder="max" className={INPUT_CLS} />
|
||||
<input type="number" value={s.step} onChange={e => updateSweep(p.id, { step: e.target.value })} placeholder="step" className={INPUT_CLS} />
|
||||
</div>
|
||||
)}
|
||||
{s.enabled && !numeric && (
|
||||
<div className="mt-1 text-[11px] text-secondary">
|
||||
{p.type === 'bool' ? '扫描 [是 / 否]' : `扫描全部选项 (${p.options?.length ?? 0})`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 组合数 / 校验提示 */}
|
||||
{strategyId && (
|
||||
<div className={`text-xs ${(combos > 2000 || gridError) ? 'text-red-400' : 'text-secondary'}`}>
|
||||
{gridError
|
||||
? gridError
|
||||
: combos === 0
|
||||
? '请至少勾选一个参数'
|
||||
: `共 ${combos} 组参数组合${combos > 2000 ? ' — 超过上限 2000, 请增大 step 或缩小范围' : ''}`}
|
||||
</div>
|
||||
)}
|
||||
<SweepParamList params={sweep.params} sweeps={sweep.sweeps} updateSweep={sweep.updateSweep} />
|
||||
<CombosHint show={!!sweep.strategyId} combos={sweep.combos} gridError={sweep.gridError} />
|
||||
|
||||
{task?.isPending ? (
|
||||
<button onClick={stopOptimize} className="inline-flex w-full items-center justify-center gap-1.5 rounded-btn bg-red-500/90 px-3 py-2 text-xs font-medium text-white hover:bg-red-500">
|
||||
@@ -280,7 +143,6 @@ export function StrategyOptimizer() {
|
||||
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
{/* 最优参数 */}
|
||||
{result.best_params && (
|
||||
<div className="rounded-card border border-accent/30 bg-accent/5 p-3">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-accent">
|
||||
@@ -298,7 +160,6 @@ export function StrategyOptimizer() {
|
||||
{result.n_completed}/{result.n_combinations} 组完成 · 耗时 {(result.elapsed_ms / 1000).toFixed(1)}s
|
||||
</div>
|
||||
|
||||
{/* 排名表 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Play, Square, TrendingDown } from 'lucide-react'
|
||||
import { api, type StrategyDetail } from '@/lib/api'
|
||||
import { fmtPct } from '@/lib/format'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import {
|
||||
startWalkForward,
|
||||
stopWalkForward,
|
||||
clearWalkForward,
|
||||
tryReconnectWalkForward,
|
||||
useWalkForwardTask,
|
||||
} from '@/lib/walkforwardTask'
|
||||
import { buildDefaultOverrides } from '@/lib/strategyOverrides'
|
||||
import {
|
||||
INPUT_CLS,
|
||||
OBJECTIVES,
|
||||
GRID_MAX_COMBINATIONS,
|
||||
useParamSweep,
|
||||
StrategySelect,
|
||||
SweepParamList,
|
||||
CombosHint,
|
||||
} from './components/paramSweep'
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10)
|
||||
const THREE_YEARS_AGO = new Date(Date.now() - 3 * 365 * 864e5).toISOString().slice(0, 10)
|
||||
|
||||
function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) {
|
||||
return (
|
||||
<div className="rounded-input border border-border bg-elevated/40 p-2.5">
|
||||
<div className="text-[11px] text-secondary">{label}</div>
|
||||
<div className="mt-0.5 text-sm font-semibold" style={color ? { color } : undefined}>{value}</div>
|
||||
{hint && <div className="mt-0.5 text-[10px] text-secondary">{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** OOS 拼接净值曲线 (逐折复利) — walk-forward 核心产出的极简 SVG 折线。 */
|
||||
function OosEquityChart({ curve }: { curve: { fold: number; date: string; value: number }[] }) {
|
||||
if (!curve.length) return null
|
||||
const W = 600, H = 120, pad = 8
|
||||
const vals = curve.map(p => p.value)
|
||||
const lo = Math.min(1, ...vals), hi = Math.max(1, ...vals)
|
||||
const span = hi - lo || 1
|
||||
// 起点补一个 value=1 基准, 让曲线从 1.0 起步
|
||||
const pts = [1, ...vals]
|
||||
const x = (i: number) => pad + (i / (pts.length - 1 || 1)) * (W - 2 * pad)
|
||||
const y = (v: number) => pad + (1 - (v - lo) / span) * (H - 2 * pad)
|
||||
const d = pts.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||||
const last = vals[vals.length - 1]
|
||||
const up = last >= 1
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-secondary">OOS 拼接净值 (逐折复利)</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" preserveAspectRatio="none" style={{ height: 120 }}>
|
||||
<line x1={pad} y1={y(1)} x2={W - pad} y2={y(1)} stroke="currentColor" strokeWidth="0.5" className="text-border" strokeDasharray="3 3" />
|
||||
<path d={d} fill="none" stroke={up ? '#34d399' : '#f87171'} strokeWidth="1.5" />
|
||||
</svg>
|
||||
<div className="mt-0.5 text-[10px] text-secondary">终值 {last.toFixed(4)} · {curve.length} 折</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StrategyWalkForward() {
|
||||
const task = useWalkForwardTask()
|
||||
const { data: stratData } = useQuery({ queryKey: ['strategies'], queryFn: api.strategyList })
|
||||
const strategies: StrategyDetail[] = stratData?.strategies ?? []
|
||||
|
||||
// 切策略: 有任务在跑时先真正取消 (关 SSE + 后端 cancel + 清 localStorage), 不能静默丢
|
||||
const sweep = useParamSweep(strategies, () => {
|
||||
if (task?.isPending) stopWalkForward()
|
||||
else clearWalkForward()
|
||||
})
|
||||
const [objective, setObjective] = useState('sortino')
|
||||
const [start, setStart] = useState(THREE_YEARS_AGO)
|
||||
const [end, setEnd] = useState(TODAY)
|
||||
const [mode, setMode] = useState<'position' | 'full'>('position')
|
||||
const [trainDays, setTrainDays] = useState('252')
|
||||
const [testDays, setTestDays] = useState('63')
|
||||
const [stepDays, setStepDays] = useState('63')
|
||||
|
||||
useEffect(() => {
|
||||
tryReconnectWalkForward()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const canRun = sweep.strategyId && sweep.combos > 0 && sweep.combos <= GRID_MAX_COMBINATIONS
|
||||
&& !sweep.gridError
|
||||
&& Number(trainDays) > 0 && Number(testDays) > 0 && Number(stepDays) > 0 && !task?.isPending
|
||||
|
||||
const onRun = () => {
|
||||
if (!canRun) return
|
||||
clearWalkForward()
|
||||
startWalkForward({
|
||||
strategy_id: sweep.strategyId,
|
||||
param_grid: sweep.buildGrid(),
|
||||
objective,
|
||||
train_days: Number(trainDays),
|
||||
test_days: Number(testDays),
|
||||
step_days: Number(stepDays),
|
||||
// 未扫描参数固定为策略当前默认值; overrides 让 basic_filter/信号/风控按当前策略参与,
|
||||
// 否则 walk-forward 优化的策略与用户实际回测的不一致 (同 PR #82 优化器修复)。
|
||||
params: sweep.selected?.params_defaults,
|
||||
overrides: sweep.selected ? buildDefaultOverrides(sweep.selected) : undefined,
|
||||
start,
|
||||
end,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
|
||||
const result = task?.result
|
||||
const progress = task?.progress
|
||||
const summary = result?.summary
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[320px_1fr]">
|
||||
{/* ── 配置面板 ── */}
|
||||
<div className="space-y-3 rounded-card border border-border bg-surface p-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-secondary">策略</label>
|
||||
<StrategySelect strategies={strategies} value={sweep.strategyId} onChange={sweep.selectStrategy} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-secondary">优化目标</label>
|
||||
<select value={objective} onChange={e => setObjective(e.target.value)} className={INPUT_CLS}>
|
||||
{OBJECTIVES.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-secondary">起始</label>
|
||||
<DatePicker value={start} onChange={setStart} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-secondary">结束</label>
|
||||
<DatePicker value={end} onChange={setEnd} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 滚动窗口 */}
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] text-secondary">训练(天)</label>
|
||||
<input type="number" min={1} value={trainDays} onChange={e => setTrainDays(e.target.value)} className={INPUT_CLS} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] text-secondary">测试(天)</label>
|
||||
<input type="number" min={1} value={testDays} onChange={e => setTestDays(e.target.value)} className={INPUT_CLS} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] text-secondary">步进(天)</label>
|
||||
<input type="number" min={1} value={stepDays} onChange={e => setStepDays(e.target.value)} className={INPUT_CLS} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-secondary">模式</label>
|
||||
<select value={mode} onChange={e => setMode(e.target.value as any)} className={INPUT_CLS}>
|
||||
<option value="position">组合仓位</option>
|
||||
<option value="full">全量独立</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<SweepParamList params={sweep.params} sweeps={sweep.sweeps} updateSweep={sweep.updateSweep} />
|
||||
<CombosHint show={!!sweep.strategyId} combos={sweep.combos} gridError={sweep.gridError} />
|
||||
<div className="text-[11px] text-secondary">每折跑 {sweep.combos || 0} 组优化 × N 折,耗时较长</div>
|
||||
|
||||
{task?.isPending ? (
|
||||
<button onClick={stopWalkForward} className="inline-flex w-full items-center justify-center gap-1.5 rounded-btn bg-red-500/90 px-3 py-2 text-xs font-medium text-white hover:bg-red-500">
|
||||
<Square className="h-3.5 w-3.5" /> 停止
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onRun} disabled={!canRun} className="inline-flex w-full items-center justify-center gap-1.5 rounded-btn bg-accent px-3 py-2 text-xs font-medium text-white hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<Play className="h-3.5 w-3.5" /> 开始 Walk-forward
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 结果面板 ── */}
|
||||
<div className="min-h-[300px] rounded-card border border-border bg-surface p-4">
|
||||
{task?.error && (
|
||||
<div className="mb-3 rounded-input border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-400">{task.error}</div>
|
||||
)}
|
||||
|
||||
{task?.isPending && progress && (
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 flex justify-between text-xs text-secondary">
|
||||
<span>第 {progress.done}/{progress.total} 折</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-elevated">
|
||||
<div className="h-full bg-accent transition-all" style={{ width: `${progress.total ? (progress.done / progress.total) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && !task?.isPending && (
|
||||
<EmptyState
|
||||
title="Walk-forward 优化"
|
||||
hint="每折在训练区间网格优化选最优参数,再在紧邻的测试区间做样本外(OOS)验证。样本内漂亮、样本外崩溃即过拟合。"
|
||||
/>
|
||||
)}
|
||||
|
||||
{result && result.n_folds === 0 && (
|
||||
<EmptyState title="未产生有效折"
|
||||
hint={`计划 ${result.n_planned_folds} 折, 但 ${result.n_skipped} 折因训练区间未优化出参数或 OOS 回测失败被跳过。请检查数据范围或放宽参数网格。`} />
|
||||
)}
|
||||
|
||||
{result && summary && result.n_folds > 0 && (
|
||||
<div className="space-y-4">
|
||||
{/* 汇总卡 */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<Stat label="OOS 复利收益" value={fmtPct(summary.compounded_oos_return)}
|
||||
color={summary.compounded_oos_return >= 0 ? '#34d399' : '#f87171'} />
|
||||
<Stat label="IS→OOS 退化"
|
||||
value={summary.degradation != null ? summary.degradation.toFixed(3) : '—'}
|
||||
hint={summary.degradation != null && summary.degradation > 0 ? '样本外退化=过拟合' : '样本外未退化'}
|
||||
color={summary.degradation != null && summary.degradation > 0 ? '#f87171' : '#34d399'} />
|
||||
<Stat label="一致性" value={fmtPct(summary.consistency)} hint="OOS 盈利折占比" />
|
||||
<Stat label="有效折" value={result.n_skipped > 0 ? `${result.n_folds} (跳过${result.n_skipped})` : String(result.n_folds)} />
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-secondary">
|
||||
IS 目标均值 {summary.avg_is_objective ?? '—'} · OOS 目标均值 {summary.avg_oos_objective ?? '—'} · 耗时 {(result.elapsed_ms / 1000).toFixed(1)}s
|
||||
</div>
|
||||
|
||||
{/* OOS 拼接净值曲线 (walk-forward 核心产出) */}
|
||||
<OosEquityChart curve={summary.oos_equity_curve} />
|
||||
|
||||
{/* 每折表 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-secondary">
|
||||
<th className="px-2 py-1.5 text-left">折</th>
|
||||
<th className="px-2 py-1.5 text-left">测试区间</th>
|
||||
<th className="px-2 py-1.5 text-left">最优参数</th>
|
||||
<th className="px-2 py-1.5 text-right">IS 目标</th>
|
||||
<th className="px-2 py-1.5 text-right">OOS 目标</th>
|
||||
<th className="px-2 py-1.5 text-right">OOS 收益</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.folds.map(f => {
|
||||
const is = f.is_score
|
||||
const oos = f.oos_objective
|
||||
// 用后端方向感知的退化标志 (min 类目标 oos<is 未必是退化)
|
||||
const degraded = f.oos_degraded === true
|
||||
return (
|
||||
<tr key={f.index} className="border-b border-border/40 hover:bg-elevated/50">
|
||||
<td className="px-2 py-1.5 text-secondary">{f.index + 1}</td>
|
||||
<td className="px-2 py-1.5 text-secondary">{f.test_start} ~ {f.test_end}</td>
|
||||
<td className="px-2 py-1.5 text-foreground">
|
||||
{f.best_params ? Object.entries(f.best_params).map(([k, v]) => `${k}=${v}`).join(', ') : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">{is != null ? is.toFixed(3) : '—'}</td>
|
||||
<td className="px-2 py-1.5 text-right" style={degraded ? { color: '#f87171' } : undefined}>
|
||||
{oos != null ? oos.toFixed(3) : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{f.oos_stats?.total_return != null ? fmtPct(f.oos_stats.total_return) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{summary.degradation != null && summary.degradation > 0 && (
|
||||
<div className="flex items-center gap-1.5 rounded-input border border-red-500/30 bg-red-500/5 px-3 py-2 text-[11px] text-red-400">
|
||||
<TrendingDown className="h-3.5 w-3.5" />
|
||||
样本外目标较样本内退化 {summary.degradation.toFixed(3)},提示参数可能过拟合训练区间。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { StrategyDetail, StrategyParamDef } from '@/lib/api'
|
||||
|
||||
/** 参数扫描配置的共享逻辑与 UI — 优化器与 walk-forward 复用。 */
|
||||
|
||||
export 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'
|
||||
|
||||
// 可选优化目标 (对齐后端 VALID_OBJECTIVES) + 中文标签
|
||||
export const OBJECTIVES: { id: string; label: string }[] = [
|
||||
{ id: 'sortino', label: '索提诺比率' },
|
||||
{ id: 'sharpe', label: '夏普比率' },
|
||||
{ id: 'calmar', label: 'Calmar 比率' },
|
||||
{ id: 'total_return', label: '总收益' },
|
||||
{ id: 'annual_return', label: '年化收益' },
|
||||
{ id: 'win_rate', label: '胜率' },
|
||||
{ id: 'profit_factor', label: '盈亏比' },
|
||||
{ id: 'max_drawdown', label: '最大回撤(越小越好)' },
|
||||
{ id: 'mc_maxdd_p95', label: '蒙卡回撤P95(越小越好)' },
|
||||
{ id: 'avg_holding_days', label: '平均持仓天数' },
|
||||
]
|
||||
|
||||
export const GRID_MAX_COMBINATIONS = 2000
|
||||
|
||||
export interface Sweep {
|
||||
enabled: boolean
|
||||
min: string
|
||||
max: string
|
||||
step: string
|
||||
}
|
||||
|
||||
function defaultSweep(p: StrategyParamDef): Sweep {
|
||||
return {
|
||||
enabled: false,
|
||||
min: String(p.min ?? p.default ?? 0),
|
||||
max: String(p.max ?? p.default ?? 1),
|
||||
step: String(p.step ?? (p.type === 'int' ? 1 : 0.01)),
|
||||
}
|
||||
}
|
||||
|
||||
/** 某参数候选值个数 (与后端整数计数一致)。 */
|
||||
function candidateCount(p: StrategyParamDef, s: Sweep): number {
|
||||
if (p.type === 'bool') return 2
|
||||
if (p.type === 'select') return p.options?.length ?? 1
|
||||
const lo = Number(s.min), hi = Number(s.max), step = Number(s.step)
|
||||
if (!(step > 0) || hi < lo) return 0
|
||||
return Math.round((hi - lo) / step) + 1
|
||||
}
|
||||
|
||||
/** 校验某数值参数的 sweep 是否会被后端拒绝 (与后端 _candidates_for 同口径)。
|
||||
* 后端按 lo+i*step 生成 (i=0..round((hi-lo)/step)), 任一值超出 [min,max] 即报错。 */
|
||||
function sweepError(p: StrategyParamDef, s: Sweep): string | null {
|
||||
if (p.type === 'bool' || p.type === 'select') return null
|
||||
const lo = Number(s.min), hi = Number(s.max), step = Number(s.step)
|
||||
if (Number.isNaN(lo) || Number.isNaN(hi) || Number.isNaN(step)) return `${p.label}: 范围/步长非法`
|
||||
if (!(step > 0)) return `${p.label}: 步长必须为正`
|
||||
if (hi < lo) return `${p.label}: max < min`
|
||||
if (p.min != null && lo < p.min - 1e-9) return `${p.label}: min 小于允许下限 ${p.min}`
|
||||
if (p.max != null && hi > p.max + 1e-9) return `${p.label}: max 超出允许上限 ${p.max}`
|
||||
const nSteps = Math.round((hi - lo) / step)
|
||||
const last = lo + nSteps * step
|
||||
if (last > hi + 1e-9) return `${p.label}: 步长 ${step} 不整除区间, 末值 ${last.toFixed(4)} 超出 max ${hi}`
|
||||
return null
|
||||
}
|
||||
|
||||
/** 管理策略选择 + 各参数扫描配置, 派生组合数 / 校验 / param_grid。 */
|
||||
export function useParamSweep(strategies: StrategyDetail[], onStrategyChange?: () => void) {
|
||||
const [strategyId, setStrategyId] = useState<string>('')
|
||||
const [sweeps, setSweeps] = useState<Record<string, Sweep>>({})
|
||||
|
||||
const selected = strategies.find(s => s.id === strategyId)
|
||||
const params = selected?.params ?? []
|
||||
|
||||
const selectStrategy = (id: string) => {
|
||||
setStrategyId(id)
|
||||
onStrategyChange?.()
|
||||
const s = strategies.find(x => x.id === id)
|
||||
const init: Record<string, Sweep> = {}
|
||||
for (const p of s?.params ?? []) init[p.id] = defaultSweep(p)
|
||||
setSweeps(init)
|
||||
}
|
||||
|
||||
const updateSweep = (pid: string, patch: Partial<Sweep>) =>
|
||||
setSweeps(prev => ({ ...prev, [pid]: { ...prev[pid], ...patch } }))
|
||||
|
||||
const combos = useMemo(() => {
|
||||
const enabled = params.filter(p => sweeps[p.id]?.enabled)
|
||||
if (!enabled.length) return 0
|
||||
return enabled.reduce((acc, p) => acc * candidateCount(p, sweeps[p.id]), 1)
|
||||
}, [params, sweeps])
|
||||
|
||||
// 网格合法性 (与后端展开同口径): 步长不整除/越界会被后端拒, 前端提前拦。
|
||||
const gridError = useMemo(() => {
|
||||
for (const p of params) {
|
||||
if (!sweeps[p.id]?.enabled) continue
|
||||
const err = sweepError(p, sweeps[p.id])
|
||||
if (err) return err
|
||||
}
|
||||
return null
|
||||
}, [params, sweeps])
|
||||
|
||||
const buildGrid = (): Record<string, any> => {
|
||||
const grid: Record<string, any> = {}
|
||||
for (const p of params) {
|
||||
const s = sweeps[p.id]
|
||||
if (!s?.enabled) continue
|
||||
if (p.type === 'bool') grid[p.id] = [true, false]
|
||||
else if (p.type === 'select') grid[p.id] = p.options ?? []
|
||||
else grid[p.id] = { min: Number(s.min), max: Number(s.max), step: Number(s.step) }
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
return { strategyId, selected, selectStrategy, params, sweeps, updateSweep, combos, gridError, buildGrid }
|
||||
}
|
||||
|
||||
/** 策略选择器。 */
|
||||
export function StrategySelect({ strategies, value, onChange }: {
|
||||
strategies: StrategyDetail[]
|
||||
value: string
|
||||
onChange: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<select value={value} onChange={e => onChange(e.target.value)} className={INPUT_CLS}>
|
||||
<option value="">选择策略…</option>
|
||||
{strategies.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
/** 可扫参数列表 (勾选 + min/max/step)。 */
|
||||
export function SweepParamList({ params, sweeps, updateSweep }: {
|
||||
params: StrategyParamDef[]
|
||||
sweeps: Record<string, Sweep>
|
||||
updateSweep: (pid: string, patch: Partial<Sweep>) => void
|
||||
}) {
|
||||
if (!params.length) return null
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs font-medium text-secondary">扫描参数 (勾选后设范围)</div>
|
||||
<div className="space-y-2">
|
||||
{params.map(p => {
|
||||
const s = sweeps[p.id] ?? defaultSweep(p)
|
||||
const numeric = p.type === 'float' || p.type === 'int'
|
||||
return (
|
||||
<div key={p.id} className="rounded-input border border-border/60 p-2">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={s.enabled} onChange={e => updateSweep(p.id, { enabled: e.target.checked })} />
|
||||
<span className="font-medium text-foreground">{p.label}</span>
|
||||
<span className="text-secondary">({p.type})</span>
|
||||
</label>
|
||||
{s.enabled && numeric && (
|
||||
<div className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
<input type="number" value={s.min} onChange={e => updateSweep(p.id, { min: e.target.value })} placeholder="min" className={INPUT_CLS} />
|
||||
<input type="number" value={s.max} onChange={e => updateSweep(p.id, { max: e.target.value })} placeholder="max" className={INPUT_CLS} />
|
||||
<input type="number" value={s.step} onChange={e => updateSweep(p.id, { step: e.target.value })} placeholder="step" className={INPUT_CLS} />
|
||||
</div>
|
||||
)}
|
||||
{s.enabled && !numeric && (
|
||||
<div className="mt-1 text-[11px] text-secondary">
|
||||
{p.type === 'bool' ? '扫描 [是 / 否]' : `扫描全部选项 (${p.options?.length ?? 0})`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 组合数 / 校验提示 (含上限与网格错误告警)。 */
|
||||
export function CombosHint({ show, combos, gridError }: { show: boolean; combos: number; gridError?: string | null }) {
|
||||
if (!show) return null
|
||||
const bad = combos > GRID_MAX_COMBINATIONS || !!gridError
|
||||
return (
|
||||
<div className={`text-xs ${bad ? 'text-red-400' : 'text-secondary'}`}>
|
||||
{gridError
|
||||
? gridError
|
||||
: combos === 0
|
||||
? '请至少勾选一个参数'
|
||||
: `共 ${combos} 组参数组合${combos > GRID_MAX_COMBINATIONS ? ` — 超过上限 ${GRID_MAX_COMBINATIONS}, 请增大 step 或缩小范围` : ''}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user