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>
This commit is contained in:
2026-10-25 20:20:49 +08:00
co-authored by Claude Code
parent 42627f15af
commit 1bcfe6e374
7 changed files with 934 additions and 5 deletions
+10
View File
@@ -0,0 +1,10 @@
"""Run the offline research pipeline: ``py -3.14 -m research``."""
from __future__ import annotations
import sys
from .main import main
if __name__ == "__main__":
sys.exit(main())
+335
View File
@@ -9,6 +9,7 @@ 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
@@ -108,3 +109,337 @@ def local_walk_forward(frame: pd.DataFrame, config: ResearchConfig) -> dict[str,
}
)
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)
+1
View File
@@ -54,6 +54,7 @@ class ResearchConfig:
# --- model ----------------------------------------------------------------
model_dir: Path = Path(os.environ.get("RESEARCH_MODEL_DIR", "research/models"))
model_kind: str = os.environ.get("RESEARCH_MODEL", "logistic") # logistic|rf|gboost|lightgbm
train_ratio: float = _env_float("RESEARCH_TRAIN_RATIO", 0.8) # chronological split
# --- reporting --------------------------------------------------------------
report_dir: Path = Path(os.environ.get("RESEARCH_REPORT_DIR", "research/reports"))
+4 -2
View File
@@ -50,8 +50,10 @@ def _rsi(values: np.ndarray, period: int = 14) -> np.ndarray:
for i in range(period, len(delta)):
avg_gain[i] = (avg_gain[i - 1] * (period - 1) + gain[i]) / period
avg_loss[i] = (avg_loss[i - 1] * (period - 1) + loss[i]) / period
rs = avg_gain / np.where(avg_loss == 0, np.nan, avg_loss)
out[period:] = 100 - 100 / (1 + rs[period - 1 :])
with np.errstate(divide="ignore", invalid="ignore"):
rsi_series = 100 - 100 / (1 + avg_gain / avg_loss)
rsi_series[np.isnan(rsi_series)] = 100.0 # no losing bars -> 100, not NaN
out[period:] = rsi_series[period - 1 :]
return out
+190
View File
@@ -0,0 +1,190 @@
"""End-to-end offline research CLI.
Ties the research pipeline stages into one reproducible run: fetch candles
from the broker feed → build the labeled dataset → summarize walk-forward
folds → hold out a chronological OOS tail → train a model ("logistic" default)
→ evaluate vs. the always-on baseline → write a markdown report → save the
wrapper to ``research/models/latest.joblib`` (the default ``model_path`` of
``trading/strategies/model_threshold.py``).
Research never touches orders: this module only reads data and writes model /
report artifacts. The live shell maps the model's P(+1) inside ``signal(ctx)``.
"""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
from typing import Optional, Sequence
import numpy as np
from .backtest import local_walk_forward
from .config import ResearchConfig
from .data_pipeline import fetch_and_store, load_dataset
from .evaluate import evaluate, write_report
from .feature_engineering import make_dataset
from .model_trainer import ModelTrainerError, train
def _parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="mt-data-research",
description=__doc__.splitlines()[0],
)
parser.add_argument("--symbol", default=None, help="instrument (default: first of RESEARCH_SYMBOLS)")
parser.add_argument("--timeframe", default=None, help="MT5 timeframe (default: RESEARCH_TIMEFRAME)")
parser.add_argument("--limit", type=int, default=None, help="bars to fetch (default: RESEARCH_LOOKBACK_BARS)")
parser.add_argument("--kind", default=None, choices=("logistic", "rf", "gboost", "lightgbm"), help="model kind")
parser.add_argument("--seed", type=int, default=42, help="random seed for reproducibility")
parser.add_argument("--model-name", default="latest", help="output model basename (default: latest)")
parser.add_argument(
"--backtest", action="store_true",
help="after training, run an offline backtest of a trading strategy on the fetched frame",
)
parser.add_argument(
"--strategy", default="ma_cross",
help="strategy name or module path (trading.strategies registry) to backtest",
)
parser.add_argument(
"--strategy-param", action="append", default=[],
help="strategy parameter as key=value (repeatable)",
)
args = parser.parse_args(argv)
return args
def _parse_param_pairs(pairs: Sequence[str]) -> dict[str, str]:
"""Parse ``key=value`` strategy parameters (mirrors trading.main)."""
out: dict[str, str] = {}
for pair in pairs or []:
key, sep, value = pair.partition("=")
if not sep or not key.strip():
raise SystemExit(f"--strategy-param {pair!r} must be key=value")
out[key.strip()] = value
return out
# --- stage steps ----------------------------------------------------------------
def run_pipeline(config: ResearchConfig, args: argparse.Namespace) -> int:
symbol = args.symbol or config.symbols[0]
timeframe = args.timeframe or config.timeframe
kind = args.kind or config.model_kind
model_path = config.model_dir / f"{args.model_name}.joblib"
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
# 1. fetch + store (durable, reloadable window)
dataset = fetch_and_store(config, symbol, timeframe=timeframe, limit=args.limit)
print(f"[1/6] fetched {dataset.rows} {symbol} {timeframe} bars -> {dataset.path.name}")
frame = load_dataset(config, dataset)
if "close" not in frame or len(frame) < config.wf_min_train_bars:
print(
f"need >= {config.wf_min_train_bars} bars with a close column for research; "
f"got {len(frame)}. Increase --limit."
)
return 2
# 2. labeled dataset
X, y = make_dataset(frame, config)
print(f"[2/6] dataset: {len(X)} labeled samples, {len(X.columns)} features, "
f"positive rate {float((y == 1).mean()):.4f}")
# 3. walk-forward fold summary (chronological, reports label stability)
wf = local_walk_forward(frame, config)
fold_hit_rates = ", ".join(
f"fold{i}={fold['hit_rate']:.3f}" for i, fold in enumerate(wf["folds"])
)
print(f"[3/6] walk-forward: {wf['n_splits']} folds | {fold_hit_rates}")
# 4. chronological train / OOS split
split = int(len(X) * config.train_ratio)
if split < 50 or len(X) - split < 50:
print(f"split {split}/{len(X) - split} leaves a side with < 50 samples; "
f"fetch more bars or raise RESEARCH_TRAIN_RATIO")
return 2
X_train, y_train = X.iloc[:split], y.iloc[:split]
X_test, y_test = X.iloc[split:], y.iloc[split:]
print(f"[4/6] chronological split: {len(X_train)} train / {len(X_test)} oos")
# 5. train + evaluate vs. always-on baseline
try:
wrapper = train(
X_train, y_train, kind=kind, symbol=symbol, timeframe=timeframe,
)
except ModelTrainerError as exc:
print(f"training failed: {exc}")
return 2
print(f"[5/6] {kind} trained; training hit-rate baseline {wrapper.baseline:.4f}")
oos_proba = wrapper.predict_proba_positive(wrapper.align_features(X_test))
model_metrics = evaluate(y_test.to_numpy(), oos_proba, threshold=0.5)
baseline_metrics = evaluate(
y_test.to_numpy(), np.ones(len(y_test)), threshold=0.5 # always-positive
)
fold_rates = ", ".join(f"{fold['hit_rate']:.3f}" for fold in wf["folds"])
extra = (
f"- model: {kind}, training positive rate {wrapper.baseline:.4f}\n"
f"- walk-forward hit rates: {fold_rates}\n"
f"- split: {len(X_train)} train / {len(X_test)} oos (chronological)\n"
f"- features: {', '.join(X.columns)}"
)
report_path = config.report_dir / f"{symbol}_{timeframe}_{stamp}.md"
write_report(
report_path,
model_metrics,
baseline_metrics,
symbol=symbol,
timeframe=timeframe,
extra=extra,
)
print(f"[6/6] oos auc {model_metrics.get('auc_roc', float('nan')):.4f} vs "
f"baseline {baseline_metrics.get('positive_rate', float('nan')):.4f} | "
f"report -> {report_path.name}")
# 6. save wrapper where model_threshold expects it by default
wrapper.save(model_path)
print(f"saved {kind} model -> {model_path} (baseline {wrapper.baseline:.4f})")
# 7. optional: offline backtest of a trading strategy on the same frame
if args.backtest:
from .backtest import backtest_registered
params = _parse_param_pairs(args.strategy_param)
result = backtest_registered(args.strategy, frame, config, params=params)
stats = result["stats"]
print(f"[backtest] {args.strategy}: {stats.get('n_trades', 0)} trades, "
f"win {stats.get('win_rate')}, ret {stats.get('total_return_pct')}%, "
f"PF {stats.get('profit_factor')}, maxDD {stats.get('max_drawdown_pct')}%")
bt_path = config.report_dir / f"{symbol}_{timeframe}_{args.model_name}_backtest.md"
bt_path.write_text(
f"# Backtest: {args.strategy} on {symbol} {timeframe}\n\n"
f"- model: {kind} ({args.model_name})\n"
f"- trades: {stats.get('n_trades')}\n"
f"- win rate: {stats.get('win_rate')}\n"
f"- avg R: {stats.get('avg_r')}\n"
f"- total return %: {stats.get('total_return_pct')}\n"
f"- max drawdown %: {stats.get('max_drawdown_pct')}\n"
f"- profit factor: {stats.get('profit_factor')}\n"
f"- cost model: {stats.get('cost_model')}\n",
encoding="utf-8",
)
print(f"saved backtest report -> {bt_path.name}")
return 0
def main(argv: Optional[Sequence[str]] = None) -> int:
args = _parse_args(argv)
config = ResearchConfig()
try:
return run_pipeline(config, args)
except KeyboardInterrupt:
print("interrupted")
return 130
if __name__ == "__main__":
raise SystemExit(main())
+41 -3
View File
@@ -62,13 +62,30 @@ def _make_estimator(kind: str) -> Any:
class ModelWrapper:
"""Trained model + metadata. Pickled with joblib (safe for local research)."""
"""Trained model + metadata. Pickled with joblib (safe for local research).
def __init__(self, estimator: Any, kind: str, symbol: str, timeframe: str):
``feature_columns`` records the exact training-time column order so the
trading shell can reindex live features to the same layout before calling
``predict_proba_positive``.
"""
def __init__(
self,
estimator: Any,
kind: str,
symbol: str,
timeframe: str,
feature_columns: list[str] | None = None,
baseline: float = 0.0,
):
self.estimator = estimator
self.kind = kind
self.symbol = symbol
self.timeframe = timeframe
self.feature_columns = list(feature_columns or [])
# training positive rate (hit-rate baseline) persisted for strategies
# that gate on beating the no-trade baseline (model_threshold.edge)
self.baseline = float(baseline)
# --- inference ----------------------------------------------------------
def predict_proba_positive(self, X: pd.DataFrame) -> np.ndarray:
@@ -80,6 +97,16 @@ class ModelWrapper:
def predict(self, X: pd.DataFrame) -> np.ndarray:
return self.estimator.predict(X)
def align_features(self, X: pd.DataFrame) -> pd.DataFrame:
"""Reindex ``X`` to the training-time feature layout.
Live features must arrive with the same column order used at fit time;
missing columns are filled with NaN and extras are dropped.
"""
if not self.feature_columns:
return X
return X.reindex(columns=self.feature_columns)
# --- persistence ----------------------------------------------------------
def save(self, path: Path) -> Path:
path = Path(path)
@@ -90,6 +117,8 @@ class ModelWrapper:
"symbol": self.symbol,
"timeframe": self.timeframe,
"estimator": self.estimator,
"feature_columns": self.feature_columns,
"baseline": self.baseline,
},
path,
)
@@ -103,6 +132,8 @@ class ModelWrapper:
kind=data["kind"],
symbol=data["symbol"],
timeframe=data["timeframe"],
feature_columns=data.get("feature_columns") or None,
baseline=data.get("baseline", 0.0),
)
@@ -126,4 +157,11 @@ def train(
raise ModelTrainerError("target has a single class; nothing to learn")
estimator = _make_estimator(kind)
estimator.fit(X, y)
return ModelWrapper(estimator=estimator, kind=kind, symbol=symbol, timeframe=timeframe)
return ModelWrapper(
estimator=estimator,
kind=kind,
symbol=symbol,
timeframe=timeframe,
feature_columns=list(X.columns),
baseline=float((y == 1).mean()),
)
+353
View File
@@ -0,0 +1,353 @@
"""Regression tests for the strategy layer: registry, params, signals, backtest.
These cover the abstraction that keeps strategy research offline-friendly: a
*trading* strategy is just a ``Strategy`` subclass with a declarative
``params_spec`` and a single ``signal(ctx)``, and the same object backs both
the live shell and the offline ``backtest_strategy`` harness. No MT5/broker
connection is needed here.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from research.backtest import BacktestError, backtest_strategy
from research.config import ResearchConfig
@pytest.fixture()
def frame() -> pd.DataFrame:
rng = np.random.default_rng(7)
n = 800
close = np.cumsum(np.r_[np.zeros(40), rng.normal(0.00015, 0.0004, n - 40)]) + 1.0800
return pd.DataFrame(
{
"open": close - 0.0002,
"high": close + 0.0006,
"low": close - 0.0006,
"close": close,
"tick_volume": rng.integers(10, 200, n),
}
)
@pytest.fixture()
def context(frame: pd.DataFrame):
from trading.types import SignalContext
return SignalContext(
symbol="EURUSD",
timeframe="H1",
candles=[
{"high": float(r.high), "low": float(r.low), "close": float(r.close)}
for _, r in frame.iterrows()
],
quote={},
account={},
positions=[],
)
def _config() -> ResearchConfig:
return ResearchConfig(
spread_bps=1.0, commission_bps_per_side=0.0, slippage_bps=1.0
)
# --- registry ------------------------------------------------------------------
def test_registry_lists_both_strategies() -> None:
from trading.strategies import list_strategies
names = {row["name"] for row in list_strategies()}
assert names == {"ma_cross", "model_threshold"}
def test_registry_load_by_name_alias_and_module_path() -> None:
from trading.strategies import load
from trading.strategies.base import Strategy
for spec in ("ma_cross", "macross", "trading.strategies.ma_cross"):
strategy = load(spec)
assert isinstance(strategy, Strategy)
assert strategy.name == "ma_cross"
def test_registry_unknown_spec_raises() -> None:
from trading.strategies import load
from trading.strategies.registry import StrategyLoadError
with pytest.raises(StrategyLoadError):
load("zzz_no_such_strategy")
# --- param validation ------------------------------------------------------------
def test_param_merge_over_defaults() -> None:
from trading.strategies import load
strategy = load("ma_cross", {"fast": 10, "slow": 40})
assert strategy.params["fast"] == 10
assert strategy.params["slow"] == 40
assert strategy.params["atr_mult_sl"] == 2.0 # untouched default
def test_param_below_minimum_rejected() -> None:
from trading.strategies import load
with pytest.raises(ValueError, match="fast"):
load("ma_cross", {"fast": 1})
def test_unknown_param_rejected() -> None:
from trading.strategies import load
with pytest.raises(ValueError, match="no parameter"):
load("ma_cross", {"bogus": 1})
# --- strategy 1: ma_cross signal --------------------------------------------------
def test_ma_cross_signal_runs_on_synthetic_frame(context) -> None:
from trading.strategies import load
strategy = load("ma_cross")
signal = strategy.signal(context)
# either it is flat (no cross at this bar) or it returns a sane TradeSignal
assert signal is None or signal.stop_loss > 0
# --- offline backtest harness (reuses the same signal(ctx)) -----------------------
def test_backtest_strategy_returns_stats_and_trades(frame) -> None:
from trading.strategies import load
result = backtest_strategy(load("ma_cross"), frame, _config())
assert result["method"] == "strategy_backtest"
assert isinstance(result["trades"], list)
stats = result["stats"]
assert set(["n_trades", "win_rate", "avg_r", "total_return_pct"]).issubset(stats)
for trade in result["trades"]:
assert {"side", "entry", "exit", "reason", "pnl_pct", "r_multiple"} <= set(trade)
assert trade["pnl_pct"] is not None
def test_backtest_rejects_too_few_bars() -> None:
from trading.strategies import load
tiny = pd.DataFrame({"close": [1.0, 1.01], "open": 1.0, "high": 1.02, "low": 0.99})
with pytest.raises(BacktestError):
backtest_strategy(load("ma_cross"), tiny, _config())
# --- strategy 2: model_threshold with a trained wrapper ---------------------------
def test_model_threshold_with_trained_wrapper(frame, context, tmp_path) -> None:
from research.feature_engineering import build_features, build_labels
from research.model_trainer import train
from trading.strategies import load
feats = build_features(frame).iloc[40:]
labels = build_labels(
frame.iloc[40:].reset_index(drop=True), _config(), direction="long"
)
y = pd.Series(labels.to_numpy(), index=feats.index).dropna()
X = feats.reindex(y.index)
assert len(X) >= 50 and y.nunique() == 2
wrapper = train(X, y, kind="logistic", symbol="EURUSD", timeframe="H1")
# train() drops NaN-feature rows before fitting, so the persisted baseline
# is the positive rate over the clean training sample, not the raw labels.
clean = (X.notna().all(axis=1)) & y.notna()
assert wrapper.baseline == pytest.approx(float((y[clean] == 1).mean()))
model_file = tmp_path / "eurusd_h1.joblib"
wrapper.save(model_file)
from research.model_trainer import ModelWrapper
reloaded = ModelWrapper.load(model_file)
assert reloaded.baseline == pytest.approx(wrapper.baseline)
strategy = load("model_threshold", {"model_path": str(model_file)})
signal = strategy.signal(context)
assert strategy._wrapper is not None # lazy load cached on first signal
assert signal is None or signal.stop_loss > 0
def test_model_threshold_fires_buy_on_strong_uptrend(tmp_path) -> None:
"""A bar inside a persistent uptrend must cross the gate and emit BUY.
Trains on a synthetic series whose tail drifts up strongly (forward labels
hit TP first), then feeds that same tail through ``signal(ctx)`` at default
params and asserts the full model->features->gate->SL/TP path fires.
"""
from research.feature_engineering import build_features, build_labels
from research.model_trainer import train
from trading.strategies import load
from trading.types import SignalContext
rng = np.random.default_rng(11)
n = 800
drift = 0.0000
close = np.cumsum(np.r_[np.zeros(40), rng.normal(drift, 0.0004, n - 700), rng.normal(0.0010, 0.0004, 660)]) + 1.0800
up = pd.DataFrame(
{
"open": close - 0.0002,
"high": close + 0.0006,
"low": close - 0.0006,
"close": close,
"tick_volume": rng.integers(10, 200, n),
}
)
feats = build_features(up).iloc[40:]
labels = build_labels(
up.iloc[40:].reset_index(drop=True), _config(), direction="long"
)
y = pd.Series(labels.to_numpy(), index=feats.index).dropna()
X = feats.reindex(y.index)
assert len(X) >= 50 and y.nunique() == 2
model_file = tmp_path / "uptrend.joblib"
train(X, y, kind="logistic", symbol="EURUSD", timeframe="H1").save(model_file)
tail = up.tail(120).reset_index(drop=True) # same shape the shell feeds signal()
ctx = SignalContext(
symbol="EURUSD",
timeframe="H1",
candles=[
{"high": float(r.high), "low": float(r.low), "close": float(r.close)}
for _, r in tail.iterrows()
],
quote={},
account={},
positions=[],
)
strategy = load("model_threshold", {"model_path": str(model_file)})
signal = strategy.signal(ctx)
assert signal is not None, "gate must fire inside a persistent uptrend"
assert signal.side == "BUY"
assert 0 < signal.stop_loss < signal.take_profit
def test_wrapper_feature_alignment(frame, tmp_path) -> None:
"""align_features reshapes live rows to the training-time column layout."""
from research.feature_engineering import build_features
from research.model_trainer import ModelWrapper, train
feats = build_features(frame).iloc[40:]
columns = list(feats.columns)
shuffled = list(reversed(columns))
live = feats.tail(1).reindex(columns=shuffled)
X_fit = feats.iloc[:-1].reset_index(drop=True)
y_fit = pd.Series(np.arange(len(X_fit), dtype=int) % 2)
wrapper = train(
X_fit, y_fit, kind="logistic", symbol="EURUSD", timeframe="H1",
)
aligned = wrapper.align_features(live)
assert list(aligned.columns) == columns
assert wrapper.predict_proba_positive(aligned).shape == (1,)
model_file = tmp_path / "alignment.joblib"
wrapper.save(model_file)
reloaded = ModelWrapper.load(model_file)
assert reloaded.feature_columns == columns
# --- legacy compat adapter --------------------------------------------------------
def test_function_strategy_adapter_wraps_bare_signal() -> None:
from trading.strategies.base import FunctionStrategy
def legacy_signal(ctx):
return None
strategy = FunctionStrategy(legacy_signal, name="legacy")
assert strategy.signal(None) is None
assert strategy.name == "legacy"
# --- CLI plumbing -----------------------------------------------------------------
def test_cli_strategy_params_parse() -> None:
import argparse
from trading.main import _cli_strategy_params
args = argparse.Namespace(strategy_param=["fast=10", "slow=40"])
assert _cli_strategy_params(args) == {"fast": "10", "slow": "40"}
bad = argparse.Namespace(strategy_param=["nokey"])
with pytest.raises(SystemExit):
_cli_strategy_params(bad)
def test_signal_loop_load_strategy_falls_back_never_dies() -> None:
from trading.signal_loop import NullStrategy, load_strategy
assert isinstance(load_strategy("does.not.exist"), NullStrategy)
assert isinstance(load_strategy("zzz_no_such_strategy"), NullStrategy)
# --- position management: one hook shared by live loop and backtester -------------
def test_strategy_default_manage_position_holds() -> None:
from trading.strategies import load
assert load("ma_cross").manage_position(None, {"type": "BUY"}) is None
def _managing_strategy(*, close_volume=None):
from trading.strategies.base import Strategy
from trading.types import PositionAction, TradeSignal
class ManagingStrategy(Strategy):
def signal(self, ctx):
if len(ctx.closes()) < 3:
return None
return TradeSignal(side="BUY", stop_loss=0.9000, take_profit=9.0, reason="enter")
def manage_position(self, ctx, position):
if close_volume is not None:
return PositionAction(
kind="partial_close", close_volume=close_volume, reason="scale-out"
)
px = ctx.closes()[-1]
return PositionAction(kind="modify", stop_loss=px - 0.0001, reason="tight")
return ManagingStrategy()
def test_backtest_applies_dynamic_trailing() -> None:
"""manage_position() must move the effective stop: SL exits settle at the
trailed level, far above the entry-time stop of 0.90."""
closes = [1.1000] + [1.1000 + 0.0030 * (k + 1) for k in range(25)] # rise to 1.175
closes += [c - 0.0030 for c in closes[-10:]] # then fall back
updown = pd.DataFrame({
"open": closes,
"high": [c + 0.0002 for c in closes],
"low": [c - 0.0002 for c in closes],
"close": closes,
})
result = backtest_strategy(_managing_strategy(), updown, _config())
sl_exits = [t for t in result["trades"] if t["reason"] == "sl"]
assert sl_exits, "the fall must be stopped by the trailed SL"
assert all(t["exit"] > 1.10 for t in sl_exits)
def test_backtest_partial_close_is_recorded(frame) -> None:
result = backtest_strategy(_managing_strategy(close_volume=0.5), frame, _config())
partial = [t for t in result["trades"] if t["reason"] == "partial_close"]
assert partial, "every scale-out must be recorded as its own closed slice"
assert all("close_volume" in t and t["close_volume"] > 0 for t in partial)