Files
mt-data-mcp/research/backtest.py
T
gavindiazandClaude Code 1bcfe6e374 feat(research): complete the offline pipeline and add live-identical backtest
research/ now runs end-to-end (fetch -> features/labels -> walk-forward ->
train -> OOS evaluate -> save), and backtest_strategy() replays the same
signal(ctx) strategy over a historical frame with the same manage_position
hook the live loop calls, simulating SL/TP, trailing, partial closes and cost
per bar. --backtest wires it into the CLI.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-25 20:20:49 +08:00

446 lines
17 KiB
Python

"""Walk-forward backtesting: delegate to mtdata-cli ``strategy_validate``.
``strategy_validate`` runs anchored fixed-candidate OOS validation with
horizon-safe outcomes (barrier next-open execution, purge/embargo to prevent
leakage). This module wraps it as a subprocess and also provides a tiny local
walk-forward stub so the pipeline can run without a live broker connection.
"""
from __future__ import annotations
import json
import math
import subprocess
from typing import Any, Optional
import pandas as pd
from .config import ResearchConfig
class BacktestError(RuntimeError):
pass
def _run_cli(config: ResearchConfig, *args: str, timeout: Optional[float] = None) -> dict[str, Any]:
cmd = [config.mtdata_cli, *args, "--json"]
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout or config.cli_timeout_seconds,
)
except subprocess.TimeoutExpired as exc:
raise BacktestError(f"mtdata-cli timed out: {' '.join(cmd)}") from exc
except FileNotFoundError as exc:
raise BacktestError(f"mtdata-cli not found: {config.mtdata_cli!r}") from exc
if proc.returncode != 0:
raise BacktestError(f"mtdata-cli failed ({proc.returncode}): {proc.stderr.strip()}")
try:
payload = json.loads(proc.stdout)
except json.JSONDecodeError as exc:
raise BacktestError(f"mtdata-cli returned non-JSON: {proc.stdout[:200]!r}") from exc
return payload
def run_strategy_validate(
config: ResearchConfig,
symbol: str,
candidates: list[dict[str, Any]],
*,
timeframe: Optional[str] = None,
lookback: Optional[int] = None,
) -> dict[str, Any]:
"""Invoke mtdata-cli strategy_validate with cost-model flags and n_splits."""
args = ["strategy_validate", symbol]
args += ["--timeframe", timeframe or config.timeframe]
if lookback is not None:
args += ["--lookback", str(lookback)]
elif config.wf_min_train_bars > 0:
args += ["--lookback", str(config.wf_min_train_bars * max(config.wf_n_splits, 1))]
args += ["--n-splits", str(config.wf_n_splits)]
args += ["--candidates", json.dumps(candidates)]
if config.commission_bps_per_side > 0 or config.spread_bps > 0:
args += ["--cost-model", "fixed"]
args += ["--spread-bps", str(config.spread_bps)]
if config.commission_bps_per_side > 0:
args += ["--commission-bps-per-side", str(config.commission_bps_per_side)]
if config.slippage_bps > 0:
args += ["--slippage-bps", str(config.slippage_bps)]
return _run_cli(config, *args)
# ---------------------------------------------------------------------------
# local walk-forward stub (no broker needed)
# ---------------------------------------------------------------------------
def local_walk_forward(frame: pd.DataFrame, config: ResearchConfig) -> dict[str, Any]:
"""Chronological fold summary over a local frame.
Placeholder: splits the frame into ``wf_n_splits`` chronological folds and
reports per-fold bar counts plus the label distribution. Real strategy/model
scoring plugs in here or, preferably, through ``run_strategy_validate``.
"""
if len(frame) < config.wf_min_train_bars:
raise BacktestError(
f"need >= {config.wf_min_train_bars} bars for walk-forward, got {len(frame)}"
)
from .feature_engineering import build_labels
labels = build_labels(frame, config)
n = len(frame)
fold_size = n // config.wf_n_splits
folds: list[dict[str, Any]] = []
for i in range(config.wf_n_splits):
lo = i * fold_size
hi = n if i == config.wf_n_splits - 1 else (i + 1) * fold_size
part = labels.iloc[lo:hi].dropna()
counts = part.value_counts().to_dict()
folds.append(
{
"fold": i,
"start_idx": lo,
"end_idx": hi,
"bars": hi - lo,
"labels": {int(k): int(v) for k, v in counts.items()},
"hit_rate": float((part == 1).mean()) if len(part) else float("nan"),
}
)
return {"method": "local_walk_forward_stub", "n_splits": config.wf_n_splits, "folds": folds}
# ---------------------------------------------------------------------------
# offline strategy backtest (reuses the *same* signal(ctx) as live trading)
# ---------------------------------------------------------------------------
def _candle_row(row: pd.Series) -> dict[str, Any]:
"""Map one frame row to the candle dict the shell's SignalContext expects."""
return {
"time": str(row.name) if hasattr(row.name, "strftime") else str(row.name),
"open": float(row.get("open", row["close"])),
"high": float(row.get("high", row["close"])),
"low": float(row.get("low", row["close"])),
"close": float(row["close"]),
"tick_volume": int(row.get("tick_volume", 0) or 0),
}
def _cost_pct(config: ResearchConfig) -> tuple[float, float]:
"""Round-trip cost as a fraction: (entry_side, exit_side)."""
entry = (config.slippage_bps + config.commission_bps_per_side) * 0.0001
exit_ = (config.slippage_bps + config.commission_bps_per_side) * 0.0001
entry += config.spread_bps * 0.0001 # spread opens against the trade
return entry, exit_
def _make_context(
frame: pd.DataFrame,
config: ResearchConfig,
index: int,
position: Optional[dict],
symbol_spec: Optional[dict],
) -> Any:
"""Rebuild a :class:`trading.types.SignalContext` for bar ``index``, with the
current open position and broker spec — matching the live loop's shape."""
from trading.types import SignalContext
window = frame.iloc[: index + 1].reset_index(drop=True)
candles = [_candle_row(window.iloc[k]) for k in range(len(window))]
px = float(frame["close"].iloc[index])
return SignalContext(
symbol=getattr(config, "symbol", "EURUSD"),
timeframe=config.timeframe,
candles=candles,
quote={"bid": px, "ask": px},
account={},
positions=[position] if position is not None else [],
symbol_spec=symbol_spec or {},
)
def _sim_position(direction: int, entry: float, sl: float, tp: float, volume: float, reason: str) -> dict:
"""Simulated position in the shape exits.py and strategies read."""
return {
"type": "BUY" if direction == 1 else "SELL",
"open_price": entry,
"price_current": entry,
"stop_loss": sl,
"take_profit": tp,
"volume": volume,
"signal_reason": reason,
}
def _pnl_pct(position: dict, exit_price: float, entry_cost: float, exit_cost: float) -> float:
direction = 1 if position["type"] == "BUY" else -1
entry = float(position["open_price"])
gross = direction * (exit_price / entry - 1.0) * 100.0
return gross - entry_cost * 100.0 - exit_cost * 100.0
def _r_multiple(position: dict, exit_price: float) -> float:
direction = 1 if position["type"] == "BUY" else -1
entry = float(position["open_price"])
risk = abs(entry - float(position["stop_loss"]))
if risk <= 0:
return 0.0
return direction * (exit_price / entry - 1.0) / (risk / entry)
def apply_position_action(
position: dict,
action: Optional[Any],
price: float,
entry_cost: float,
exit_cost: float,
) -> tuple[Optional[dict], Optional[dict]]:
"""Apply a strategy :class:`trading.types.PositionAction` to a simulated position.
Returns ``(new_position, realized_trade)`` — ``realized_trade`` is ``None``
for "hold"/"modify" (no PnL locked yet); for "close" it clears the position
and locks full PnL; for "partial_close" it reduces ``volume`` by
``close_volume`` and locks PnL for that slice only. This mirrors exactly
what the live loop's Executor does with the same action.
"""
if action is None or action.kind == "hold":
return position, None
if action.kind == "modify":
if action.stop_loss is not None:
position["stop_loss"] = float(action.stop_loss)
if action.take_profit is not None:
position["take_profit"] = float(action.take_profit)
return position, None
if action.kind == "close":
net = _pnl_pct(position, price, entry_cost, exit_cost)
return None, {
"exit": round(price, 5),
"reason": "close",
"pnl_pct": net,
"r_multiple": _r_multiple(position, price),
}
if action.kind == "partial_close":
volume = float(position.get("volume", 0.0) or 0.0)
close_vol = min(float(action.close_volume or 0.0), volume)
if close_vol <= 0:
return position, None
fraction = close_vol / volume if volume else 1.0
net = _pnl_pct(position, price, entry_cost, exit_cost) * fraction
position["volume"] = volume - close_vol
trade = {
"exit": round(price, 5),
"reason": "partial_close",
"pnl_pct": net,
"r_multiple": _r_multiple(position, price) * fraction,
"close_volume": round(close_vol, 6),
}
return (None if position["volume"] <= 1e-12 else position), trade
return position, None
def _record_trade(
trades: list[dict],
equity_pct: list[float],
position: dict,
entry_bar: int,
exit_idx: int,
exit_price: float,
reason: str,
net_pct: float,
r_mul: float,
*,
close_volume: Optional[float] = None,
) -> None:
"""Append one closed-trade row and advance the cumulative equity curve."""
entry = float(position["open_price"])
row = {
"side": "BUY" if position["type"] == "BUY" else "SELL",
"entry_idx": entry_bar,
"entry": round(entry, 5),
"exit_idx": exit_idx,
"exit": round(exit_price, 5),
"reason": reason,
"bars_held": exit_idx - entry_bar,
"pnl_pct": round(net_pct, 4),
"r_multiple": round(r_mul, 4),
"signal_reason": position.get("signal_reason", ""),
}
if close_volume is not None:
row["close_volume"] = round(close_volume, 6)
trades.append(row)
equity_pct.append(equity_pct[-1] + net_pct)
def backtest_strategy(
strategy: Any,
frame: pd.DataFrame,
config: ResearchConfig,
*,
same_bar_policy: str = "sl_first",
symbol_spec: Optional[dict] = None,
) -> dict[str, Any]:
"""Backtest any ``signal(ctx)`` strategy on a historical frame.
This is the key abstraction for "time spent on strategy research": the
*same* strategy object used by the live shell (a ``trading.strategies``
instance) is called bar-by-bar over historical data with a reconstructed
:class:`trading.types.SignalContext`. No dataset-label coupling — trades
are simulated from the signal's own SL/TP levels with realistic cost.
Position management is modeled too: if the strategy overrides
``manage_position`` (the hook the live loop calls), every bar that does not
hit the server-side SL/TP first runs it and applies the returned
``PositionAction`` with the same semantics as the live Executor — so a
trailing stop, breakeven, partial close or early exit behaves identically
in research and live. ``symbol_spec`` (a ``symbols_describe`` payload) is
passed into every context, as the live loop does.
Execution model (close-to-next-open to avoid lookahead):
1. evaluate the strategy at bar *i* (close)
2. open the trade at bar *i+1*, if a signal fired
3. per bar: server-side SL/TP first (prices moved first), then
``manage_position`` at the bar close; unresolved trades close at the
last bar's close.
Returns summary stats plus per-trade rows.
"""
required = {"close"}
missing = required - set(frame.columns)
if missing:
raise BacktestError(f"frame missing columns {sorted(missing)} for backtest")
if len(frame) < 3:
raise BacktestError(f"need >= 3 bars to backtest, got {len(frame)}")
trades: list[dict[str, Any]] = []
equity_pct: list[float] = [0.0] # cumulative return % after each closed trade
entry_cost, exit_cost = _cost_pct(config)
n_bars = len(frame)
position: Optional[dict] = None
entry_bar = -1
last_close = 0.0
for j in range(n_bars):
bar = frame.iloc[j]
last_close = float(bar["close"])
high = float(bar["high"]) if "high" in frame.columns else last_close
low = float(bar["low"]) if "low" in frame.columns else last_close
if position is not None and j > entry_bar:
# (a) server-side SL/TP decide first — prices moved first in real time
direction = 1 if position["type"] == "BUY" else -1
sl = float(position["stop_loss"])
tp = float(position["take_profit"])
hit_sl = low <= sl if direction == 1 else high >= sl
hit_tp = high >= tp if direction == 1 else low <= tp
if hit_sl and hit_tp:
if same_bar_policy == "tp_first":
hit_sl = False
else:
hit_tp = False
exit_price: Optional[float] = None
reason = ""
if hit_tp:
exit_price, reason = tp, "tp"
elif hit_sl:
exit_price, reason = sl, "sl"
if exit_price is not None:
_record_trade(
trades, equity_pct, position, entry_bar, j, exit_price, reason,
_pnl_pct(position, exit_price, entry_cost, exit_cost),
_r_multiple(position, exit_price),
)
position = None
continue
# (b) strategy management at bar close (no SL/TP hit this bar)
ctx = _make_context(frame, config, j, position, symbol_spec)
before = position
action = strategy.manage_position(ctx, position)
position, realized = apply_position_action(before, action, last_close, entry_cost, exit_cost)
if realized is not None:
_record_trade(
trades, equity_pct, before, entry_bar, j,
realized["exit"], realized["reason"],
realized["pnl_pct"], realized["r_multiple"],
close_volume=realized.get("close_volume"),
)
continue
if position is None and j < n_bars - 1:
# entry evaluation at bar j (close); trades only act from bar j+1 on
ctx = _make_context(frame, config, j, None, symbol_spec)
signal = strategy.signal(ctx)
if signal is None:
continue
entry = last_close
direction = 1 if signal.side.upper() in ("BUY", "LONG", "BUY_LIMIT") else -1
sl = signal.stop_loss
tp = signal.take_profit
if not sl or not tp or sl <= 0 or tp <= 0:
continue # levels missing (e.g. ATR warmup) — treat as no trade
volume = float(signal.volume) if signal.volume and signal.volume > 0 else 1.0
position = _sim_position(direction, entry, sl, tp, volume, signal.reason)
entry_bar = j
# timeout: any position still open at the end closes at the last bar's close
if position is not None:
_record_trade(
trades, equity_pct, position, entry_bar, n_bars - 1, last_close, "timeout",
_pnl_pct(position, last_close, entry_cost, exit_cost),
_r_multiple(position, last_close),
)
if not trades:
return {"method": "strategy_backtest", "trades": [], "stats": {"n_trades": 0}}
pnls = [t["pnl_pct"] for t in trades]
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p <= 0]
gross_win = sum(wins)
gross_loss = abs(sum(losses))
peak = -math.inf
max_dd = 0.0
for eq in equity_pct:
peak = max(peak, eq)
max_dd = max(max_dd, peak - eq)
stats: dict[str, Any] = {
"n_trades": len(trades),
"win_rate": round(len(wins) / len(trades), 4),
"avg_r": round(sum(t["r_multiple"] for t in trades) / len(trades), 4),
"total_return_pct": round(equity_pct[-1], 4),
"max_drawdown_pct": round(max_dd, 4),
"profit_factor": round(gross_win / gross_loss, 4) if gross_loss > 0 else None,
"avg_bars_held": round(sum(t["bars_held"] for t in trades) / len(trades), 2),
"cost_model": {
"spread_bps": config.spread_bps,
"commission_bps_per_side": config.commission_bps_per_side,
"slippage_bps": config.slippage_bps,
},
}
return {"method": "strategy_backtest", "trades": trades, "stats": stats, "equity_pct": [round(e, 4) for e in equity_pct]}
def backtest_registered(
spec: str,
frame: pd.DataFrame,
config: ResearchConfig,
*,
params: Optional[dict[str, Any]] = None,
symbol_spec: Optional[dict] = None,
) -> dict[str, Any]:
"""Backtest a registry-resolved strategy by name or module path.
Convenience for the research CLI and for scripted sweeps: resolve
``"ma_cross"`` / ``"trading.strategies.ma_cross"`` / any module path
through the same loader the live shell uses, then run
:func:`backtest_strategy`.
"""
from trading.strategies import load
strategy = load(spec, params or {})
return backtest_strategy(strategy, frame, config, symbol_spec=symbol_spec)