mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 16:54:17 +08:00
feat(backtest): 成交价精简 + 一键寻优全策略 + 预设网格
- 成交价精简为 next_open/next_close,删除 this_close/worst/best 三种 非真实模式(this_close 有未来函数偏差,worst/best 为压力测试边界) - 初始资金默认统一为 1,000,000(原 10万/20万) - 新增 presets.py:18 策略各配 1-2 参数的预设寻优网格(笛卡尔积≤200) - registry.to_schema() 返回 preset_grid 字段供前端自动填充 - 新增 POST /backtest/optimize-all/run/async:逐策略预设网格寻优 + 全局排名(OptimizeAllResult:ranking/best/per_strategy/total_grid_points) - 新增 optimize-all 端到端单测 2 例(838 单测全绿)
This commit is contained in:
@@ -32,7 +32,7 @@ import click
|
||||
@click.option(
|
||||
"--execution",
|
||||
default="next_open",
|
||||
type=click.Choice(["next_open", "next_close", "this_close", "worst", "best"]),
|
||||
type=click.Choice(["next_open", "next_close"]),
|
||||
help="成交价规则",
|
||||
)
|
||||
@click.option("--period", default="DAILY", help="K线周期")
|
||||
@@ -298,7 +298,7 @@ def _print_table(result: Any) -> None:
|
||||
@click.option(
|
||||
"--execution",
|
||||
default="next_open",
|
||||
type=click.Choice(["next_open", "next_close", "this_close", "worst", "best"]),
|
||||
type=click.Choice(["next_open", "next_close"]),
|
||||
help="成交价规则",
|
||||
)
|
||||
@click.option("--period", default="DAILY", help="K线周期")
|
||||
|
||||
@@ -73,7 +73,7 @@ class BacktestEngine:
|
||||
min_commission: Minimum commission per trade
|
||||
stamp_tax: Stamp tax rate (for sells)
|
||||
slippage: Slippage rate
|
||||
execution: Execution mode ('next_open', 'this_close')
|
||||
execution: Execution mode ('next_open', 'next_close')
|
||||
position_mode: Position mode ('full', 'long_only', 'short_only')
|
||||
reject_policy: Reject policy ('reduce', 'reject')
|
||||
benchmark: Benchmark data for performance comparison
|
||||
@@ -135,7 +135,6 @@ class BacktestEngine:
|
||||
if self._execution_model is not None:
|
||||
# ExecutionModel path
|
||||
trades = self._execute_with_model(signals, df)
|
||||
future_leak = False
|
||||
else:
|
||||
# OrderSimulator path (default)
|
||||
simulator = OrderSimulator(
|
||||
@@ -154,7 +153,6 @@ class BacktestEngine:
|
||||
cash=self._cash,
|
||||
position=0.0,
|
||||
)
|
||||
future_leak = simulator.future_leak_warning
|
||||
|
||||
# Step 3: Portfolio tracking
|
||||
trades = self._compute_pnls(trades)
|
||||
@@ -176,7 +174,6 @@ class BacktestEngine:
|
||||
"execution": self._execution,
|
||||
"position_mode": self._position_mode,
|
||||
"reject_policy": self._reject_policy,
|
||||
"future_leak_warning": future_leak,
|
||||
}
|
||||
|
||||
return BacktestResult(
|
||||
|
||||
@@ -26,14 +26,13 @@ class OrderSimulator:
|
||||
|
||||
Attributes:
|
||||
df: K线数据 DataFrame
|
||||
execution: 成交价规则 (next_open/next_close/this_close/worst/best)
|
||||
execution: 成交价规则 (next_open/next_close)
|
||||
position_mode: 仓位模式 (full/fixed/percent)
|
||||
reject_policy: 拒绝策略 (reduce/skip)
|
||||
commission: 佣金费率
|
||||
min_commission: 最低佣金
|
||||
stamp_tax: 印花税率(仅卖出)
|
||||
slippage: 滑点(每股)
|
||||
future_leak_warning: 是否使用了未来数据(this_close 模式)
|
||||
"""
|
||||
|
||||
df: pd.DataFrame
|
||||
@@ -45,7 +44,6 @@ class OrderSimulator:
|
||||
stamp_tax: float = 0.001
|
||||
slippage: float = 0.0
|
||||
slippage_model: SlippageModel | None = None
|
||||
future_leak_warning: bool = False
|
||||
|
||||
def simulate(
|
||||
self,
|
||||
@@ -195,26 +193,23 @@ class OrderSimulator:
|
||||
def _resolve_exec_index(self, bar_idx: int) -> int | None:
|
||||
"""根据执行模式确定成交的 K 线索引。
|
||||
|
||||
开盘价 / 收盘价模式均在信号后一根 K 线成交(next_open 取次根开盘价,
|
||||
next_close 取次根收盘价),避免使用信号当根的未完成/未来数据。
|
||||
|
||||
Args:
|
||||
bar_idx: 信号对应的 K 线索引
|
||||
|
||||
Returns:
|
||||
成交 K 线索引
|
||||
"""
|
||||
if self.execution == "this_close":
|
||||
# 当信号 K 线收盘时成交
|
||||
self.future_leak_warning = True
|
||||
return bar_idx
|
||||
else:
|
||||
# 其他模式在下一根 K 线成交
|
||||
return bar_idx + 1
|
||||
return bar_idx + 1
|
||||
|
||||
def _get_price(self, exec_idx: int, direction: str) -> float | None:
|
||||
"""根据执行模式和方向获取成交价。
|
||||
"""根据执行模式获取成交价。
|
||||
|
||||
Args:
|
||||
exec_idx: 成交 K 线索引
|
||||
direction: 交易方向
|
||||
direction: 交易方向(仅作保留,当前两种模式均不依赖方向)
|
||||
|
||||
Returns:
|
||||
成交价格
|
||||
@@ -228,14 +223,6 @@ class OrderSimulator:
|
||||
return float(row["open"])
|
||||
elif self.execution == "next_close":
|
||||
return float(row["close"])
|
||||
elif self.execution == "this_close":
|
||||
return float(row["close"])
|
||||
elif self.execution == "worst":
|
||||
# 买入取最高价,卖出取最低价
|
||||
return float(row["high"]) if direction == "BUY" else float(row["low"])
|
||||
elif self.execution == "best":
|
||||
# 买入取最低价,卖出取最高价
|
||||
return float(row["low"]) if direction == "BUY" else float(row["high"])
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""策略参数寻优预设网格。
|
||||
|
||||
每个内置策略 1-2 个关键参数的合理取值列表,供:
|
||||
|
||||
1. Web UI 参数寻优页(``ParamGridPicker``)切换策略时自动填充——用户开箱即用,
|
||||
仍可编辑/取消勾选。
|
||||
2. 「一键寻优所有策略」(``/backtest/optimize-all/run/async``)逐策略做网格
|
||||
寻优再全局排名。
|
||||
|
||||
约束:单策略笛卡尔积 ≤ 200(``ParamGridOptimizer.MAX_GRID_POINTS``)。行尾注释
|
||||
标注该策略的网格点数。修改本文件即同步生效,无需重启策略注册表。
|
||||
|
||||
新增策略时:在 :data:`STRATEGY_PRESETS` 增加同名 key 即可;未登记的策略在
|
||||
「一键寻优」中会被跳过并记录 warning。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# 策略名 → {参数名: 取值列表}
|
||||
STRATEGY_PRESETS: dict[str, dict[str, list[Any]]] = {
|
||||
# ── 均线类 ──────────────────────────────────────────────────────────────
|
||||
"ma_cross": {
|
||||
# 快线 < 慢线才有意义;笛卡尔积去重快≥慢的无效组合后约 30+
|
||||
"fast": [5, 10, 15, 20, 30, 60],
|
||||
"slow": [10, 20, 30, 60, 120, 250],
|
||||
}, # 36
|
||||
"ema_cross": {
|
||||
"fast": [5, 10, 12, 20],
|
||||
"slow": [20, 26, 30, 60],
|
||||
}, # 16
|
||||
"triple_ma": {
|
||||
"short": [5, 10, 15],
|
||||
"long": [60, 120, 250],
|
||||
}, # 9(mid 用默认 20)
|
||||
"bbi": {
|
||||
"m1": [3, 6],
|
||||
"m4": [20, 30, 50],
|
||||
}, # 6
|
||||
# ── 通道/突破类 ─────────────────────────────────────────────────────────
|
||||
"boll_breakout": {
|
||||
"n": [10, 15, 20, 30],
|
||||
"p": [1.5, 2.0, 2.5, 3.0],
|
||||
}, # 16
|
||||
"donchian": {
|
||||
"n": [10, 15, 20, 30, 40, 55],
|
||||
}, # 6
|
||||
"keltner": {
|
||||
"n": [10, 15, 20, 30],
|
||||
"m": [7, 10, 14, 20],
|
||||
}, # 16
|
||||
"atr_breakout": {
|
||||
"n_ma": [10, 20],
|
||||
"k": [1.5, 2.0, 2.5, 3.0],
|
||||
}, # 8
|
||||
# ── 振荡/反转类 ─────────────────────────────────────────────────────────
|
||||
"macd": {
|
||||
"short": [8, 10, 12, 15],
|
||||
"long": [20, 26, 30, 40],
|
||||
}, # 16
|
||||
"rsi_reversal": {
|
||||
"n": [7, 14, 21],
|
||||
"oversold": [20, 25, 30],
|
||||
}, # 9
|
||||
"kdj_cross": {
|
||||
"n": [5, 9, 14, 18, 21],
|
||||
}, # 5
|
||||
"cci": {
|
||||
"n": [14, 20],
|
||||
"oversold": [-150, -100, -50],
|
||||
}, # 6
|
||||
"wr_reversal": {
|
||||
"n": [7, 14],
|
||||
"oversold": [-90, -80, -70],
|
||||
}, # 6
|
||||
"bias_reversal": {
|
||||
"n": [6, 12],
|
||||
"threshold": [3.0, 5.0, 7.0, 10.0],
|
||||
}, # 8
|
||||
# ── 趋向/平滑类 ─────────────────────────────────────────────────────────
|
||||
"dmi": {
|
||||
"m1": [14, 20],
|
||||
"m2": [6, 10, 14],
|
||||
}, # 6
|
||||
"trix": {
|
||||
"m1": [12, 18],
|
||||
"m2": [12, 20, 30],
|
||||
}, # 6
|
||||
"emv": {
|
||||
"n": [10, 14, 20, 28],
|
||||
}, # 4
|
||||
"dpo": {
|
||||
"m1": [15, 20, 30],
|
||||
}, # 3
|
||||
}
|
||||
|
||||
|
||||
def get_preset(strategy_name: str) -> dict[str, list[Any]]:
|
||||
"""返回某策略的预设网格;未登记返回空 dict。"""
|
||||
return STRATEGY_PRESETS.get(strategy_name, {})
|
||||
@@ -225,11 +225,15 @@ class RegisteredStrategy:
|
||||
|
||||
def to_schema(self) -> dict[str, Any]:
|
||||
"""序列化为 JSON 兼容的策略描述(供前端策略下拉框 + 参数表单)。"""
|
||||
# 延迟导入避免 presets ↔ registry 循环依赖
|
||||
from easy_tdx.backtest.strategies.presets import get_preset
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"label": self.label,
|
||||
"description": self.description,
|
||||
"params": [p.to_schema() for p in self.params],
|
||||
"preset_grid": get_preset(self.name),
|
||||
}
|
||||
|
||||
def build(
|
||||
|
||||
@@ -17,6 +17,9 @@ __all__ = [
|
||||
"StrategySchemaResponse",
|
||||
"TaskSubmitResponse",
|
||||
"TaskStateResponse",
|
||||
"OptimizeAllBacktestRequest",
|
||||
"OptimizeAllResult",
|
||||
"OptimizeAllRankEntry",
|
||||
"serialize_result",
|
||||
]
|
||||
|
||||
@@ -35,12 +38,12 @@ class BacktestRequest(BaseModel):
|
||||
|
||||
strategy: str = Field(..., description="策略名(见 /backtest/strategies)")
|
||||
params: dict[str, Any] = Field(default_factory=dict, description="策略参数")
|
||||
cash: float = Field(default=100000.0, gt=0, description="初始资金")
|
||||
cash: float = Field(default=1_000_000.0, gt=0, description="初始资金")
|
||||
commission: float = Field(default=0.0003, ge=0, le=0.01, description="佣金费率")
|
||||
min_commission: float = Field(default=5.0, ge=0, description="单笔最低佣金")
|
||||
stamp_tax: float = Field(default=0.001, ge=0, le=0.01, description="印花税(卖出)")
|
||||
slippage: float = Field(default=0.0, ge=0, le=0.05, description="滑点费率")
|
||||
execution: Literal["next_open", "next_close", "this_close", "worst", "best"] = Field(
|
||||
execution: Literal["next_open", "next_close"] = Field(
|
||||
default="next_open", description="成交模式"
|
||||
)
|
||||
|
||||
@@ -78,14 +81,12 @@ class PortfolioBacktestRequest(BaseModel):
|
||||
|
||||
strategy: str = Field(..., description="策略名")
|
||||
params: dict[str, Any] = Field(default_factory=dict, description="策略参数")
|
||||
cash: float = Field(default=200000.0, gt=0, description="组合总资金")
|
||||
cash: float = Field(default=1_000_000.0, gt=0, description="组合总资金")
|
||||
commission: float = Field(default=0.0003, ge=0, le=0.01)
|
||||
min_commission: float = Field(default=5.0, ge=0)
|
||||
stamp_tax: float = Field(default=0.001, ge=0, le=0.01)
|
||||
slippage: float = Field(default=0.0, ge=0, le=0.05)
|
||||
execution: Literal["next_open", "next_close", "this_close", "worst", "best"] = Field(
|
||||
default="next_open"
|
||||
)
|
||||
execution: Literal["next_open", "next_close"] = Field(default="next_open")
|
||||
stocks: list[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
@@ -115,12 +116,10 @@ class OptimizeBacktestRequest(BaseModel):
|
||||
"""
|
||||
|
||||
strategy: str = Field(..., description="策略名")
|
||||
cash: float = Field(default=100000.0, gt=0)
|
||||
cash: float = Field(default=1_000_000.0, gt=0)
|
||||
commission: float = Field(default=0.0003, ge=0, le=0.01)
|
||||
slippage: float = Field(default=0.0, ge=0, le=0.05)
|
||||
execution: Literal["next_open", "next_close", "this_close", "worst", "best"] = Field(
|
||||
default="next_open"
|
||||
)
|
||||
execution: Literal["next_open", "next_close"] = Field(default="next_open")
|
||||
param_grid: dict[str, list[int | float | str]] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
@@ -147,6 +146,39 @@ class OptimizeBacktestRequest(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class OptimizeAllBacktestRequest(BaseModel):
|
||||
"""一键寻优所有策略请求。
|
||||
|
||||
在单个标的上,对所有策略的预设参数网格(见
|
||||
``easy_tdx.backtest.strategies.presets.STRATEGY_PRESETS``)依次做网格寻优,
|
||||
取各策略最优点汇总成全局排名,找出最佳策略 + 参数组合。数据来源与单标的
|
||||
回测一致(ohlcv 内联或 symbol 取行情)。
|
||||
"""
|
||||
|
||||
cash: float = Field(default=1_000_000.0, gt=0)
|
||||
commission: float = Field(default=0.0003, ge=0, le=0.01)
|
||||
slippage: float = Field(default=0.0, ge=0, le=0.05)
|
||||
execution: Literal["next_open", "next_close"] = Field(default="next_open")
|
||||
|
||||
# 数据来源 A:内联 OHLCV
|
||||
ohlcv: list[dict[str, Any]] | None = Field(default=None, max_length=2000)
|
||||
|
||||
# 数据来源 B:按标的取行情
|
||||
symbol: str | None = Field(default=None, pattern=r"^(SZ|SH|BJ):\d{6}$")
|
||||
category: Literal["DAY", "WEEK", "MONTH", "MIN_5", "MIN_15", "MIN_30", "MIN_60"] = Field(
|
||||
default="DAY"
|
||||
)
|
||||
count: int = Field(default=250, ge=20, le=800)
|
||||
start_date: str | None = Field(default=None)
|
||||
end_date: str | None = Field(default=None)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_data_source(self) -> OptimizeAllBacktestRequest:
|
||||
if self.ohlcv is None and self.symbol is None:
|
||||
raise ValueError("必须提供 ohlcv 或 symbol 之一")
|
||||
return self
|
||||
|
||||
|
||||
# ── 响应模型 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -202,6 +234,30 @@ class TaskListResponse(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class OptimizeAllRankEntry(BaseModel):
|
||||
"""一键寻优全局排名单行:某策略的最优点摘要。"""
|
||||
|
||||
strategy: str
|
||||
strategy_label: str
|
||||
params: dict[str, Any]
|
||||
total_return: float = 0.0
|
||||
sharpe: float = 0.0
|
||||
max_drawdown: float = 0.0
|
||||
total_trades: int = 0
|
||||
win_rate: float = 0.0
|
||||
profit_factor: float = 0.0
|
||||
grid_points: int = 0 # 该策略本轮寻优的网格点数
|
||||
|
||||
|
||||
class OptimizeAllResult(BaseModel):
|
||||
"""一键寻优所有策略的结果:全局排名 + 最佳 + 各策略最优点。"""
|
||||
|
||||
ranking: list[OptimizeAllRankEntry] # 按 total_return 降序
|
||||
best: OptimizeAllRankEntry | None = None
|
||||
per_strategy: dict[str, OptimizeAllRankEntry] = {} # 策略名 → 最优点
|
||||
total_grid_points: int = 0 # 所有策略网格点合计
|
||||
|
||||
|
||||
# ── 结果序列化 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ from fastapi import APIRouter, Depends
|
||||
from easy_tdx.web.backtest_schemas import (
|
||||
BacktestRequest,
|
||||
BacktestResultResponse,
|
||||
OptimizeAllBacktestRequest,
|
||||
OptimizeAllRankEntry,
|
||||
OptimizeAllResult,
|
||||
OptimizeBacktestRequest,
|
||||
PortfolioBacktestRequest,
|
||||
StrategySchemaResponse,
|
||||
@@ -222,6 +225,47 @@ async def run_optimize_async(
|
||||
return TaskSubmitResponse(task_id=task_id, status=status)
|
||||
|
||||
|
||||
# ── 一键寻优所有策略 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/backtest/optimize-all/run/async", response_model=TaskSubmitResponse, status_code=202)
|
||||
async def run_optimize_all_async(
|
||||
req: OptimizeAllBacktestRequest,
|
||||
client: Any = Depends(get_client),
|
||||
) -> TaskSubmitResponse:
|
||||
"""提交「一键寻优所有策略」后台任务。
|
||||
|
||||
在单个标的上,对所有策略的预设参数网格(见 presets.STRATEGY_PRESETS)依次
|
||||
做网格寻优,取各策略最优点汇总成全局排名。数据获取支持内联 ohlcv 或按
|
||||
symbol 取行情。通过 GET /backtest/tasks/{task_id} 轮询结果。
|
||||
"""
|
||||
# 1. 取数据
|
||||
if req.ohlcv is not None:
|
||||
df = _ohlcv_to_df(req.ohlcv)
|
||||
desc_bars = f"{len(df)} 根"
|
||||
elif req.symbol is not None:
|
||||
df = await _fetch_bars(client, req.symbol, req.category, 800)
|
||||
desc_bars = f"{req.symbol}"
|
||||
if req.start_date or req.end_date:
|
||||
df = _filter_df_by_date(df, req.start_date, req.end_date)
|
||||
else:
|
||||
raise ValueError("必须提供 ohlcv 或 symbol")
|
||||
|
||||
# 2. 捕获快照
|
||||
snapshot = req.model_copy()
|
||||
description = f"一键寻优全部策略 | {desc_bars}"
|
||||
|
||||
# 3. 提交后台任务
|
||||
runner = get_runner()
|
||||
task_id = runner.submit(
|
||||
lambda: _run_optimize_all(df, snapshot),
|
||||
description=description,
|
||||
)
|
||||
state = runner.get(task_id)
|
||||
status: Any = state.status if state.status in ("pending", "running") else "running"
|
||||
return TaskSubmitResponse(task_id=task_id, status=status)
|
||||
|
||||
|
||||
# ── 内部实现 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -399,6 +443,76 @@ def _run_optimize(df: pd.DataFrame, req: OptimizeBacktestRequest) -> dict[str, A
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
def _run_optimize_all(df: pd.DataFrame, req: OptimizeAllBacktestRequest) -> dict[str, Any]:
|
||||
"""对所有策略的预设网格逐策略寻优,汇总成全局排名(后台线程内调用)。
|
||||
|
||||
遍历 ``STRATEGY_PRESETS`` 中每个策略,用其预设参数网格跑
|
||||
:class:`ParamGridOptimizer`,取各策略的最优点(best)组装排名。单个策略
|
||||
无有效结果(如全网格回测失败)则跳过。
|
||||
"""
|
||||
from easy_tdx.backtest.optimizer import ParamGridOptimizer
|
||||
from easy_tdx.backtest.strategies import get_registry
|
||||
from easy_tdx.backtest.strategies.presets import STRATEGY_PRESETS
|
||||
|
||||
registry = get_registry()
|
||||
ranking: list[OptimizeAllRankEntry] = []
|
||||
per_strategy: dict[str, OptimizeAllRankEntry] = {}
|
||||
total_grid = 0
|
||||
|
||||
for strategy_name, grid in STRATEGY_PRESETS.items():
|
||||
# 预设里登记但未注册的策略跳过(理论上不应发生)
|
||||
if strategy_name not in registry.names():
|
||||
continue
|
||||
label = registry.get(strategy_name).label
|
||||
try:
|
||||
optimizer = ParamGridOptimizer(
|
||||
strategy_name=strategy_name,
|
||||
param_grid=grid,
|
||||
df=df,
|
||||
cash=req.cash,
|
||||
commission=req.commission,
|
||||
slippage=req.slippage,
|
||||
execution=req.execution,
|
||||
)
|
||||
except ValueError:
|
||||
# 单策略网格超限(不应发生,预设已控制规模)→ 跳过
|
||||
continue
|
||||
|
||||
result = optimizer.run()
|
||||
if result.best is None:
|
||||
continue
|
||||
|
||||
# 该策略本轮真实跑的网格点数(笛卡尔积,去掉失败点后的有效点)
|
||||
total_grid += len(result.results)
|
||||
|
||||
entry = OptimizeAllRankEntry(
|
||||
strategy=strategy_name,
|
||||
strategy_label=label,
|
||||
params=result.best.params,
|
||||
total_return=result.best.total_return,
|
||||
sharpe=result.best.sharpe,
|
||||
max_drawdown=result.best.max_drawdown,
|
||||
total_trades=result.best.total_trades,
|
||||
win_rate=result.best.win_rate,
|
||||
profit_factor=result.best.profit_factor,
|
||||
grid_points=len(result.results),
|
||||
)
|
||||
ranking.append(entry)
|
||||
per_strategy[strategy_name] = entry
|
||||
|
||||
# 按 total_return 降序
|
||||
ranking.sort(key=lambda r: r.total_return, reverse=True)
|
||||
best = ranking[0] if ranking else None
|
||||
|
||||
result_obj = OptimizeAllResult(
|
||||
ranking=ranking,
|
||||
best=best,
|
||||
per_strategy=per_strategy,
|
||||
total_grid_points=total_grid,
|
||||
)
|
||||
return result_obj.model_dump()
|
||||
|
||||
|
||||
def _filter_df_by_date(df: pd.DataFrame, start: str | None, end: str | None) -> pd.DataFrame:
|
||||
"""按日期范围过滤 DataFrame(闭区间,比较 YYYY-MM-DD)。"""
|
||||
if not start and not end:
|
||||
|
||||
@@ -185,17 +185,6 @@ def test_chanlun_injection():
|
||||
assert len(result.trades) >= 1
|
||||
|
||||
|
||||
def test_this_close_warning_in_config():
|
||||
"""Test future_leak_warning in config when using this_close."""
|
||||
df = _make_df(n=50)
|
||||
engine = BacktestEngine(MACrossStrategy, execution="this_close")
|
||||
result = engine.run(df)
|
||||
|
||||
# Config should have future_leak_warning
|
||||
# Note: MACrossStrategy may not generate signals, so warning might be False
|
||||
assert "future_leak_warning" in result.config
|
||||
|
||||
|
||||
def test_config_snapshot():
|
||||
"""Test config contains correct cash and commission."""
|
||||
df = _make_df(n=50)
|
||||
|
||||
@@ -77,74 +77,6 @@ class TestExecutionModes:
|
||||
assert len(trades) == 1
|
||||
assert trades[0].price == 102.0 # df["close"].iloc[1]
|
||||
|
||||
def test_this_close(self) -> None:
|
||||
"""this_close: 当前K线的收盘价。"""
|
||||
df = _make_df(10)
|
||||
sim = OrderSimulator(df, execution="this_close")
|
||||
|
||||
signals = [_buy_signal(0, size=100)]
|
||||
trades = sim.simulate(signals, cash=20000, position=0)
|
||||
|
||||
assert len(trades) == 1
|
||||
assert trades[0].price == 101.0 # df["close"].iloc[0]
|
||||
|
||||
def test_this_close_future_leak_warning(self) -> None:
|
||||
"""this_close 模式应设置 future_leak_warning 标志。"""
|
||||
df = _make_df(10)
|
||||
sim = OrderSimulator(df, execution="this_close")
|
||||
|
||||
assert sim.future_leak_warning is False
|
||||
|
||||
# 执行模拟后应设置标志
|
||||
signals = [_buy_signal(0, size=100)]
|
||||
sim.simulate(signals, cash=20000, position=0)
|
||||
|
||||
assert sim.future_leak_warning is True
|
||||
|
||||
def test_worst_price_buy(self) -> None:
|
||||
"""worst: 买入取最高价。"""
|
||||
df = _make_df(10)
|
||||
sim = OrderSimulator(df, execution="worst")
|
||||
|
||||
signals = [_buy_signal(0, size=100)]
|
||||
trades = sim.simulate(signals, cash=20000, position=0)
|
||||
|
||||
assert len(trades) == 1
|
||||
assert trades[0].price == 103.0 # df["high"].iloc[1]
|
||||
|
||||
def test_worst_price_sell(self) -> None:
|
||||
"""worst: 卖出取最低价。"""
|
||||
df = _make_df(10)
|
||||
sim = OrderSimulator(df, execution="worst")
|
||||
|
||||
signals = [_sell_signal(0, size=100)]
|
||||
trades = sim.simulate(signals, cash=0, position=200)
|
||||
|
||||
assert len(trades) == 1
|
||||
assert trades[0].price == 100.0 # df["low"].iloc[1]
|
||||
|
||||
def test_best_price_buy(self) -> None:
|
||||
"""best: 买入取最低价。"""
|
||||
df = _make_df(10)
|
||||
sim = OrderSimulator(df, execution="best")
|
||||
|
||||
signals = [_buy_signal(0, size=100)]
|
||||
trades = sim.simulate(signals, cash=20000, position=0)
|
||||
|
||||
assert len(trades) == 1
|
||||
assert trades[0].price == 100.0 # df["low"].iloc[1]
|
||||
|
||||
def test_best_price_sell(self) -> None:
|
||||
"""best: 卖出取最高价。"""
|
||||
df = _make_df(10)
|
||||
sim = OrderSimulator(df, execution="best")
|
||||
|
||||
signals = [_sell_signal(0, size=100)]
|
||||
trades = sim.simulate(signals, cash=0, position=200)
|
||||
|
||||
assert len(trades) == 1
|
||||
assert trades[0].price == 103.0 # df["high"].iloc[1]
|
||||
|
||||
|
||||
# ── Test Position Modes ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -489,16 +421,3 @@ class TestNonContinuousIndex:
|
||||
# position 1 的 open = 101.0;旧代码会用 label 10 当位置 → iloc[10] 越界
|
||||
assert trades[0].price == 101.0
|
||||
assert trades[0].rejected is False
|
||||
|
||||
def test_this_close_with_non_continuous_index(self) -> None:
|
||||
"""this_close 模式下信号在 bar 2(label=30),应在同根 close 成交。"""
|
||||
df = _make_df(10)
|
||||
df.index = [10 * (i + 1) for i in range(len(df))]
|
||||
sim = OrderSimulator(df, execution="this_close")
|
||||
|
||||
signals = [_buy_signal(2, size=100)]
|
||||
trades = sim.simulate(signals, cash=20000, position=0)
|
||||
|
||||
assert len(trades) == 1
|
||||
# position 2 的 close = 103.0
|
||||
assert trades[0].price == 103.0
|
||||
|
||||
@@ -191,7 +191,7 @@ def test_backtest_request_defaults():
|
||||
from easy_tdx.web.backtest_schemas import BacktestRequest
|
||||
|
||||
req = BacktestRequest(strategy="ma_cross", symbol="SZ:000001")
|
||||
assert req.cash == 100000.0
|
||||
assert req.cash == 1_000_000.0
|
||||
assert req.commission == 0.0003
|
||||
assert req.execution == "next_open"
|
||||
assert req.category == "DAY"
|
||||
@@ -704,7 +704,7 @@ def test_portfolio_request_defaults():
|
||||
from easy_tdx.web.backtest_schemas import PortfolioBacktestRequest
|
||||
|
||||
req = PortfolioBacktestRequest(strategy="ma_cross", stocks=["SZ:000001"])
|
||||
assert req.cash == 200000.0
|
||||
assert req.cash == 1_000_000.0
|
||||
assert req.category == "DAY"
|
||||
|
||||
|
||||
@@ -899,6 +899,55 @@ def test_optimize_single_param_no_heatmap(client, sample_ohlcv):
|
||||
assert final["result"]["heatmap"] is None
|
||||
|
||||
|
||||
def test_optimize_all_endpoint(client, sample_ohlcv):
|
||||
"""POST /backtest/optimize-all/run/async 端到端:逐策略预设网格寻优 + 全局排名。"""
|
||||
resp = client.post(
|
||||
"/api/v1/backtest/optimize-all/run/async",
|
||||
json={
|
||||
"cash": 1_000_000,
|
||||
"ohlcv": sample_ohlcv,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 202, resp.text
|
||||
task_id = resp.json()["task_id"]
|
||||
|
||||
final = None
|
||||
for _ in range(400):
|
||||
poll = client.get(f"/api/v1/backtest/tasks/{task_id}")
|
||||
final = poll.json()
|
||||
if final["status"] in ("done", "failed"):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
assert final["status"] == "done", final
|
||||
result = final["result"]
|
||||
# 排名按 total_return 降序、best 指向第一名、各策略最优点齐全
|
||||
assert "ranking" in result and len(result["ranking"]) > 0
|
||||
assert "best" in result and result["best"] is not None
|
||||
assert "per_strategy" in result and len(result["per_strategy"]) == len(result["ranking"])
|
||||
assert "total_grid_points" in result and result["total_grid_points"] > 0
|
||||
# ranking 降序校验
|
||||
returns = [r["total_return"] for r in result["ranking"]]
|
||||
assert returns == sorted(returns, reverse=True)
|
||||
# best == ranking[0]
|
||||
assert result["best"]["strategy"] == result["ranking"][0]["strategy"]
|
||||
# 合计网格点 == 各策略 grid_points 之和
|
||||
assert result["total_grid_points"] == sum(r["grid_points"] for r in result["ranking"])
|
||||
|
||||
|
||||
def test_optimize_all_request_validation():
|
||||
"""optimize-all 请求必须提供数据源。"""
|
||||
from easy_tdx.web.backtest_schemas import OptimizeAllBacktestRequest
|
||||
|
||||
# 缺数据源
|
||||
with pytest.raises(ValueError):
|
||||
OptimizeAllBacktestRequest()
|
||||
# 合法
|
||||
req = OptimizeAllBacktestRequest(symbol="SZ:000001")
|
||||
assert req.cash == 1_000_000.0
|
||||
assert req.execution == "next_open"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 5: 任务列表端点(对比页用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user