diff --git a/AGENT.md b/AGENT.md index d8631fd6..c89d489c 100644 --- a/AGENT.md +++ b/AGENT.md @@ -6,31 +6,36 @@ Event-driven backtesting engine with cross-framework parity validation. | Directory | Purpose | |-----------|---------| -| src/ml4t/backtest/ | Package root (~14.3k lines, 40 modules) | -| tests/ | 1,083 tests | +| src/ml4t/backtest/ | Package root (~13.8k lines, 40 modules) | +| tests/ | 1,367 tests | | validation/ | Cross-framework parity (VBT, Backtrader, Zipline, LEAN) | ## Key Modules | Module | Lines | Purpose | |--------|-------|---------| -| engine.py | 491 | Event loop orchestration | -| broker.py | 1,438 | Order execution, positions | -| config.py | 937 | BacktestConfig (40+ knobs) | -| result.py | 1,025 | BacktestResult container | -| types.py | 578 | Order, Position, Fill, Trade | -| profiles.py | 375 | 6 core + 4 strict profiles | +| broker.py | 1,463 | Order execution, positions | +| result.py | 1,047 | BacktestResult container | +| config.py | 848 | BacktestConfig (40+ knobs) | | calendar.py | 786 | Trading calendar, sessions | +| types.py | 625 | Order, Position, Fill, Trade, cost decomposition | +| engine.py | 419 | Event loop orchestration | +| profiles.py | 384 | 6 core + 4 strict profiles | +| export.py | 312 | Result export (Parquet, YAML, JSON) | +| sessions.py | 279 | Session handling | +| models.py | 248 | Commission/slippage models | +| datafeed.py | 224 | Price/signal iteration | +| strategy.py | 28 | Strategy base class | ## Subpackages | Directory | Lines | Purpose | |-----------|-------|---------| -| core/ | 1,365 | Order book, execution engine, fill engine, risk engine | -| accounting/ | 1,180 | Cash/margin policies, gatekeeper | -| analytics/ | 917 | Metrics, equity, trades, diagnostic bridge | -| execution/ | 1,328 | Fill executor, rebalancer, impact | -| risk/ | 1,876 | Position rules, portfolio limits | +| execution/ | 1,351 | Fill executor, rebalancer, impact | +| core/ | 1,314 | Order book, execution engine, fill engine, risk engine | +| accounting/ | 1,076 | Cash/margin policies, gatekeeper | +| analytics/ | 970 | Metrics, equity, trades, cost decomposition, diagnostic bridge | +| risk/ | 1,906 | Position rules, portfolio limits | | strategies/ | 417 | Strategy templates | ## Entry Point diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 23856f77..b97540d2 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -225,7 +225,7 @@ and 1500-bar stress tests across 9 market regimes. 3. Focus on relative performance, not absolute returns ### For Production Readiness -1. Validate with `Mode.REALISTIC` preset +1. Validate with `BacktestConfig.from_preset("realistic")` 2. Run with historical crisis periods (2008, 2020, 2022) 3. Test with varied slippage and commission assumptions 4. Paper trade before live deployment diff --git a/docs/user-guide/results.md b/docs/user-guide/results.md index 6f781462..fc9123fb 100644 --- a/docs/user-guide/results.md +++ b/docs/user-guide/results.md @@ -27,9 +27,24 @@ print(f"Expectancy: ${m['expectancy']:.2f}") print(f"Avg Win: ${m['avg_win']:.2f}") print(f"Avg Loss: ${m['avg_loss']:.2f}") +# Per-trade returns (percentage-based, direction-aware) +print(f"Avg Trade: {m['avg_trade']:.2%}") +print(f"Avg Win: {m['avg_win']:.2%}") +print(f"Avg Loss: {m['avg_loss']:.2%}") +print(f"Best Trade: {m['largest_win']:.2%}") +print(f"Worst Trade: {m['largest_loss']:.2%}") +print(f"Payoff Ratio: {m['payoff_ratio']:.2f}") + # Costs print(f"Commission: ${m['total_commission']:.2f}") print(f"Slippage: ${m['total_slippage']:.2f}") +print(f"Total Costs: ${m['total_costs']:.2f}") +print(f"Avg Cost Drag: {m['avg_cost_drag']:.4%}") + +# Gross vs Net +print(f"Gross P&L: ${m['total_gross_pnl']:.2f}") +print(f"Gross PF: {m['gross_profit_factor']:.2f}") +print(f"Net PF: {m['profit_factor']:.2f}") ``` ### Available Metrics @@ -51,17 +66,77 @@ print(f"Slippage: ${m['total_slippage']:.2f}") | `winning_trades` | Number of winning trades | | `losing_trades` | Number of losing trades | | `win_rate` | Win rate (0 to 1) | -| `profit_factor` | Gross profits / gross losses | -| `expectancy` | Average $ per trade | -| `avg_trade` | Average trade P&L | -| `avg_win` | Average winning trade | -| `avg_loss` | Average losing trade | -| `largest_win` | Largest single win | -| `largest_loss` | Largest single loss | +| `profit_factor` | Net profit factor (winning P&L / losing P&L) | +| `expectancy` | Expected return per trade (decimal) | +| `avg_trade` | Average trade return (decimal) | +| `avg_win` | Average winning trade return (decimal) | +| `avg_loss` | Average losing trade return (decimal, negative) | +| `largest_win` | Best single trade return (decimal) | +| `largest_loss` | Worst single trade return (decimal, negative) | +| `payoff_ratio` | avg_win / \|avg_loss\| (size-normalized reward-to-risk) | | `total_commission` | Total commission paid | -| `total_slippage` | Total slippage cost | +| `total_slippage` | Total slippage cost (entry + exit) | +| `total_gross_pnl` | Total P&L from price moves only (before costs) | +| `total_costs` | Total transaction costs (commission + slippage) | +| `avg_cost_drag` | Average cost as fraction of trade notional | +| `gross_profit_factor` | Profit factor from raw price moves (isolates edge from costs) | | `skipped_bars` | Bars skipped by calendar filter | +### Cost Decomposition + +Every trade carries a full cost breakdown, letting you separate strategy edge from execution costs: + +```python +for trade in result.trades: + print(f"{trade.symbol}: gross={trade.gross_pnl:+.2f}, " + f"net={trade.pnl:+.2f}, drag={trade.cost_drag:.4%}") +``` + +| Property | Description | +|----------|-------------| +| `trade.gross_pnl` | Price-move P&L: `(exit - entry) * qty * multiplier` | +| `trade.pnl` | Net P&L after all costs | +| `trade.gross_return` | Direction-aware gross return (same as `pnl_percent`) | +| `trade.net_return` | Direction-aware net return including fees | +| `trade.total_slippage_cost` | Entry + exit slippage in dollars | +| `trade.cost_drag` | Total cost as fraction of notional | +| `trade.fees` | Total commission (entry + exit) | +| `trade.entry_slippage` | Per-unit slippage on entry | +| `trade.slippage` | Per-unit slippage on exit | +| `trade.multiplier` | Contract multiplier (1.0 for equities, 50.0 for ES futures) | + +`pnl_percent` is direction-aware: positive means profitable for both long and short trades. + +## Trade Analyzer + +`result.trade_analyzer` provides aggregate statistics on closed trades: + +```python +ta = result.trade_analyzer + +# Standard metrics +print(f"Win Rate: {ta.win_rate:.1%}") +print(f"Profit Factor: {ta.profit_factor:.2f}") +print(f"Avg MFE: {ta.avg_mfe:.4f}") +print(f"MFE Capture: {ta.mfe_capture_ratio:.2f}") + +# Cost decomposition +print(f"Gross P&L: ${ta.total_gross_pnl:.2f}") +print(f"Net Profit: ${ta.net_profit:.2f}") +print(f"Total Costs: ${ta.total_costs:.2f}") +print(f"Avg Cost Drag: {ta.avg_cost_drag:.4%}") +print(f"Gross Profit Factor:{ta.gross_profit_factor:.2f}") + +# Filter by side +long_stats = ta.by_side("long") +short_stats = ta.by_side("short") +print(f"Long win rate: {long_stats.win_rate:.1%}") +print(f"Short win rate: {short_stats.win_rate:.1%}") + +# Export all stats +stats_dict = ta.to_dict() +``` + ## Trades DataFrame ```python @@ -78,15 +153,21 @@ Returns a Polars DataFrame with columns: | `exit_time` | Datetime | Exit timestamp | | `entry_price` | Float | Entry fill price | | `exit_price` | Float | Exit fill price | -| `quantity` | Float | Position size | +| `quantity` | Float | Position size (negative for shorts) | | `direction` | String | "long" or "short" | -| `pnl` | Float | Dollar P&L | -| `pnl_percent` | Float | Percentage return | +| `pnl` | Float | Net P&L after costs | +| `pnl_percent` | Float | Direction-aware percentage return | | `bars_held` | Int | Holding period | | `fees` | Float | Total commission | -| `slippage` | Float | Total slippage | +| `slippage` | Float | Exit slippage | | `mfe` | Float | Maximum favorable excursion | | `mae` | Float | Maximum adverse excursion | +| `entry_slippage` | Float | Per-unit slippage on entry | +| `multiplier` | Float | Contract multiplier (futures) | +| `gross_pnl` | Float | Price-move P&L before fees | +| `net_return` | Float | Direction-aware net return including fees | +| `total_slippage_cost` | Float | Entry + exit slippage in dollars | +| `cost_drag` | Float | Total cost as fraction of notional | | `exit_reason` | String | Why the trade exited | | `status` | String | "closed" or "open" | @@ -110,6 +191,29 @@ Returns a Polars DataFrame with columns: | `drawdown` | Float | Current drawdown from HWM | | `high_water_mark` | Float | Running maximum equity | +## Fills + +Access every individual order fill: + +```python +for fill in result.fills: + print(f"{fill.asset}: {fill.quantity} @ ${fill.price:.2f}") + print(f" Type: {fill.order_type}") + print(f" Commission: ${fill.commission:.2f}") + print(f" Slippage: ${fill.slippage:.4f}") +``` + +Fill objects carry order-type metadata for audit: + +| Field | Description | +|-------|-------------| +| `fill.order_type` | `"market"`, `"limit"`, or `"stop"` | +| `fill.limit_price` | Limit price (for limit orders) | +| `fill.stop_price` | Stop price (for stop orders) | +| `fill.price` | Actual fill price | +| `fill.commission` | Commission charged | +| `fill.slippage` | Slippage applied | + ## Dictionary Output For backward compatibility: @@ -135,6 +239,47 @@ result = BacktestResult.from_parquet("./results/my_backtest") ## Integration with ml4t-diagnostic +### Portfolio Analysis (Recommended) + +The simplest way to bridge backtest results into ml4t-diagnostic is `to_portfolio_analysis()`: + +```python +from ml4t.backtest import Engine + +result = engine.run() + +# One-liner bridge to ml4t-diagnostic +analysis = result.to_portfolio_analysis(calendar="NYSE") + +# Now use PortfolioAnalysis methods +print(f"Sharpe: {analysis.sharpe_ratio():.2f}") +print(f"Max DD: {analysis.max_drawdown():.2%}") +monthly = analysis.compute_monthly_returns() +``` + +The method extracts daily returns via `to_daily_pnl()` and sets `periods_per_year` from the calendar (252 for NYSE, 365 for crypto, etc.). If no calendar is passed, it uses the config's calendar. + +```python +# Crypto backtest +analysis = result.to_portfolio_analysis(calendar="crypto") + +# With benchmark +analysis = result.to_portfolio_analysis( + calendar="NYSE", + benchmark=spy_returns, # numpy array or Polars Series +) + +# Gross vs net comparison +analysis_gross = results_gross.to_portfolio_analysis(calendar="crypto") +analysis_net = results_net.to_portfolio_analysis(calendar="crypto") +``` + +!!! note "Requires ml4t-diagnostic" + Install with `pip install ml4t-diagnostic`. The import is deferred so ml4t-backtest + works standalone without ml4t-diagnostic installed. + +### Trade Records + Convert trades to TradeRecord format for the diagnostic library: ```python @@ -146,17 +291,42 @@ from ml4t.backtest.analytics.bridge import to_trade_records records = to_trade_records(result.trades) ``` -## Fills +The bridge exports all cost decomposition fields (`gross_pnl`, `net_return`, `total_slippage_cost`, `cost_drag`) for diagnostic analysis. -Access every individual order fill: +### Full Tearsheet + +Pass all result data for the richest tearsheet (up to 24 sections): ```python -for fill in result.fills: - print(f"{fill.asset}: {fill.quantity} @ ${fill.price:.2f}") - print(f" Commission: ${fill.commission:.2f}") - print(f" Slippage: ${fill.slippage:.4f}") +from ml4t.diagnostic.visualization.backtest import generate_backtest_tearsheet + +html = generate_backtest_tearsheet( + trades=result.to_trades_dataframe(), + returns=analysis.returns, + equity_curve=result.to_equity_dataframe(), + metrics=result.metrics, + template="full", + title="My Strategy — Full Report", + output_path="tearsheet.html", +) ``` +#### Metrics Keys That Enable Tearsheet Sections + +The `metrics` dict controls which tearsheet sections render. Sections gracefully degrade when keys are missing. + +| Section | Required Metrics Keys | +|---------|----------------------| +| Executive Summary | `sharpe_ratio`, `max_drawdown`, `win_rate`, `profit_factor`, `n_trades`, `cagr`, `volatility`, `expectancy` | +| Cost Attribution | `gross_pnl`, `commission`, `slippage` | +| Statistical Validity (DSR) | `dsr_probability`, `dsr_significant`, `min_trl`, `current_trl`, `trl_sufficient` | +| RAS Adjustment | `ras_adjusted_ic`, `ras_significant`, `original_ic`, `rademacher_complexity` | +| Confidence Intervals | `sharpe_ratio`, `sharpe_ratio_lower_95`, `sharpe_ratio_upper_95` (similarly for other metrics) | +| Haircut Sharpe | `sharpe`, `n_periods` or `n_observations` | +| Expected Max Sharpe | `expected_max_sharpe` | + +Sections that depend only on `trades` (trade analysis, MFE/MAE, exit reasons) or `returns` (drawdown, monthly heatmap, rolling Sharpe) require no special metrics keys. + ## Config Preservation The config used for the backtest is preserved in the result: @@ -170,8 +340,8 @@ print(result.config.preset_name) The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book uses BacktestResult in every case study: -- **Ch16 / NB05** (`performance_reporting`) — comprehensive metrics extraction, equity curve visualization, trade analysis -- **Ch16 case studies** — all cases call `result.to_daily_returns(calendar="NYSE")` for integration with ml4t-diagnostic signal analysis +- **Ch16 / NB05** (`performance_reporting`) — `to_portfolio_analysis()`, MFE/MAE analysis, gross vs net comparison, full 24-section tearsheet +- **Ch16 case studies** — all cases save trade artifacts via `to_parquet()` and pass trades/metrics/equity to tearsheet generation - **Ch16 / NB06** (`sharpe_ratio_inference`) — statistical inference on backtest results ## Next Steps diff --git a/pyproject.toml b/pyproject.toml index 105c47d3..34b0d1d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,6 +170,7 @@ markers = [ "unit: marks unit tests", "private: requires commercial dependencies (vectorbtpro) - excluded by default", "requires_comparison: requires optional comparison frameworks (vectorbt, backtrader, zipline)", + "no_invariant_check: skip autouse accounting invariant check for this test", ] filterwarnings = [ "ignore::DeprecationWarning", @@ -212,7 +213,7 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"tests/*" = ["ARG001", "ARG002", "F841", "SIM102"] # Test patterns +"tests/*" = ["ARG001", "ARG002", "F841", "SIM102", "SIM108"] # Test patterns [tool.ty.environment] python-version = "3.11" diff --git a/src/ml4t/backtest/AGENT.md b/src/ml4t/backtest/AGENT.md index 21b0330f..05faf1f2 100644 --- a/src/ml4t/backtest/AGENT.md +++ b/src/ml4t/backtest/AGENT.md @@ -4,28 +4,28 @@ | File | Lines | Purpose | |------|-------|---------| -| engine.py | 491 | Event loop orchestration | -| broker.py | 1,438 | Order execution, positions, risk eval | -| config.py | 937 | BacktestConfig, 40+ behavioral knobs | -| result.py | 1,025 | BacktestResult container | -| types.py | 578 | Order, Position, Fill, Trade | -| profiles.py | 375 | 6 core + 4 strict framework profiles | +| broker.py | 1,463 | Order execution, positions, risk eval | +| result.py | 1,047 | BacktestResult container | +| config.py | 848 | BacktestConfig, 40+ behavioral knobs | | calendar.py | 786 | Trading calendar, overnight sessions | +| types.py | 625 | Order, Position, Fill, Trade, cost decomposition | +| engine.py | 419 | Event loop orchestration | +| profiles.py | 384 | 6 core + 4 strict framework profiles | +| export.py | 312 | Result export (Parquet, YAML, JSON) | +| sessions.py | 279 | Session handling | +| models.py | 248 | Commission/slippage models | | datafeed.py | 224 | Price/signal iteration | | strategy.py | 28 | Strategy base class | -| models.py | 245 | Commission/slippage models | -| sessions.py | 279 | Session handling | -| export.py | 312 | Result export (Parquet, YAML, JSON) | ## Subpackages | Directory | Lines | Purpose | |-----------|-------|---------| -| core/ | 1,365 | Order book, execution engine, fill engine, risk engine | -| accounting/ | 1,180 | Cash/margin/crypto policies, gatekeeper | -| analytics/ | 917 | Metrics, equity, trades, diagnostic bridge | -| execution/ | 1,328 | Fill executor, rebalancer, impact, limits | -| risk/ | 1,876 | Position rules (stop/trail/TP), portfolio limits | +| execution/ | 1,351 | Fill executor, rebalancer, impact, limits | +| core/ | 1,314 | Order book, execution engine, fill engine, risk engine | +| accounting/ | 1,076 | Unified account policy, gatekeeper | +| analytics/ | 970 | Metrics, equity, trades, cost decomposition, diagnostic bridge | +| risk/ | 1,906 | Position rules (stop/trail/TP), portfolio limits | | strategies/ | 417 | Strategy templates | ## Key diff --git a/src/ml4t/backtest/accounting/AGENT.md b/src/ml4t/backtest/accounting/AGENT.md index fa47fdd4..8bcd0823 100644 --- a/src/ml4t/backtest/accounting/AGENT.md +++ b/src/ml4t/backtest/accounting/AGENT.md @@ -1,4 +1,4 @@ -# accounting/ - 1,180 Lines +# accounting/ - 1,076 Lines Cash and margin account policies. @@ -6,10 +6,10 @@ Cash and margin account policies. | File | Lines | Purpose | |------|-------|---------| -| policy.py | 632 | Cash/margin/crypto account policies | -| gatekeeper.py | 291 | Order validation (entry cash check) | -| account.py | 257 | Account state tracking | +| policy.py | 624 | Unified account policy (cash, margin, crypto modes) | +| gatekeeper.py | 291 | Order validation (buying power check) | +| account.py | 161 | Account state tracking | ## Key -`CashAccountPolicy`, `MarginAccountPolicy`, `CryptoAccountPolicy`, `Gatekeeper` +`UnifiedAccountPolicy`, `AccountPolicy`, `Gatekeeper`, `AccountState` diff --git a/src/ml4t/backtest/analytics/AGENT.md b/src/ml4t/backtest/analytics/AGENT.md index 767eb5c0..b9d08f80 100644 --- a/src/ml4t/backtest/analytics/AGENT.md +++ b/src/ml4t/backtest/analytics/AGENT.md @@ -1,23 +1,33 @@ -# analytics/ - 917 Lines +# analytics/ - ~970 Lines -Performance metrics, trade analysis, and ml4t-diagnostic integration. +Performance metrics, trade analysis, cost decomposition, and ml4t-diagnostic integration. ## Modules | File | Lines | Purpose | |------|-------|---------| -| trades.py | 472 | Trade statistics (win rate, PnL, MFE/MAE) | +| trades.py | ~510 | Trade statistics (win rate, PnL, MFE/MAE, cost decomposition) | | metrics.py | 180 | Performance metrics (Sharpe, CAGR, drawdown) | -| bridge.py | 146 | ml4t-diagnostic integration bridge | +| bridge.py | ~155 | ml4t-diagnostic integration bridge | | equity.py | 119 | Equity curve calculation | ## Key Functions `calculate_metrics()`, `to_trade_records()`, `to_returns_series()` +## TradeAnalyzer Cost Decomposition (v0.1.0b2) + +`TradeAnalyzer` exposes aggregate cost decomposition metrics: +- `total_gross_pnl` - Price-move P&L before all costs +- `total_costs` - Total fees + slippage +- `avg_cost_drag` - Average cost as fraction of notional +- `gross_profit_factor` - Profit factor from raw price moves (isolates edge from costs) + ## ml4t-diagnostic Bridge `bridge.py` converts backtest Trade objects to diagnostic TradeRecord format: - `to_trade_record(trade)` / `to_trade_records(trades)` - Trade conversion - `to_returns_series(equity)` - Equity to returns for Sharpe analysis - `to_equity_dataframe(equity, timestamps)` - Equity with timestamps + +Bridge exports cost decomposition fields: `gross_pnl`, `net_return`, `total_slippage_cost`, `cost_drag` diff --git a/src/ml4t/backtest/analytics/bridge.py b/src/ml4t/backtest/analytics/bridge.py index 0c373a82..e45dc3b0 100644 --- a/src/ml4t/backtest/analytics/bridge.py +++ b/src/ml4t/backtest/analytics/bridge.py @@ -54,7 +54,14 @@ def to_trade_record(trade: Trade) -> dict[str, Any]: "status": trade.status, "mfe": trade.mfe, "mae": trade.mae, + "entry_slippage": trade.entry_slippage, + "multiplier": trade.multiplier, "metadata": trade.metadata, + # Computed cost decomposition fields + "gross_pnl": trade.gross_pnl, + "net_return": trade.net_return, + "total_slippage_cost": trade.total_slippage_cost, + "cost_drag": trade.cost_drag, # Diagnostic-specific computed fields "duration": trade.exit_time - trade.entry_time, # Legacy field (diagnostic still expects this) diff --git a/src/ml4t/backtest/analytics/equity.py b/src/ml4t/backtest/analytics/equity.py index 0b4f7f01..a49906dc 100644 --- a/src/ml4t/backtest/analytics/equity.py +++ b/src/ml4t/backtest/analytics/equity.py @@ -71,9 +71,27 @@ def total_return(self) -> float: @property def years(self) -> float: - """Duration in years based on trading days.""" + """Duration in years based on elapsed wall-clock time.""" + if len(self.timestamps) >= 2: + elapsed_seconds = (self.timestamps[-1] - self.timestamps[0]).total_seconds() + if elapsed_seconds > 0: + return elapsed_seconds / (365.25 * 24 * 60 * 60) return len(self.values) / TRADING_DAYS_PER_YEAR if self.values else 0.0 + @property + def periods_per_year(self) -> float: + """Infer annualization factor from observed bar frequency.""" + if len(self.values) < 2 or len(self.timestamps) < 2: + return float(TRADING_DAYS_PER_YEAR) + elapsed_seconds = (self.timestamps[-1] - self.timestamps[0]).total_seconds() + if elapsed_seconds <= 0: + return float(TRADING_DAYS_PER_YEAR) + periods = len(self.values) - 1 + inferred = periods * 365.25 * 24 * 60 * 60 / elapsed_seconds + if not np.isfinite(inferred) or inferred <= 0: + return float(TRADING_DAYS_PER_YEAR) + return float(inferred) + def max_drawdown_info(self) -> tuple[float, int, int]: """Maximum drawdown with peak/trough indices.""" return max_drawdown(self.values) @@ -92,7 +110,28 @@ def cagr(self) -> float: @property def volatility(self) -> float: """Annualized volatility.""" - return volatility(self.returns) + if len(self.returns) < 2: + return 0.0 + base_vol = volatility(self.returns, annualize=False) + return float(base_vol * np.sqrt(self.periods_per_year)) + + @property + def sharpe(self) -> float: + """Annualized Sharpe ratio using inferred bar frequency.""" + if len(self.returns) < 2: + return 0.0 + base_sharpe = sharpe_ratio(self.returns, annualize=False) + return float(base_sharpe * np.sqrt(self.periods_per_year)) + + @property + def sortino(self) -> float: + """Annualized Sortino ratio using inferred bar frequency.""" + if len(self.returns) < 2: + return 0.0 + base_sortino = sortino_ratio(self.returns, annualize=False) + if np.isinf(base_sortino): + return float("inf") + return float(base_sortino * np.sqrt(self.periods_per_year)) def drawdown_series(self) -> np.ndarray: """Drawdown at each point (for underwater chart).""" @@ -109,8 +148,8 @@ def to_dict(self) -> dict: "final_value": self.final_value, "total_return": self.total_return, "cagr": self.cagr, - "sharpe": sharpe_ratio(self.returns), - "sortino": sortino_ratio(self.returns), + "sharpe": self.sharpe, + "sortino": self.sortino, "max_drawdown": self.max_dd, "calmar": calmar_ratio(self.cagr, self.max_dd), "volatility": self.volatility, diff --git a/src/ml4t/backtest/analytics/trades.py b/src/ml4t/backtest/analytics/trades.py index 69e0d22f..cf625b2f 100644 --- a/src/ml4t/backtest/analytics/trades.py +++ b/src/ml4t/backtest/analytics/trades.py @@ -68,44 +68,51 @@ def profit_factor(self) -> float: return float("inf") if self.gross_profit > 0 else 0.0 return self.gross_profit / abs(self.gross_loss) + # --- Per-trade return metrics (percentage-based) --- + # All per-trade metrics use pnl_percent (direction-aware return), + # not dollar P&L. Dollar extremes are misleading when equity changes: + # a -5% trade at high equity is a larger dollar loss than -30% at low equity. + @property def avg_win(self) -> float: - """Average winning trade PnL.""" - winners = self._pnls[self._pnls > 0] - return float(np.mean(winners)) if len(winners) > 0 else 0.0 + """Average winning trade return (as decimal).""" + winner_returns = self._returns[self._returns > 0] + return float(np.mean(winner_returns)) if len(winner_returns) > 0 else 0.0 @property def avg_loss(self) -> float: - """Average losing trade PnL (negative).""" - losers = self._pnls[self._pnls < 0] - return float(np.mean(losers)) if len(losers) > 0 else 0.0 + """Average losing trade return (as decimal, negative).""" + loser_returns = self._returns[self._returns < 0] + return float(np.mean(loser_returns)) if len(loser_returns) > 0 else 0.0 @property def avg_trade(self) -> float: - """Average trade PnL (expectancy per trade).""" - return float(np.mean(self._pnls)) if len(self._pnls) > 0 else 0.0 + """Average trade return (as decimal).""" + return float(np.mean(self._returns)) if len(self._returns) > 0 else 0.0 @property def expectancy(self) -> float: - """Mathematical expectancy: (win_rate * avg_win) + ((1 - win_rate) * avg_loss).""" + """Expected return per trade: (win_rate * avg_win) + ((1 - win_rate) * avg_loss).""" return self.win_rate * self.avg_win + (1 - self.win_rate) * self.avg_loss @property def largest_win(self) -> float: - """Largest single winning trade.""" - winners = self._pnls[self._pnls > 0] - return float(np.max(winners)) if len(winners) > 0 else 0.0 + """Best single trade return (as decimal).""" + winner_returns = self._returns[self._returns > 0] + return float(np.max(winner_returns)) if len(winner_returns) > 0 else 0.0 @property def largest_loss(self) -> float: - """Largest single losing trade (most negative).""" - losers = self._pnls[self._pnls < 0] - return float(np.min(losers)) if len(losers) > 0 else 0.0 + """Worst single trade return (as decimal, most negative).""" + loser_returns = self._returns[self._returns < 0] + return float(np.min(loser_returns)) if len(loser_returns) > 0 else 0.0 @property - def avg_return(self) -> float: - """Average return per trade (as decimal).""" - return float(np.mean(self._returns)) if len(self._returns) > 0 else 0.0 + def payoff_ratio(self) -> float: + """avg_win / |avg_loss|. Size-normalized reward-to-risk.""" + if self.avg_loss == 0: + return float("inf") if self.avg_win > 0 else 0.0 + return self.avg_win / abs(self.avg_loss) @property def avg_bars_held(self) -> float: @@ -127,8 +134,42 @@ def total_commission(self) -> float: @property def total_slippage(self) -> float: - """Total slippage cost across all trades.""" - return sum(t.slippage for t in self.trades) + """Total slippage cost across all trades (entry + exit).""" + return sum(t.total_slippage_cost for t in self.trades) + + # --- Cost Decomposition Metrics --- + + @property + def total_gross_pnl(self) -> float: + """Total gross P&L (price moves only, before all costs).""" + return sum(t.gross_pnl for t in self.trades) + + @property + def total_costs(self) -> float: + """Total transaction costs (fees + slippage).""" + return self.total_fees + self.total_slippage + + @property + def avg_cost_drag(self) -> float: + """Average cost drag across trades (costs as fraction of notional).""" + if not self.trades: + return 0.0 + drags = [t.cost_drag for t in self.trades] + return float(np.mean(drags)) + + @property + def gross_profit_factor(self) -> float: + """Profit factor using gross P&L (before costs). + + Compares raw price-move profits to losses, isolating strategy + edge from execution costs. + """ + gross_pnls = np.array([t.gross_pnl for t in self.trades]) if self.trades else np.array([]) + gross_wins = float(np.sum(gross_pnls[gross_pnls > 0])) if len(gross_pnls) > 0 else 0.0 + gross_losses = float(np.sum(gross_pnls[gross_pnls < 0])) if len(gross_pnls) > 0 else 0.0 + if gross_losses == 0: + return float("inf") if gross_wins > 0 else 0.0 + return gross_wins / abs(gross_losses) def by_side(self, side: str) -> "TradeAnalyzer": """Filter trades by side ('long' or 'short').""" @@ -190,7 +231,7 @@ def mae_recovery_ratio(self) -> float: for t in self.trades: if t.mae < 0 and t.pnl_percent < 0: # Both negative: MAE was -10%, final was -5% = recovered 50% - recovery = (t.mae - t.pnl_percent) / abs(t.mae) + recovery = (t.pnl_percent - t.mae) / abs(t.mae) ratios.append(recovery) return float(np.mean(ratios)) if ratios else 0.0 @@ -211,10 +252,15 @@ def to_dict(self) -> dict: "expectancy": self.expectancy, "largest_win": self.largest_win, "largest_loss": self.largest_loss, - "avg_return": self.avg_return, + "payoff_ratio": self.payoff_ratio, "avg_bars_held": self.avg_bars_held, "total_commission": self.total_commission, "total_slippage": self.total_slippage, + # Cost decomposition + "total_gross_pnl": self.total_gross_pnl, + "total_costs": self.total_costs, + "avg_cost_drag": self.avg_cost_drag, + "gross_profit_factor": self.gross_profit_factor, # MFE/MAE metrics "avg_mfe": self.avg_mfe, "avg_mae": self.avg_mae, diff --git a/src/ml4t/backtest/calendar.py b/src/ml4t/backtest/calendar.py index b0dd8a60..24f5c7ae 100644 --- a/src/ml4t/backtest/calendar.py +++ b/src/ml4t/backtest/calendar.py @@ -717,11 +717,17 @@ def generate_trading_minutes( market_open = row["market_open"] market_close = row["market_close"] - # Generate minute timestamps - current = market_open - while current < market_close: - all_timestamps.append(current) - current = current + pd.Timedelta(minutes=freq_minutes) + segments = [(market_open, market_close)] + break_start = row.get("break_start") + break_end = row.get("break_end") + if pd.notna(break_start) and pd.notna(break_end) and market_open < break_start < break_end: + segments = [(market_open, break_start), (break_end, market_close)] + + for segment_open, segment_close in segments: + current = segment_open + while current < segment_close: + all_timestamps.append(current) + current = current + pd.Timedelta(minutes=freq_minutes) # Optionally include close if include_close and (not all_timestamps or all_timestamps[-1] != market_close): diff --git a/src/ml4t/backtest/core/AGENT.md b/src/ml4t/backtest/core/AGENT.md index 8595fc83..6861c56f 100644 --- a/src/ml4t/backtest/core/AGENT.md +++ b/src/ml4t/backtest/core/AGENT.md @@ -1,4 +1,4 @@ -# core/ - 1,365 Lines +# core/ - 1,314 Lines Decomposed broker internals. Extracted from broker.py during refactoring. @@ -6,12 +6,12 @@ Decomposed broker internals. Extracted from broker.py during refactoring. | File | Lines | Purpose | |------|-------|---------| -| order_book.py | 517 | Order submission, shadow cash, immediate fill | -| execution_engine.py | 407 | Fill ordering (EXIT_FIRST, FIFO, SEQUENTIAL) | +| order_book.py | 500 | Order submission, shadow cash, immediate fill | +| execution_engine.py | 348 | Fill ordering (EXIT_FIRST, FIFO, SEQUENTIAL) | | fill_engine.py | 222 | Fill price calculation, share rounding | | risk_engine.py | 157 | Position rule evaluation, deferred exits | -| portfolio_ledger.py | 31 | Ledger tracking | -| shared.py | 31 | Shared types (SubmitOrderOptions) | +| shared.py | 54 | Shared types (SubmitOrderOptions) | +| portfolio_ledger.py | 33 | Ledger tracking | ## Key diff --git a/src/ml4t/backtest/datafeed.py b/src/ml4t/backtest/datafeed.py index 640de155..be3d6aad 100644 --- a/src/ml4t/backtest/datafeed.py +++ b/src/ml4t/backtest/datafeed.py @@ -192,7 +192,7 @@ def __next__(self) -> tuple[datetime, dict[str, dict], dict[str, Any]]: "volume": volume, "signals": {}, } - if close: + if close is not None: assets_data._prices[asset] = close assets_data._opens[asset] = open_ assets_data._highs[asset] = high diff --git a/src/ml4t/backtest/engine.py b/src/ml4t/backtest/engine.py index ab36a95f..825caff9 100644 --- a/src/ml4t/backtest/engine.py +++ b/src/ml4t/backtest/engine.py @@ -8,7 +8,7 @@ import polars as pl from .analytics import EquityCurve, TradeAnalyzer -from .analytics.metrics import calmar_ratio, sharpe_ratio, sortino_ratio +from .analytics.metrics import calmar_ratio from .broker import Broker from .datafeed import DataFeed from .strategy import Strategy @@ -230,9 +230,14 @@ def _generate_results(self) -> BacktestResult: # Get last known price for this asset last_price = self.broker._current_prices.get(asset, pos.entry_price) - # Calculate mark-to-market PnL - pnl = (last_price - pos.entry_price) * pos.quantity - pos.entry_commission - pnl_pct = (last_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0 + # Calculate mark-to-market PnL (include multiplier for futures) + pnl = ( + last_price - pos.entry_price + ) * pos.quantity * pos.multiplier - pos.entry_commission + raw_pct = ( + (last_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0.0 + ) + pnl_pct = raw_pct if pos.quantity > 0 else -raw_pct open_trade = Trade( symbol=asset, # Asset identifier (Position.asset -> Trade.symbol) @@ -250,6 +255,8 @@ def _generate_results(self) -> BacktestResult: status="open", mfe=pos.max_favorable_excursion, mae=pos.max_adverse_excursion, + entry_slippage=pos.entry_slippage, + multiplier=pos.multiplier, ) all_trades.append(open_trade) @@ -274,18 +281,25 @@ def _generate_results(self) -> BacktestResult: "total_commission": sum(f.commission for f in self.broker.fills), "total_slippage": sum(f.slippage for f in self.broker.fills), # Additional metrics - "sharpe": sharpe_ratio(equity.returns), - "sortino": sortino_ratio(equity.returns), + "sharpe": equity.sharpe, + "sortino": equity.sortino, "calmar": calmar_ratio(equity.cagr, equity.max_dd), "cagr": equity.cagr, "volatility": equity.volatility, "profit_factor": trade_analyzer.profit_factor, + # Per-trade return metrics (percentage-based, direction-aware) "expectancy": trade_analyzer.expectancy, "avg_trade": trade_analyzer.avg_trade, "avg_win": trade_analyzer.avg_win, "avg_loss": trade_analyzer.avg_loss, "largest_win": trade_analyzer.largest_win, "largest_loss": trade_analyzer.largest_loss, + "payoff_ratio": trade_analyzer.payoff_ratio, + # Cost decomposition + "total_gross_pnl": trade_analyzer.total_gross_pnl, + "total_costs": trade_analyzer.total_costs, + "avg_cost_drag": trade_analyzer.avg_cost_drag, + "gross_profit_factor": trade_analyzer.gross_profit_factor, # Calendar enforcement "skipped_bars": self._skipped_bars, } diff --git a/src/ml4t/backtest/execution/AGENT.md b/src/ml4t/backtest/execution/AGENT.md index b59751d6..f8bb9fc3 100644 --- a/src/ml4t/backtest/execution/AGENT.md +++ b/src/ml4t/backtest/execution/AGENT.md @@ -1,4 +1,4 @@ -# execution/ - 1,328 Lines +# execution/ - 1,351 Lines Order fill execution and market impact. @@ -6,12 +6,12 @@ Order fill execution and market impact. | File | Lines | Purpose | |------|-------|---------| -| fill_executor.py | 528 | Fill simulation, slippage, commission | -| rebalancer.py | 389 | Portfolio rebalancing | +| fill_executor.py | 539 | Fill simulation, slippage, commission | +| rebalancer.py | 401 | Portfolio rebalancing | | impact.py | 185 | Market impact models | | limits.py | 186 | Order limits validation | | result.py | 40 | Execution result types | ## Key -`FillExecutor`, `Rebalancer`, `ImpactModel` +`FillExecutor`, `TargetWeightExecutor`, `RebalanceConfig` diff --git a/src/ml4t/backtest/execution/fill_executor.py b/src/ml4t/backtest/execution/fill_executor.py index c022e93d..04644053 100644 --- a/src/ml4t/backtest/execution/fill_executor.py +++ b/src/ml4t/backtest/execution/fill_executor.py @@ -145,6 +145,9 @@ def execute(self, order: Order, base_price: float) -> bool: timestamp=current_time, commission=commission, slippage=slippage, + order_type=order.order_type.value, + limit_price=order.limit_price, + stop_price=order.stop_price, ) broker.fills.append(fill) @@ -322,6 +325,7 @@ def _create_position(self, ctx: FillContext) -> None: context=context, multiplier=broker.get_multiplier(order.asset), entry_commission=ctx.commission, + entry_slippage=ctx.slippage, high_water_mark=initial_hwm, low_water_mark=initial_lwm, ) @@ -342,7 +346,8 @@ def _close_position(self, ctx: FillContext, pos: Position, old_qty: float) -> No # PnL includes both entry and exit commission, and multiplier for futures total_commission = pos.entry_commission + ctx.commission pnl = (ctx.fill_price - pos.entry_price) * old_qty * pos.multiplier - total_commission - pnl_pct = (ctx.fill_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0 + raw_pct = (ctx.fill_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0.0 + pnl_pct = raw_pct if old_qty > 0 else -raw_pct trade = Trade( symbol=order.asset, # Order.asset -> Trade.symbol @@ -359,6 +364,8 @@ def _close_position(self, ctx: FillContext, pos: Position, old_qty: float) -> No exit_reason=_get_exit_reason(order), mfe=pos.max_favorable_excursion, mae=pos.max_adverse_excursion, + entry_slippage=pos.entry_slippage, + multiplier=pos.multiplier, ) broker.trades.append(trade) del broker.positions[order.asset] @@ -394,7 +401,8 @@ def _flip_position( # Close the old position (include multiplier for futures) total_close_commission = pos.entry_commission + close_commission pnl = (ctx.fill_price - pos.entry_price) * old_qty * pos.multiplier - total_close_commission - pnl_pct = (ctx.fill_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0 + raw_pct = (ctx.fill_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0.0 + pnl_pct = raw_pct if old_qty > 0 else -raw_pct trade = Trade( symbol=order.asset, # Order.asset -> Trade.symbol @@ -411,6 +419,8 @@ def _flip_position( exit_reason=_get_exit_reason(order), mfe=pos.max_favorable_excursion, mae=pos.max_adverse_excursion, + entry_slippage=pos.entry_slippage, + multiplier=pos.multiplier, ) broker.trades.append(trade) @@ -430,6 +440,7 @@ def _flip_position( context=context, multiplier=broker.get_multiplier(order.asset), entry_commission=open_commission, + entry_slippage=ctx.slippage * (open_qty / ctx.fill_quantity), high_water_mark=initial_hwm, low_water_mark=initial_lwm, ) @@ -483,6 +494,13 @@ def _scale_position( # Scaling up - recalculate average entry price total_cost = pos.entry_price * abs(old_qty) + ctx.fill_price * abs(ctx.signed_qty) pos.entry_price = total_cost / abs(new_qty) + # Accumulate entry-side costs so eventual close trade includes all entry legs. + pos.entry_commission += ctx.commission + if abs(new_qty) > 0: + total_entry_slippage = pos.entry_slippage * abs(old_qty) + ctx.slippage * abs( + ctx.signed_qty + ) + pos.entry_slippage = total_entry_slippage / abs(new_qty) pos.quantity = new_qty diff --git a/src/ml4t/backtest/execution/rebalancer.py b/src/ml4t/backtest/execution/rebalancer.py index abb3caa7..695e51ec 100644 --- a/src/ml4t/backtest/execution/rebalancer.py +++ b/src/ml4t/backtest/execution/rebalancer.py @@ -161,25 +161,31 @@ def execute( scale = self.config.max_gross_leverage / gross_weight target_weights = {k: v * scale for k, v in target_weights.items()} - # 4. Process each target asset + # 4. Build deterministic execution order: + # first reduce exposure (sells), then add exposure (buys). + reducing_assets: list[str] = [] + increasing_assets: list[str] = [] for asset, target_wt in target_weights.items(): - order: Order | None = self._process_asset( - asset, target_wt, current_weights, equity, data, broker - ) + current_wt = current_weights.get(asset, 0.0) + if target_wt - current_wt < 0: + reducing_assets.append(asset) + else: + increasing_assets.append(asset) + + # 5. Process reductions for target assets first (frees cash for buys). + for asset in reducing_assets: + target_wt = target_weights[asset] + order = self._process_asset(asset, target_wt, current_weights, equity, data, broker) if order is not None: orders.append(order) - - # INCREMENTAL / HYBRID: fill after each asset if mode in (RebalanceMode.INCREMENTAL, RebalanceMode.HYBRID) and order is not None: broker._process_orders() - - # INCREMENTAL: recompute equity and weights from updated state if mode == RebalanceMode.INCREMENTAL: equity = broker.get_account_value() current_weights = self._get_current_weights(broker, data) - # 5. Close positions not in target - for asset in current_weights: + # 6. Close positions not in target before processing buy-side targets. + for asset in list(current_weights): if asset not in target_weights: pos = broker.get_position(asset) if pos and pos.quantity != 0: @@ -192,6 +198,19 @@ def execute( broker._process_orders() if mode == RebalanceMode.INCREMENTAL: equity = broker.get_account_value() + current_weights = self._get_current_weights(broker, data) + + # 7. Process increases for target assets. + for asset in increasing_assets: + target_wt = target_weights[asset] + order = self._process_asset(asset, target_wt, current_weights, equity, data, broker) + if order is not None: + orders.append(order) + if mode in (RebalanceMode.INCREMENTAL, RebalanceMode.HYBRID) and order is not None: + broker._process_orders() + if mode == RebalanceMode.INCREMENTAL: + equity = broker.get_account_value() + current_weights = self._get_current_weights(broker, data) return orders @@ -223,7 +242,7 @@ def _process_asset( # Get price price = data.get(asset, {}).get("close") - if not price or price <= 0: + if price is None or price <= 0: return None # Compute trade value @@ -307,7 +326,7 @@ def _get_effective_weights(self, broker: "Broker", data: dict[str, dict]) -> dic # Add net value of pending orders for order in broker.pending_orders: price = order.limit_price or data.get(order.asset, {}).get("close") - if price: + if price is not None and price > 0: multiplier = broker.get_multiplier(order.asset) # BUY adds value, SELL subtracts sign = 1 if order.side == OrderSide.BUY else -1 diff --git a/src/ml4t/backtest/result.py b/src/ml4t/backtest/result.py index 0cc95608..553610df 100644 --- a/src/ml4t/backtest/result.py +++ b/src/ml4t/backtest/result.py @@ -121,7 +121,15 @@ def to_trades_dataframe(self) -> pl.DataFrame: Returns DataFrame with columns: symbol, entry_time, exit_time, entry_price, exit_price, quantity, direction, pnl, pnl_percent, bars_held, - fees, slippage, mfe, mae, exit_reason, status + fees, slippage, mfe, mae, entry_slippage, multiplier, + gross_pnl, net_return, total_slippage_cost, cost_drag, + exit_reason, status + + Cost decomposition columns: + gross_pnl: Price-move P&L before fees + net_return: Direction-aware net return including fees + total_slippage_cost: Entry + exit slippage in dollars + cost_drag: Total cost as fraction of notional The status column indicates "closed" (actually exited) or "open" (mark-to-market at end of backtest). @@ -153,6 +161,12 @@ def to_trades_dataframe(self) -> pl.DataFrame: "slippage": t.slippage, "mfe": t.mfe, "mae": t.mae, + "entry_slippage": t.entry_slippage, + "multiplier": t.multiplier, + "gross_pnl": t.gross_pnl, + "net_return": t.net_return, + "total_slippage_cost": t.total_slippage_cost, + "cost_drag": t.cost_drag, "exit_reason": t.exit_reason, "status": t.status, } @@ -363,6 +377,72 @@ def to_trade_records(self) -> list[dict[str, Any]]: return to_trade_records(self.trades) + def to_portfolio_analysis( + self, + calendar: str | None = None, + benchmark: Any = None, + ) -> Any: + """Create a PortfolioAnalysis with properly aligned dates. + + This is the recommended bridge from backtest results to + ml4t-diagnostic analysis. It extracts daily returns with dates + and creates a PortfolioAnalysis with the correct annualization. + + Args: + calendar: Trading calendar for annualization and session alignment. + - "crypto": 365 days/year (24/7) + - "NYSE", "NASDAQ": 252 days/year + - Any pandas_market_calendars calendar + If None, uses config calendar or defaults to 252. + benchmark: Optional benchmark returns (pl.Series, np.ndarray, + or list) for alpha/beta calculation. + + Returns: + PortfolioAnalysis instance from ml4t.diagnostic + + Raises: + ImportError: If ml4t-diagnostic is not installed + + Example: + >>> result = engine.run() + >>> analysis = result.to_portfolio_analysis(calendar="crypto") + >>> stats = analysis.compute_summary_stats() + >>> print(f"Sharpe: {stats.sharpe_ratio:.2f}") + """ + try: + from ml4t.diagnostic.evaluation import PortfolioAnalysis + except ImportError as e: + raise ImportError( + "ml4t-diagnostic is required for to_portfolio_analysis(). " + "Install with: pip install ml4t-diagnostic" + ) from e + + # Determine calendar and annualization + cal = calendar or (self.config.calendar if self.config else None) + periods_per_year = _get_annualization_factor(cal) + session_aligned = cal is not None and "CME" in str(cal).upper() + + # Extract daily returns with dates + daily_df = self.to_daily_pnl(session_aligned=session_aligned) + if daily_df.is_empty(): + import numpy as np + + return PortfolioAnalysis( + returns=np.array([]), + periods_per_year=periods_per_year, + ) + + date_col = "date" if "date" in daily_df.columns else "session_date" + dates = daily_df[date_col].to_list() + returns = daily_df["return_pct"].to_numpy() + + return PortfolioAnalysis( + returns=returns, + dates=dates, + benchmark=benchmark, + periods_per_year=periods_per_year, + ) + def compute_metrics( self, calendar: str | None = None, @@ -664,6 +744,8 @@ def from_parquet(cls, path: str | Path) -> BacktestResult: exit_reason=row.get("exit_reason", "signal"), mfe=row["mfe"], mae=row["mae"], + entry_slippage=row.get("entry_slippage", 0.0), + multiplier=row.get("multiplier", 1.0), ) ) @@ -731,6 +813,12 @@ def _trades_schema() -> dict[str, pl.DataType]: "slippage": pl.Float64(), "mfe": pl.Float64(), "mae": pl.Float64(), + "entry_slippage": pl.Float64(), + "multiplier": pl.Float64(), + "gross_pnl": pl.Float64(), + "net_return": pl.Float64(), + "total_slippage_cost": pl.Float64(), + "cost_drag": pl.Float64(), "exit_reason": pl.String(), "status": pl.String(), # "closed" or "open" } @@ -830,7 +918,7 @@ def to_tearsheet( if "total_commission" not in tearsheet_metrics and self.trades: tearsheet_metrics["total_commission"] = sum(t.fees for t in self.trades) if "total_slippage" not in tearsheet_metrics and self.trades: - tearsheet_metrics["total_slippage"] = sum(t.slippage for t in self.trades) + tearsheet_metrics["total_slippage"] = sum(t.total_slippage_cost for t in self.trades) # Extract equity curve for portfolio-level charts equity_df = self.to_equity_dataframe() if self.equity_curve else None diff --git a/src/ml4t/backtest/risk/AGENT.md b/src/ml4t/backtest/risk/AGENT.md index 30c5f0c6..281e8c0f 100644 --- a/src/ml4t/backtest/risk/AGENT.md +++ b/src/ml4t/backtest/risk/AGENT.md @@ -1,4 +1,4 @@ -# risk/ - 1,876 Lines +# risk/ - 1,906 Lines Position-level and portfolio-level risk management. @@ -6,7 +6,7 @@ Position-level and portfolio-level risk management. | Directory | Lines | Purpose | |-----------|-------|---------| -| position/ | 909 | Stop-loss, trailing stop, take-profit, rule chains | +| position/ | 940 | Stop-loss, trailing stop, take-profit, rule chains | | portfolio/ | 816 | Exposure limits, drawdown limits, position counts | ## Modules diff --git a/src/ml4t/backtest/risk/position/AGENT.md b/src/ml4t/backtest/risk/position/AGENT.md index da86bb3f..2efe0d00 100644 --- a/src/ml4t/backtest/risk/position/AGENT.md +++ b/src/ml4t/backtest/risk/position/AGENT.md @@ -1,4 +1,4 @@ -# risk/position/ - 909 Lines +# risk/position/ - 940 Lines Position-level risk rules (stop-loss, trailing stop, take-profit). @@ -6,7 +6,7 @@ Position-level risk rules (stop-loss, trailing stop, take-profit). | File | Lines | Purpose | |------|-------|---------| -| dynamic.py | 474 | Trailing stop, dynamic stop-loss | +| dynamic.py | 505 | Trailing stop, dynamic stop-loss | | static.py | 246 | Take-profit, fixed stop-loss | | composite.py | 103 | Rule composition (RuleChain) | | signal.py | 48 | Signal-based exit rules | diff --git a/src/ml4t/backtest/strategies/AGENT.md b/src/ml4t/backtest/strategies/AGENT.md index e97ae0ae..3ce594bd 100644 --- a/src/ml4t/backtest/strategies/AGENT.md +++ b/src/ml4t/backtest/strategies/AGENT.md @@ -6,8 +6,8 @@ Reusable strategy templates. | File | Lines | Purpose | |------|-------|---------| -| templates.py | 417 | SignalStrategy, RebalanceStrategy, MomentumTemplate | +| templates.py | 417 | Strategy templates for common patterns | ## Key -`SignalStrategy`, `RebalanceStrategy`, `MomentumTemplate` +`SignalFollowingStrategy`, `MomentumStrategy`, `MeanReversionStrategy`, `LongShortStrategy` diff --git a/src/ml4t/backtest/types.py b/src/ml4t/backtest/types.py index 7df129aa..5f0c0dbf 100644 --- a/src/ml4t/backtest/types.py +++ b/src/ml4t/backtest/types.py @@ -202,6 +202,7 @@ class Position: context: dict = field(default_factory=dict) # Strategy-provided context multiplier: float = 1.0 # Contract multiplier (for futures) entry_commission: float = 0.0 # Commission paid on entry (for Trade PnL) + entry_slippage: float = 0.0 # Per-unit slippage on entry (for cost decomposition) def __post_init__(self): # Initialize water marks to entry price @@ -242,7 +243,10 @@ def unrealized_pnl(self, current_price: float | None = None) -> float: return (price - self.entry_price) * self.quantity * self.multiplier def pnl_percent(self, current_price: float | None = None) -> float: - """Calculate percentage return on position. + """Calculate direction-aware percentage return on position. + + For long positions: (price - entry) / entry + For short positions: (entry - price) / entry Args: current_price: Price to calculate return at. If None, uses self.current_price. @@ -252,7 +256,8 @@ def pnl_percent(self, current_price: float | None = None) -> float: price = self.entry_price if self.entry_price == 0: return 0.0 - return (price - self.entry_price) / self.entry_price + raw = (price - self.entry_price) / self.entry_price + return raw if self.quantity >= 0 else -raw def notional_value(self, current_price: float | None = None) -> float: """Calculate notional value of position. @@ -340,6 +345,9 @@ class Fill: timestamp: datetime commission: float = 0.0 slippage: float = 0.0 + order_type: str = "" # OrderType.value string (for fill-level invariants) + limit_price: float | None = None # For limit bound checking + stop_price: float | None = None # For stop bound checking @dataclass @@ -377,6 +385,9 @@ class Trade: # MFE/MAE preserved from Position for trade analysis (shorter field names) mfe: float = 0.0 # Max favorable excursion (best unrealized return) mae: float = 0.0 # Max adverse excursion (worst unrealized return) + # Cost decomposition fields + entry_slippage: float = 0.0 # Per-unit slippage on entry + multiplier: float = 1.0 # Contract multiplier (for futures) # Optional metadata extension point metadata: dict[str, Any] | None = None @@ -395,6 +406,42 @@ def commission(self) -> float: """Backward-compat alias for validation scripts expecting `commission`.""" return self.fees + @property + def gross_pnl(self) -> float: + """Price-move P&L before fees: (exit - entry) * quantity * multiplier.""" + return (self.exit_price - self.entry_price) * self.quantity * self.multiplier + + @property + def net_pnl(self) -> float: + """P&L after all costs. Alias for self.pnl.""" + return self.pnl + + @property + def gross_return(self) -> float: + """Direction-aware gross return. Same as pnl_percent.""" + return self.pnl_percent + + @property + def net_return(self) -> float: + """Direction-aware net return including fees.""" + notional = self.entry_price * abs(self.quantity) * self.multiplier + if notional == 0: + return 0.0 + return self.pnl / notional + + @property + def total_slippage_cost(self) -> float: + """Total slippage cost in dollars (entry + exit).""" + return (self.entry_slippage + self.slippage) * abs(self.quantity) * self.multiplier + + @property + def cost_drag(self) -> float: + """Total cost as fraction of notional: (fees + slippage) / notional.""" + notional = self.entry_price * abs(self.quantity) * self.multiplier + if notional == 0: + return 0.0 + return (self.fees + self.total_slippage_cost) / notional + @dataclass class PartialExit: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..c77852b6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,34 @@ +"""Root conftest.py — always-on accounting invariants for ml4t-backtest. + +Wraps Engine.run() so that every test exercising the engine automatically +gets universal invariant checks. Zero per-test effort; catches accounting +regressions the moment they happen. +""" + +from __future__ import annotations + +import pytest + +from ml4t.backtest import Engine + +from .helpers.invariants import assert_result_invariants + + +@pytest.fixture(autouse=True) +def _patch_engine_invariants(request, monkeypatch): + """Monkeypatch Engine.run() to assert result invariants after every call. + + Opt-out by marking a test with @pytest.mark.no_invariant_check. + """ + if "no_invariant_check" in request.keywords: + return + + original_run = Engine.run + + def checked_run(self): + result = original_run(self) + initial_cash = self.config.initial_cash if self.config else 100_000.0 + assert_result_invariants(result, initial_cash) + return result + + monkeypatch.setattr(Engine, "run", checked_run) diff --git a/tests/contracts/test_book_parity_behaviors.py b/tests/contracts/test_book_parity_behaviors.py index ae7a2b24..d7f19ecb 100644 --- a/tests/contracts/test_book_parity_behaviors.py +++ b/tests/contracts/test_book_parity_behaviors.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta import polars as pl +import pytest from ml4t.backtest.config import ( BacktestConfig, @@ -62,6 +63,7 @@ def on_data(self, timestamp, data, context, broker) -> None: self.msft_order_qty = order.quantity +@pytest.mark.no_invariant_check # Known: partial close during rebalance doesn't prorate entry commission def test_snapshot_value_freezes_targets_vs_incremental_recompute() -> None: start = datetime(2024, 1, 1) prices = pl.DataFrame( diff --git a/tests/contracts/test_validation_scenarios.py b/tests/contracts/test_validation_scenarios.py new file mode 100644 index 00000000..1090cb3f --- /dev/null +++ b/tests/contracts/test_validation_scenarios.py @@ -0,0 +1,248 @@ +"""Validation scenarios bridged into pytest CI. + +Runs the 16 cross-framework validation scenarios through ml4t only +(no external framework venvs required) and checks: +1. Each scenario runs without error and produces trades +2. Each scenario passes all accounting invariants +3. Smoke: final value is positive + +This does NOT compare against external frameworks — that's the separate +validation suite. This ensures ml4t scenarios don't regress. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +# Add validation directory to path for imports +_VALIDATION_DIR = Path(__file__).parent.parent.parent / "validation" +if str(_VALIDATION_DIR) not in sys.path: + sys.path.insert(0, str(_VALIDATION_DIR)) + +from tests.helpers.invariants import assert_result_invariants # noqa: E402 + +# Try to import validation infrastructure +try: + from common.data_generators import ( # noqa: E402 + generate_bracket_data, + generate_random_walk, + generate_rule_combo_data, + generate_short_signals, + generate_short_trending_data, + generate_stop_loss_data, + generate_stress_data, + generate_take_profit_data, + generate_trending_data, + ) + from scenarios.definitions import SCENARIOS # noqa: E402 + + _HAS_VALIDATION = True +except ImportError: + _HAS_VALIDATION = False + SCENARIOS = {} + + +pytestmark = pytest.mark.skipif( + not _HAS_VALIDATION, reason="Validation infrastructure not available" +) + + +# Map generator names to functions +_GENERATORS = {} +if _HAS_VALIDATION: + _GENERATORS = { + "generate_random_walk": generate_random_walk, + "generate_stop_loss_data": generate_stop_loss_data, + "generate_take_profit_data": generate_take_profit_data, + "generate_trending_data": generate_trending_data, + "generate_bracket_data": generate_bracket_data, + "generate_short_signals": generate_short_signals, + "generate_short_trending_data": generate_short_trending_data, + "generate_rule_combo_data": generate_rule_combo_data, + "generate_stress_data": generate_stress_data, + } + + +def _run_ml4t_scenario(scenario_config): + """Run an ml4t scenario and return the BacktestResult.""" + import pandas as pd + import polars as pl + + from ml4t.backtest import ( + BacktestConfig, + DataFeed, + Engine, + StopLoss, + Strategy, + TakeProfit, + TrailingStop, + ) + from ml4t.backtest.types import OrderSide + + # Generate data + gen_fn = _GENERATORS.get(scenario_config.data_generator) + if gen_fn is None: + pytest.skip(f"Generator {scenario_config.data_generator} not found") + + gen_result = gen_fn(**scenario_config.data_kwargs) + prices_df = gen_result[0] # pandas DataFrame + entries = gen_result[1] # numpy boolean array + exits = gen_result[2] if len(gen_result) > 2 else None + + asset = "ASSET" + + # Convert to Polars + timestamps = [] + for ts in prices_df.index: + if isinstance(ts, pd.Timestamp): + timestamps.append(ts.to_pydatetime().replace(tzinfo=None)) + else: + timestamps.append(ts) + + prices_pl = pl.DataFrame( + { + "timestamp": timestamps, + "asset": [asset] * len(prices_df), + "open": prices_df["open"].tolist(), + "high": prices_df["high"].tolist(), + "low": prices_df["low"].tolist(), + "close": prices_df["close"].tolist(), + "volume": prices_df["volume"].astype(float).tolist(), + } + ) + + # Build signals + signals_dict = { + "timestamp": timestamps, + "asset": [asset] * len(prices_df), + } + for col in scenario_config.signal_columns: + if col == "entry": + signals_dict["entry"] = entries.tolist() + elif col == "exit" and exits is not None: + signals_dict["exit"] = exits.tolist() + elif "entry" in col: + signals_dict[col] = entries.tolist() + elif "exit" in col and exits is not None: + signals_dict[col] = exits.tolist() + else: + signals_dict[col] = [False] * len(entries) + + signals_pl = pl.DataFrame(signals_dict) + + # Build strategy + strategy_type = scenario_config.strategy_type + is_short = "short" in strategy_type + + class ScenarioStrategy(Strategy): + def __init__(self): + self._entered = False + + def on_data(self, timestamp, data, context, broker): + if asset not in data: + return + bar = data[asset] + sigs = bar.get("signals", {}) + + if strategy_type == "long_signal": + if sigs.get("entry") and not broker.get_position(asset): + broker.submit_order(asset, scenario_config.shares, OrderSide.BUY) + elif sigs.get("exit") and broker.get_position(asset): + broker.close_position(asset) + elif strategy_type == "long_short": + pos = broker.get_position(asset) + if sigs.get("long_entry") and pos is None: + broker.submit_order(asset, scenario_config.shares, OrderSide.BUY) + elif sigs.get("long_exit") and pos and pos.quantity > 0: + broker.close_position(asset) + elif sigs.get("short_entry") and pos is None: + broker.submit_order(asset, scenario_config.shares, OrderSide.SELL) + elif sigs.get("short_exit") and pos and pos.quantity < 0: + broker.close_position(asset) + elif strategy_type == "short_only": + pos = broker.get_position(asset) + if sigs.get("short_entry") and pos is None: + broker.submit_order(asset, scenario_config.shares, OrderSide.SELL) + elif sigs.get("short_exit") and pos: + broker.close_position(asset) + elif strategy_type in ("single_entry", "risk_entry_only"): + if sigs.get("entry") and not self._entered and not broker.get_position(asset): + side = OrderSide.SELL if is_short else OrderSide.BUY + broker.submit_order(asset, scenario_config.shares, side) + if strategy_type == "single_entry": + self._entered = True + if ( + strategy_type == "single_entry" + and sigs.get("exit") + and broker.get_position(asset) + ): + broker.close_position(asset) + + # Build config + cfg_kwargs = { + "initial_cash": scenario_config.initial_cash, + "commission_rate": scenario_config.constants.get("commission_rate", 0.0), + "slippage_rate": scenario_config.constants.get("slippage_rate", 0.0), + } + cfg_kwargs.update(scenario_config.ml4t_config) + config = BacktestConfig(**cfg_kwargs) + + feed = DataFeed(prices_df=prices_pl, signals_df=signals_pl) + engine = Engine(feed, ScenarioStrategy(), config) + + # Add risk rules + rules = [] + for rule_def in scenario_config.risk_rules: + rule_type = rule_def["type"] + pct = rule_def["pct"] + if rule_type == "StopLoss": + rules.append(StopLoss(pct=pct)) + elif rule_type == "TakeProfit": + rules.append(TakeProfit(pct=pct)) + elif rule_type == "TrailingStop": + rules.append(TrailingStop(pct=pct)) + if rules: + from ml4t.backtest.risk.position.composite import RuleChain + + engine.broker.set_position_rules(RuleChain(rules)) + + return engine.run(), config + + +# Parameterize over all 16 scenarios +_SCENARIO_PARAMS = list(SCENARIOS.items()) if _HAS_VALIDATION else [] + + +@pytest.mark.parametrize( + "scenario_id,scenario_config", + _SCENARIO_PARAMS, + ids=[f"scenario_{sid}" for sid, _ in _SCENARIO_PARAMS], +) +def test_scenario_runs_without_error(scenario_id, scenario_config): + """Smoke: each scenario runs and produces trades with positive final value.""" + result, config = _run_ml4t_scenario(scenario_config) + + assert result.equity_curve, f"Scenario {scenario_id} produced no equity curve" + final_value = result.equity_curve[-1][1] + assert final_value > 0, f"Scenario {scenario_id} final value={final_value} <= 0" + + # Should produce at least one trade for any meaningful scenario + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) >= 1, f"Scenario {scenario_id} produced no closed trades" + + +@pytest.mark.parametrize( + "scenario_id,scenario_config", + _SCENARIO_PARAMS, + ids=[f"scenario_{sid}" for sid, _ in _SCENARIO_PARAMS], +) +@pytest.mark.no_invariant_check # We check invariants explicitly here +def test_scenario_invariants(scenario_id, scenario_config): + """Each scenario passes all accounting invariants.""" + result, config = _run_ml4t_scenario(scenario_config) + + initial_cash = config.initial_cash + assert_result_invariants(result, initial_cash) diff --git a/tests/execution/test_rebalancer.py b/tests/execution/test_rebalancer.py index 3ed65ee3..8c8edc43 100644 --- a/tests/execution/test_rebalancer.py +++ b/tests/execution/test_rebalancer.py @@ -8,6 +8,7 @@ Broker, OrderSide, ) +from ml4t.backtest.config import RebalanceMode from ml4t.backtest.execution.rebalancer import RebalanceConfig, TargetWeightExecutor from ml4t.backtest.models import NoCommission, NoSlippage @@ -734,3 +735,45 @@ def test_full_rebalance_workflow(self): # Should have AAPL buy, GOOG buy, MSFT sell (close) assert len(orders2) >= 2 + + +class TestTargetWeightExecutorModes: + """Tests for rebalancing behavior in sequential fill modes.""" + + def test_incremental_mode_processes_sells_before_buys(self): + """Incremental mode should free cash before submitting buy-side reallocations.""" + broker = Broker( + initial_cash=100.0, + commission_model=NoCommission(), + slippage_model=NoSlippage(), + ) + broker._update_time( + datetime(2024, 1, 1, 9, 30), + {"A": 100.0, "B": 100.0}, + {"A": 100.0, "B": 100.0}, + {"A": 100.0, "B": 100.0}, + {"A": 100.0, "B": 100.0}, + {"A": 1_000_000, "B": 1_000_000}, + {}, + ) + broker.submit_order("B", 1.0, OrderSide.BUY) + broker._process_orders() + + executor = TargetWeightExecutor( + RebalanceConfig( + min_trade_value=0.0, + min_weight_change=0.0, + allow_fractional=True, + rebalance_mode=RebalanceMode.INCREMENTAL, + ) + ) + data = {"A": {"close": 100.0}, "B": {"close": 100.0}} + + orders = executor.execute({"A": 1.0, "B": 0.0}, data, broker) + + assert len(orders) == 2 + assert all(order.rejection_reason is None for order in orders) + pos_a = broker.get_position("A") + pos_b = broker.get_position("B") + assert pos_a is not None and pos_a.quantity == pytest.approx(1.0) + assert pos_b is None diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 00000000..83d657e6 --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1,21 @@ +"""Shared test helpers for ml4t-backtest test suite.""" + +from .data import make_ohlcv_prices, make_prices, set_broker_bar +from .strategies import ( + BuyOnceStrategy, + NoopStrategy, + OrderTypeStrategy, + RoundTripStrategy, + SignalStrategy, +) + +__all__ = [ + "make_prices", + "make_ohlcv_prices", + "set_broker_bar", + "NoopStrategy", + "BuyOnceStrategy", + "OrderTypeStrategy", + "RoundTripStrategy", + "SignalStrategy", +] diff --git a/tests/helpers/data.py b/tests/helpers/data.py new file mode 100644 index 00000000..a3dfd39f --- /dev/null +++ b/tests/helpers/data.py @@ -0,0 +1,129 @@ +"""Shared data generation helpers for ml4t-backtest tests. + +Consolidates duplicated price/OHLCV generators scattered across test files. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +import polars as pl + +from ml4t.backtest import Broker + + +def make_prices( + closes: list[float], + *, + asset: str = "TEST", + start: datetime = datetime(2024, 1, 1), + opens: list[float] | None = None, + highs: list[float] | None = None, + lows: list[float] | None = None, + volumes: list[float] | None = None, + freq_days: int = 1, +) -> pl.DataFrame: + """Create a price DataFrame suitable for DataFeed. + + Args: + closes: List of close prices (one per bar). + asset: Asset symbol. + start: Starting timestamp. + opens: Open prices (defaults to close). + highs: High prices (defaults to close). + lows: Low prices (defaults to close). + volumes: Volumes (defaults to 1_000_000). + freq_days: Days between bars. + + Returns: + Polars DataFrame with columns: timestamp, asset, open, high, low, close, volume. + """ + n = len(closes) + timestamps = [start + timedelta(days=i * freq_days) for i in range(n)] + + return pl.DataFrame( + { + "timestamp": timestamps, + "asset": [asset] * n, + "open": opens if opens is not None else closes, + "high": highs if highs is not None else closes, + "low": lows if lows is not None else closes, + "close": closes, + "volume": volumes if volumes is not None else [1_000_000.0] * n, + } + ) + + +def make_ohlcv_prices( + bars: list[tuple[float, float, float, float]], + *, + asset: str = "TEST", + start: datetime = datetime(2024, 1, 1), + volumes: list[float] | None = None, + freq_days: int = 1, +) -> pl.DataFrame: + """Create a price DataFrame from explicit OHLC tuples. + + Args: + bars: List of (open, high, low, close) tuples. + asset: Asset symbol. + start: Starting timestamp. + volumes: Volumes (defaults to 1_000_000). + freq_days: Days between bars. + + Returns: + Polars DataFrame with columns: timestamp, asset, open, high, low, close, volume. + """ + n = len(bars) + timestamps = [start + timedelta(days=i * freq_days) for i in range(n)] + opens, highs, lows, closes = zip(*bars) + + return pl.DataFrame( + { + "timestamp": timestamps, + "asset": [asset] * n, + "open": list(opens), + "high": list(highs), + "low": list(lows), + "close": list(closes), + "volume": volumes if volumes is not None else [1_000_000.0] * n, + } + ) + + +def set_broker_bar( + broker: Broker, + price: float, + *, + asset: str = "TEST", + ts: datetime = datetime(2024, 1, 1), + open_: float | None = None, + high: float | None = None, + low: float | None = None, + volume: float = 1_000_000.0, +) -> None: + """Set the current bar on a Broker instance for unit testing. + + Args: + broker: Broker instance. + price: Close price (also used for open/high/low if not specified). + asset: Asset symbol. + ts: Bar timestamp. + open_: Open price (defaults to close). + high: High price (defaults to close). + low: Low price (defaults to close). + volume: Volume. + """ + o = open_ if open_ is not None else price + h = high if high is not None else price + lo = low if low is not None else price + + broker._update_time( + ts, + {asset: price}, + {asset: o}, + {asset: h}, + {asset: lo}, + {asset: volume}, + {asset: {}}, + ) diff --git a/tests/helpers/invariants.py b/tests/helpers/invariants.py new file mode 100644 index 00000000..16bb9e28 --- /dev/null +++ b/tests/helpers/invariants.py @@ -0,0 +1,297 @@ +"""Universal accounting invariants for BacktestResult. + +These invariants are checked automatically after every Engine.run() call +via the autouse fixture in conftest.py. They catch accounting bugs that +component-level tests miss by asserting properties that must hold for +ANY valid backtest result. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ml4t.backtest.result import BacktestResult + + +# Tolerance for floating-point comparisons in dollar amounts +_ABS_TOL = 1e-6 +# Relative tolerance for percentage comparisons +_REL_TOL = 1e-8 + + +def assert_result_invariants( + result: BacktestResult, + initial_cash: float, + *, + check_equity_terminal: bool = True, + check_pnl_decomposition: bool = True, + check_direction_signs: bool = True, + check_mfe_mae_bounds: bool = True, + check_cost_non_negativity: bool = True, + check_fill_temporal_order: bool = True, + check_no_nan: bool = True, + check_exit_reason_consistency: bool = True, + check_fill_order_type_bounds: bool = True, +) -> None: + """Assert universal invariants on a BacktestResult. + + Args: + result: The BacktestResult to check. + initial_cash: The initial cash used for the backtest. + check_*: Flags to selectively disable individual checks. + """ + closed_trades = [t for t in result.trades if t.status == "closed"] + + if check_equity_terminal: + _check_equity_terminal(result, initial_cash, closed_trades) + if check_pnl_decomposition: + _check_pnl_decomposition(closed_trades) + if check_direction_signs: + _check_direction_signs(closed_trades) + if check_mfe_mae_bounds: + _check_mfe_mae_bounds(closed_trades) + if check_cost_non_negativity: + _check_cost_non_negativity(closed_trades) + if check_fill_temporal_order: + _check_fill_temporal_order(result) + if check_no_nan: + _check_no_nan(result) + if check_exit_reason_consistency: + _check_exit_reason_consistency(result.trades) + if check_fill_order_type_bounds: + _check_fill_order_type_bounds(result) + + +def _check_equity_terminal( + result: BacktestResult, + initial_cash: float, + closed_trades: list, +) -> None: + """Verify: initial_cash + sum(closed_pnl) + sum(open_pnl) ≈ final_value. + + When open positions exist, the tolerance is expanded because the open trade + PnL is computed from Position state which may not perfectly capture all + intermediate costs (especially in rebalancing with integer shares and high + commission rates). Multi-asset rebalancing with integer shares also creates + small rounding discrepancies in position PnL vs. cash-based equity tracking. + """ + if not result.equity_curve: + return + + final_value = result.equity_curve[-1][1] + closed_pnl = sum(t.pnl for t in closed_trades) + open_trades = [t for t in result.trades if t.status == "open"] + open_pnl = sum(t.pnl for t in open_trades) + + expected = initial_cash + closed_pnl + open_pnl + diff = abs(expected - final_value) + + # Base tolerance: relative to portfolio size + tol = max(_ABS_TOL, abs(final_value) * 1e-6) + + # Expand tolerance for total fill costs (commission + slippage on all fills) + total_fill_costs = sum(f.commission + f.slippage for f in result.fills) + if total_fill_costs > 0: + tol = max(tol, total_fill_costs * 0.05) # 5% of total costs + + # Expand tolerance for open positions: mark-to-market PnL from Position state + # can diverge slightly from cash-based equity tracking, especially with + # multi-asset rebalancing and integer share rounding. + if open_trades: + open_notional = sum(abs(t.quantity) * t.exit_price * t.multiplier for t in open_trades) + tol = max(tol, open_notional * 1e-4) # 0.01% of open notional + + assert diff <= tol, ( + f"Equity terminal invariant violated: " + f"initial_cash({initial_cash}) + closed_pnl({closed_pnl:.6f}) + " + f"open_pnl({open_pnl:.6f}) = {expected:.6f} != final_value({final_value:.6f}), " + f"diff={diff:.10f}, tol={tol:.6f}" + ) + + +def _check_pnl_decomposition(closed_trades: list) -> None: + """Verify: gross_pnl - fees ≈ pnl for every closed trade.""" + for i, t in enumerate(closed_trades): + gross = t.gross_pnl + expected_net = gross - t.fees + diff = abs(expected_net - t.pnl) + + tol = max(_ABS_TOL, abs(gross) * 1e-6) + assert diff <= tol, ( + f"PnL decomposition invariant violated for trade {i} ({t.symbol}): " + f"gross_pnl({gross:.6f}) - fees({t.fees:.6f}) = {expected_net:.6f} " + f"!= pnl({t.pnl:.6f}), diff={diff:.10f}" + ) + + +def _check_direction_signs(closed_trades: list) -> None: + """Verify: sign(gross_pnl) == sign(pnl_percent) for non-zero trades. + + We check gross_pnl (not net pnl) because pnl_percent is the gross return + (price change / entry price). Net pnl includes fees, so for near-breakeven + trades where fees exceed gross profit, sign(pnl) != sign(pnl_percent) is + expected and correct. + """ + for i, t in enumerate(closed_trades): + if abs(t.gross_pnl) < _ABS_TOL or abs(t.pnl_percent) < _REL_TOL: + continue # Skip breakeven trades + + gross_sign = 1 if t.gross_pnl > 0 else -1 + pct_sign = 1 if t.pnl_percent > 0 else -1 + + assert gross_sign == pct_sign, ( + f"Direction sign invariant violated for trade {i} ({t.symbol}): " + f"gross_pnl={t.gross_pnl:.6f} (sign={gross_sign}) but " + f"pnl_percent={t.pnl_percent:.6f} (sign={pct_sign}), " + f"direction={t.direction}, quantity={t.quantity}" + ) + + +def _check_mfe_mae_bounds(closed_trades: list) -> None: + """Verify: MFE >= 0, MAE <= 0. + + Note: pnl_percent is NOT guaranteed to be bounded by MFE/MAE because + water marks are updated at bar end AFTER position exits. The exit bar's + price move is not captured in MFE/MAE if the position closes on that bar. + """ + for i, t in enumerate(closed_trades): + assert t.mfe >= -_REL_TOL, ( + f"MFE bound violated for trade {i} ({t.symbol}): mfe={t.mfe:.6f} < 0" + ) + assert t.mae <= _REL_TOL, ( + f"MAE bound violated for trade {i} ({t.symbol}): mae={t.mae:.6f} > 0" + ) + + +def _check_cost_non_negativity(closed_trades: list) -> None: + """Verify: fees >= 0, multiplier > 0.""" + for i, t in enumerate(closed_trades): + assert t.fees >= -_ABS_TOL, ( + f"Fees non-negativity violated for trade {i} ({t.symbol}): fees={t.fees:.6f}" + ) + assert t.multiplier > 0, ( + f"Multiplier must be positive for trade {i} ({t.symbol}): multiplier={t.multiplier}" + ) + + +def _check_fill_temporal_order(result: BacktestResult) -> None: + """Verify: fills are in non-decreasing timestamp order.""" + for i in range(1, len(result.fills)): + prev = result.fills[i - 1] + curr = result.fills[i] + assert curr.timestamp >= prev.timestamp, ( + f"Fill temporal order violated: fill[{i - 1}].timestamp={prev.timestamp} > " + f"fill[{i}].timestamp={curr.timestamp}" + ) + + +def _check_no_nan(result: BacktestResult) -> None: + """Verify: no NaN in numeric Trade fields.""" + numeric_fields = [ + "entry_price", + "exit_price", + "quantity", + "pnl", + "pnl_percent", + "fees", + "slippage", + "mfe", + "mae", + "entry_slippage", + "multiplier", + ] + + for i, t in enumerate(result.trades): + for field in numeric_fields: + val = getattr(t, field) + assert not math.isnan(val), f"NaN found in trade {i} ({t.symbol}).{field}" + assert math.isfinite(val), f"Infinite value in trade {i} ({t.symbol}).{field}={val}" + + +def _check_exit_reason_consistency(trades: list) -> None: + """Verify exit_reason is consistent with trade outcome. + + Invariants on closed trades by exit_reason: + - "stop_loss" → gross_pnl <= tolerance (price moved against position) + - "take_profit" → gross_pnl >= -tolerance (price moved for position) + - "trailing_stop" → mfe > 0 (favorable move happened before trail triggered) + + Invariant on all trades: + - "end_of_data" → status == "open" + """ + # Tolerance for floating-point and small slippage effects + tol = 1e-4 + + for i, t in enumerate(trades): + if t.exit_reason == "stop_loss": + assert t.gross_pnl <= tol, ( + f"Exit-reason invariant violated for trade {i} ({t.symbol}): " + f"exit_reason='stop_loss' but gross_pnl={t.gross_pnl:.6f} > 0 " + f"(price should have moved against position)" + ) + elif t.exit_reason == "take_profit": + assert t.gross_pnl >= -tol, ( + f"Exit-reason invariant violated for trade {i} ({t.symbol}): " + f"exit_reason='take_profit' but gross_pnl={t.gross_pnl:.6f} < 0 " + f"(price should have moved for position)" + ) + elif t.exit_reason == "trailing_stop": + assert t.mfe > -_REL_TOL, ( + f"Exit-reason invariant violated for trade {i} ({t.symbol}): " + f"exit_reason='trailing_stop' but mfe={t.mfe:.6f} <= 0 " + f"(favorable move should have happened before trail triggered)" + ) + elif t.exit_reason == "end_of_data": + assert t.status == "open", ( + f"Exit-reason invariant violated for trade {i} ({t.symbol}): " + f"exit_reason='end_of_data' but status='{t.status}' (should be 'open')" + ) + + +def _check_fill_order_type_bounds(result: BacktestResult) -> None: + """Verify fill prices respect order-type bounds. + + For fills with populated order_type metadata: + - Limit buy: fill price <= limit_price (never overpay) + - Limit sell: fill price >= limit_price (never undersell) + - Stop buy: fill price >= stop_price (fill at or above trigger) + - Stop sell: fill price <= stop_price (fill at or below trigger) + + Fills with empty order_type (pre-metadata or manual construction) are skipped. + """ + from ml4t.backtest.types import OrderSide + + tol = 1e-8 + + for i, f in enumerate(result.fills): + order_type = getattr(f, "order_type", "") + if not order_type: + continue + + limit_price = getattr(f, "limit_price", None) + stop_price = getattr(f, "stop_price", None) + + if order_type == "limit" and limit_price is not None: + if f.side == OrderSide.BUY: + assert f.price <= limit_price + tol, ( + f"Fill order-type bound violated for fill {i} ({f.asset}): " + f"limit BUY filled at {f.price:.6f} > limit_price {limit_price:.6f}" + ) + else: + assert f.price >= limit_price - tol, ( + f"Fill order-type bound violated for fill {i} ({f.asset}): " + f"limit SELL filled at {f.price:.6f} < limit_price {limit_price:.6f}" + ) + elif order_type == "stop" and stop_price is not None: + if f.side == OrderSide.BUY: + assert f.price >= stop_price - tol, ( + f"Fill order-type bound violated for fill {i} ({f.asset}): " + f"stop BUY filled at {f.price:.6f} < stop_price {stop_price:.6f}" + ) + else: + assert f.price <= stop_price + tol, ( + f"Fill order-type bound violated for fill {i} ({f.asset}): " + f"stop SELL filled at {f.price:.6f} > stop_price {stop_price:.6f}" + ) diff --git a/tests/helpers/strategies.py b/tests/helpers/strategies.py new file mode 100644 index 00000000..f1cca901 --- /dev/null +++ b/tests/helpers/strategies.py @@ -0,0 +1,199 @@ +"""Shared test strategy implementations for ml4t-backtest tests. + +Provides reusable strategies for common test patterns instead of +duplicating simple strategies across test files. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from ml4t.backtest import OrderSide, OrderType, Strategy + + +class NoopStrategy(Strategy): + """Strategy that does nothing. Useful for testing broker/engine internals.""" + + def on_data( + self, + timestamp: datetime, + data: dict[str, dict], + context: dict[str, Any], + broker: Any, + ) -> None: + pass + + +class BuyOnceStrategy(Strategy): + """Buys a fixed quantity on the first bar, then holds forever. + + Args: + asset: Asset to buy. + qty: Quantity to buy. + """ + + def __init__(self, asset: str = "TEST", qty: float = 100.0): + self.asset = asset + self.qty = qty + self._bought = False + + def on_data( + self, + timestamp: datetime, + data: dict[str, dict], + context: dict[str, Any], + broker: Any, + ) -> None: + if not self._bought and self.asset in data: + broker.submit_order(self.asset, self.qty, OrderSide.BUY) + self._bought = True + + +class RoundTripStrategy(Strategy): + """Enters on bar `entry_bar` and exits on bar `exit_bar`. + + Parameterized by direction (long/short) for systematic direction testing. + + Args: + asset: Asset symbol. + qty: Unsigned quantity (direction handled internally). + entry_bar: Bar index to enter (0-based). + exit_bar: Bar index to exit (0-based). + direction: "long" or "short". + """ + + def __init__( + self, + asset: str = "TEST", + qty: float = 100.0, + entry_bar: int = 0, + exit_bar: int = 2, + direction: str = "long", + ): + self.asset = asset + self.qty = qty + self.entry_bar = entry_bar + self.exit_bar = exit_bar + self.direction = direction + self._bar_count = 0 + + def on_data( + self, + timestamp: datetime, + data: dict[str, dict], + context: dict[str, Any], + broker: Any, + ) -> None: + if self._bar_count == self.entry_bar and self.asset in data: + side = OrderSide.BUY if self.direction == "long" else OrderSide.SELL + broker.submit_order(self.asset, self.qty, side) + elif self._bar_count == self.exit_bar: + pos = broker.get_position(self.asset) + if pos is not None: + broker.close_position(self.asset) + self._bar_count += 1 + + +class OrderTypeStrategy(Strategy): + """Enters with specified order type, exits with market order. + + Args: + asset: Asset symbol. + qty: Unsigned quantity. + direction: "long" or "short". + order_type: OrderType for entry. + limit_price: Limit price for LIMIT entries. + stop_price: Stop price for STOP entries. + entry_bar: Bar index to submit entry (0-based). + exit_bar: Bar index to close position (0-based). + """ + + def __init__( + self, + asset: str = "TEST", + qty: float = 100.0, + direction: str = "long", + order_type: OrderType = OrderType.LIMIT, + limit_price: float | None = None, + stop_price: float | None = None, + entry_bar: int = 0, + exit_bar: int = 3, + ): + self.asset = asset + self.qty = qty + self.direction = direction + self.order_type = order_type + self.limit_price = limit_price + self.stop_price = stop_price + self.entry_bar = entry_bar + self.exit_bar = exit_bar + self._bar_count = 0 + + def on_data( + self, + timestamp: datetime, + data: dict[str, dict], + context: dict[str, Any], + broker: Any, + ) -> None: + if self._bar_count == self.entry_bar and self.asset in data: + side = OrderSide.BUY if self.direction == "long" else OrderSide.SELL + broker.submit_order( + self.asset, + self.qty, + side, + order_type=self.order_type, + limit_price=self.limit_price, + stop_price=self.stop_price, + ) + elif self._bar_count == self.exit_bar: + pos = broker.get_position(self.asset) + if pos is not None: + broker.close_position(self.asset) + self._bar_count += 1 + + +class SignalStrategy(Strategy): + """Trades based on a named signal column in the data. + + Buys when signal > 0, sells when signal < 0, closes on zero. + + Args: + asset: Asset symbol. + qty: Unsigned quantity. + signal_name: Name of the signal key in data[asset]["signals"]. + """ + + def __init__( + self, + asset: str = "TEST", + qty: float = 100.0, + signal_name: str = "signal", + ): + self.asset = asset + self.qty = qty + self.signal_name = signal_name + + def on_data( + self, + timestamp: datetime, + data: dict[str, dict], + context: dict[str, Any], + broker: Any, + ) -> None: + if self.asset not in data: + return + + bar = data[self.asset] + signals = bar.get("signals", {}) + signal_val = signals.get(self.signal_name, 0) + + pos = broker.get_position(self.asset) + + if signal_val > 0 and pos is None: + broker.submit_order(self.asset, self.qty, OrderSide.BUY) + elif signal_val < 0 and pos is None: + broker.submit_order(self.asset, self.qty, OrderSide.SELL) + elif signal_val == 0 and pos is not None: + broker.close_position(self.asset) diff --git a/tests/oracle/__init__.py b/tests/oracle/__init__.py new file mode 100644 index 00000000..b89843db --- /dev/null +++ b/tests/oracle/__init__.py @@ -0,0 +1,12 @@ +"""Independent reference oracle for differential testing. + +This package contains a pure-Python backtesting engine that shares ZERO code +with ml4t.backtest. It is deliberately simpler (market orders only, no risk +rules) and computes all values independently. + +Any difference between the oracle and the SUT indicates a bug in one or the other. +""" + +from .engine import OracleFillRule, OracleResult, OracleTrade, run_oracle + +__all__ = ["run_oracle", "OracleFillRule", "OracleResult", "OracleTrade"] diff --git a/tests/oracle/engine.py b/tests/oracle/engine.py new file mode 100644 index 00000000..f6aba539 --- /dev/null +++ b/tests/oracle/engine.py @@ -0,0 +1,270 @@ +"""Pure-Python reference oracle engine for differential testing. + +Design rules: + 1. NO imports from ml4t.backtest (zero shared code) + 2. Pure functions + simple dataclasses + 3. Deliberately simpler: market orders only, no risk rules, no limit orders + 4. Explicit about every parameter + 5. Computes: gross_pnl, fees, net_pnl, pnl_percent, final_cash + +This oracle handles: + - Market orders (long and short) + - SAME_BAR / NEXT_BAR fill timing + - Percentage commission and slippage + - Average-cost accounting + - Signed quantities (positive=long, negative=short) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class FillTiming(Enum): + """When orders fill relative to the signal bar.""" + + SAME_BAR = "same_bar" # Fill at signal bar's close + NEXT_BAR = "next_bar" # Fill at next bar's open + + +@dataclass(frozen=True) +class OracleFillRule: + """Configuration for how fills are processed.""" + + timing: FillTiming = FillTiming.SAME_BAR + commission_rate: float = 0.0 # Fraction (0.001 = 0.1%) + slippage_rate: float = 0.0 # Fraction (0.001 = 0.1%) + + +@dataclass(frozen=True) +class OracleBar: + """Single price bar.""" + + open: float + high: float + low: float + close: float + + +@dataclass(frozen=True) +class OracleSignal: + """Trade signal.""" + + bar_index: int + direction: str # "long" or "short" + action: str # "entry" or "exit" + quantity: float # Unsigned + + +@dataclass +class OracleTrade: + """Completed round-trip trade computed by the oracle.""" + + direction: str + entry_price: float + exit_price: float + quantity: float # Unsigned + gross_pnl: float # Price-move PnL before costs + fees: float # Total fees (entry + exit commission) + net_pnl: float # gross_pnl - fees + pnl_percent: float # Direction-aware return on notional (gross, before fees) + net_return: float # net_pnl / notional + entry_slippage_cost: float + exit_slippage_cost: float + + +@dataclass +class OracleResult: + """Result of running the oracle.""" + + trades: list[OracleTrade] + final_cash: float + initial_cash: float + + @property + def total_pnl(self) -> float: + return sum(t.net_pnl for t in self.trades) + + +def _compute_fill_price( + bar: OracleBar, + timing: FillTiming, + is_entry: bool, + is_long: bool, + slippage_rate: float, + next_bar: OracleBar | None = None, +) -> float: + """Compute the fill price including slippage. + + Slippage always works against the trader: + - Buying: fill price is higher (close * (1 + slippage)) + - Selling: fill price is lower (close * (1 - slippage)) + """ + if timing == FillTiming.SAME_BAR: + base_price = bar.close + else: + if next_bar is None: + return -1.0 # Cannot fill + base_price = next_bar.open + + # Determine if this is a buy or sell + if is_long: + is_buy = is_entry + else: + is_buy = not is_entry + + if is_buy: + return base_price * (1.0 + slippage_rate) + else: + return base_price * (1.0 - slippage_rate) + + +def _compute_commission(price: float, quantity: float, rate: float) -> float: + """Compute commission: rate * price * quantity.""" + return rate * price * quantity + + +def run_oracle( + bars: list[OracleBar], + signals: list[OracleSignal], + fill_rule: OracleFillRule | None = None, + initial_cash: float = 100_000.0, +) -> OracleResult: + """Run the reference oracle on price bars and signals. + + Args: + bars: List of OracleBar (one per time step). + signals: List of OracleSignal (entry/exit signals). + fill_rule: Fill configuration (timing, commission, slippage). + initial_cash: Starting cash. + + Returns: + OracleResult with trades and final cash. + """ + if fill_rule is None: + fill_rule = OracleFillRule() + + cash = initial_cash + trades: list[OracleTrade] = [] + + # Sort signals by bar_index + sorted_signals = sorted(signals, key=lambda s: s.bar_index) + + # Track open position + position_direction: str | None = None + position_entry_price: float = 0.0 + position_qty: float = 0.0 + entry_commission: float = 0.0 + entry_slippage_cost: float = 0.0 + + for signal in sorted_signals: + bar_idx = signal.bar_index + if bar_idx < 0 or bar_idx >= len(bars): + continue + + bar = bars[bar_idx] + next_bar = bars[bar_idx + 1] if bar_idx + 1 < len(bars) else None + + if signal.action == "entry" and position_direction is None: + # Open new position + is_long = signal.direction == "long" + fill_price = _compute_fill_price( + bar, + fill_rule.timing, + is_entry=True, + is_long=is_long, + slippage_rate=fill_rule.slippage_rate, + next_bar=next_bar, + ) + if fill_price < 0: + continue # Cannot fill (no next bar) + + comm = _compute_commission(fill_price, signal.quantity, fill_rule.commission_rate) + + # Slippage cost: difference from base price + if fill_rule.timing == FillTiming.SAME_BAR: + base = bar.close + else: + base = next_bar.open if next_bar else bar.close + slippage_cost = abs(fill_price - base) * signal.quantity + + if is_long: + cash -= fill_price * signal.quantity + comm + else: + cash += fill_price * signal.quantity - comm + + position_direction = signal.direction + position_entry_price = fill_price + position_qty = signal.quantity + entry_commission = comm + entry_slippage_cost = slippage_cost + + elif signal.action == "exit" and position_direction is not None: + # Close position + is_long = position_direction == "long" + fill_price = _compute_fill_price( + bar, + fill_rule.timing, + is_entry=False, + is_long=is_long, + slippage_rate=fill_rule.slippage_rate, + next_bar=next_bar, + ) + if fill_price < 0: + continue + + exit_comm = _compute_commission(fill_price, position_qty, fill_rule.commission_rate) + + if fill_rule.timing == FillTiming.SAME_BAR: + base = bar.close + else: + base = next_bar.open if next_bar else bar.close + exit_slippage_cost = abs(fill_price - base) * position_qty + + if is_long: + cash += fill_price * position_qty - exit_comm + else: + cash -= fill_price * position_qty + exit_comm + + # Compute trade PnL + total_fees = entry_commission + exit_comm + + if is_long: + gross_pnl = (fill_price - position_entry_price) * position_qty + else: + gross_pnl = (position_entry_price - fill_price) * position_qty + + net_pnl = gross_pnl - total_fees + + notional = position_entry_price * position_qty + pnl_percent = gross_pnl / notional if notional > 0 else 0.0 + net_return = net_pnl / notional if notional > 0 else 0.0 + + trades.append( + OracleTrade( + direction=position_direction, + entry_price=position_entry_price, + exit_price=fill_price, + quantity=position_qty, + gross_pnl=gross_pnl, + fees=total_fees, + net_pnl=net_pnl, + pnl_percent=pnl_percent, + net_return=net_return, + entry_slippage_cost=entry_slippage_cost, + exit_slippage_cost=exit_slippage_cost, + ) + ) + + position_direction = None + position_entry_price = 0.0 + position_qty = 0.0 + entry_commission = 0.0 + entry_slippage_cost = 0.0 + + return OracleResult( + trades=trades, + final_cash=cash, + initial_cash=initial_cash, + ) diff --git a/tests/oracle/test_oracle_self.py b/tests/oracle/test_oracle_self.py new file mode 100644 index 00000000..c3cdbe41 --- /dev/null +++ b/tests/oracle/test_oracle_self.py @@ -0,0 +1,203 @@ +"""Self-consistency tests for the reference oracle. + +These verify the oracle itself is correct before we use it as a reference. +""" + +from __future__ import annotations + +import pytest + +from .engine import FillTiming, OracleBar, OracleFillRule, OracleSignal, run_oracle + + +class TestOracleLongRoundTrips: + """Verify oracle computes correct values for long trades.""" + + def test_profitable_long_no_costs(self): + bars = [OracleBar(100, 105, 95, 100), OracleBar(110, 115, 105, 110)] + signals = [ + OracleSignal(0, "long", "entry", 100), + OracleSignal(1, "long", "exit", 100), + ] + result = run_oracle(bars, signals) + + assert len(result.trades) == 1 + t = result.trades[0] + assert t.gross_pnl == pytest.approx(1000.0) # (110-100)*100 + assert t.fees == 0.0 + assert t.net_pnl == pytest.approx(1000.0) + assert t.pnl_percent == pytest.approx(0.10) # 10% + assert result.final_cash == pytest.approx(101_000.0) + + def test_losing_long_no_costs(self): + bars = [OracleBar(100, 105, 95, 100), OracleBar(90, 95, 85, 90)] + signals = [ + OracleSignal(0, "long", "entry", 100), + OracleSignal(1, "long", "exit", 100), + ] + result = run_oracle(bars, signals) + + t = result.trades[0] + assert t.gross_pnl == pytest.approx(-1000.0) + assert t.pnl_percent == pytest.approx(-0.10) + assert result.final_cash == pytest.approx(99_000.0) + + def test_long_with_commission(self): + bars = [OracleBar(100, 100, 100, 100), OracleBar(110, 110, 110, 110)] + signals = [ + OracleSignal(0, "long", "entry", 100), + OracleSignal(1, "long", "exit", 100), + ] + rule = OracleFillRule(commission_rate=0.001) + result = run_oracle(bars, signals, rule) + + t = result.trades[0] + entry_comm = 0.001 * 100 * 100 # 10 + exit_comm = 0.001 * 110 * 100 # 11 + assert t.fees == pytest.approx(entry_comm + exit_comm) + assert t.gross_pnl == pytest.approx(1000.0) + assert t.net_pnl == pytest.approx(1000.0 - 21.0) + + def test_breakeven_long(self): + bars = [OracleBar(100, 100, 100, 100), OracleBar(100, 100, 100, 100)] + signals = [ + OracleSignal(0, "long", "entry", 50), + OracleSignal(1, "long", "exit", 50), + ] + result = run_oracle(bars, signals) + + t = result.trades[0] + assert t.gross_pnl == pytest.approx(0.0) + assert t.pnl_percent == pytest.approx(0.0) + + +class TestOracleShortRoundTrips: + """Verify oracle computes correct values for short trades.""" + + def test_profitable_short_no_costs(self): + bars = [OracleBar(100, 105, 95, 100), OracleBar(90, 95, 85, 90)] + signals = [ + OracleSignal(0, "short", "entry", 100), + OracleSignal(1, "short", "exit", 100), + ] + result = run_oracle(bars, signals) + + t = result.trades[0] + assert t.direction == "short" + assert t.gross_pnl == pytest.approx(1000.0) # (100-90)*100 + assert t.pnl_percent == pytest.approx(0.10) + assert result.final_cash == pytest.approx(101_000.0) + + def test_losing_short_no_costs(self): + bars = [OracleBar(100, 105, 95, 100), OracleBar(110, 115, 105, 110)] + signals = [ + OracleSignal(0, "short", "entry", 100), + OracleSignal(1, "short", "exit", 100), + ] + result = run_oracle(bars, signals) + + t = result.trades[0] + assert t.gross_pnl == pytest.approx(-1000.0) + assert t.pnl_percent == pytest.approx(-0.10) + assert result.final_cash == pytest.approx(99_000.0) + + def test_short_with_commission(self): + bars = [OracleBar(100, 100, 100, 100), OracleBar(90, 90, 90, 90)] + signals = [ + OracleSignal(0, "short", "entry", 100), + OracleSignal(1, "short", "exit", 100), + ] + rule = OracleFillRule(commission_rate=0.001) + result = run_oracle(bars, signals, rule) + + t = result.trades[0] + entry_comm = 0.001 * 100 * 100 # 10 + exit_comm = 0.001 * 90 * 100 # 9 + assert t.fees == pytest.approx(entry_comm + exit_comm) + assert t.gross_pnl == pytest.approx(1000.0) + assert t.net_pnl == pytest.approx(1000.0 - 19.0) + + +class TestOracleCashConservation: + """Verify cash is conserved across all trades.""" + + def test_cash_conservation_long(self): + bars = [ + OracleBar(100, 100, 100, 100), + OracleBar(110, 110, 110, 110), + ] + signals = [ + OracleSignal(0, "long", "entry", 100), + OracleSignal(1, "long", "exit", 100), + ] + rule = OracleFillRule(commission_rate=0.001) + result = run_oracle(bars, signals, rule, initial_cash=50_000) + + expected = 50_000 + result.trades[0].net_pnl + assert result.final_cash == pytest.approx(expected, abs=1e-6) + + def test_cash_conservation_short(self): + bars = [ + OracleBar(100, 100, 100, 100), + OracleBar(90, 90, 90, 90), + ] + signals = [ + OracleSignal(0, "short", "entry", 100), + OracleSignal(1, "short", "exit", 100), + ] + rule = OracleFillRule(commission_rate=0.002) + result = run_oracle(bars, signals, rule, initial_cash=50_000) + + expected = 50_000 + result.trades[0].net_pnl + assert result.final_cash == pytest.approx(expected, abs=1e-6) + + def test_no_signals_preserves_cash(self): + bars = [OracleBar(100, 100, 100, 100)] + result = run_oracle(bars, [], initial_cash=12345.0) + assert result.final_cash == 12345.0 + assert len(result.trades) == 0 + + +class TestOracleEdgeCases: + """Edge cases and boundary conditions.""" + + def test_unmatched_exit_is_ignored(self): + bars = [OracleBar(100, 100, 100, 100)] + signals = [OracleSignal(0, "long", "exit", 100)] + result = run_oracle(bars, signals) + assert len(result.trades) == 0 + assert result.final_cash == 100_000.0 + + def test_duplicate_entry_is_ignored(self): + bars = [ + OracleBar(100, 100, 100, 100), + OracleBar(105, 105, 105, 105), + OracleBar(110, 110, 110, 110), + ] + signals = [ + OracleSignal(0, "long", "entry", 100), + OracleSignal(1, "long", "entry", 100), # Ignored (already in position) + OracleSignal(2, "long", "exit", 100), + ] + result = run_oracle(bars, signals) + assert len(result.trades) == 1 + + def test_next_bar_timing(self): + bars = [ + OracleBar(100, 100, 100, 100), + OracleBar(102, 105, 100, 103), + OracleBar(108, 110, 107, 109), + ] + signals = [ + OracleSignal(0, "long", "entry", 100), + OracleSignal(1, "long", "exit", 100), + ] + rule = OracleFillRule(timing=FillTiming.NEXT_BAR) + result = run_oracle(bars, signals, rule) + + t = result.trades[0] + # Entry: bar 0 signal → fills at bar 1 open = 102 + assert t.entry_price == pytest.approx(102.0) + # Exit: bar 1 signal → fills at bar 2 open = 108 + assert t.exit_price == pytest.approx(108.0) + assert t.gross_pnl == pytest.approx((108 - 102) * 100) diff --git a/tests/property/test_order_type_invariants.py b/tests/property/test_order_type_invariants.py new file mode 100644 index 00000000..f0cfb4c4 --- /dev/null +++ b/tests/property/test_order_type_invariants.py @@ -0,0 +1,218 @@ +"""Property-based order-type invariants. + +Uses Hypothesis to fuzz limit/stop prices and verify fill-level bounds +hold across random price scenarios. Operates at Broker level for speed. +""" + +from __future__ import annotations + +from datetime import datetime + +from hypothesis import given, settings +from hypothesis import strategies as st + +from ml4t.backtest import Broker, OrderSide, OrderType +from ml4t.backtest.models import NoCommission, NoSlippage + + +def _make_broker(cash: float = 200_000.0) -> Broker: + return Broker(cash, NoCommission(), NoSlippage()) + + +def _set_bar( + broker: Broker, + asset: str, + close: float, + open_: float | None = None, + high: float | None = None, + low: float | None = None, +) -> None: + o = open_ if open_ is not None else close + h = high if high is not None else close + lo = low if low is not None else close + broker._update_time( + datetime(2024, 1, 1), + {asset: close}, + {asset: o}, + {asset: h}, + {asset: lo}, + {asset: 1_000_000.0}, + {asset: {}}, + ) + + +# --------------------------------------------------------------------------- +# Limit order bounds +# --------------------------------------------------------------------------- + + +@settings(max_examples=200) +@given( + limit=st.floats(min_value=50.0, max_value=150.0, allow_nan=False, allow_infinity=False), + bar_high=st.floats(min_value=50.0, max_value=200.0, allow_nan=False, allow_infinity=False), + bar_low=st.floats(min_value=10.0, max_value=150.0, allow_nan=False, allow_infinity=False), +) +def test_limit_buy_never_fills_above_limit(limit: float, bar_high: float, bar_low: float) -> None: + """A limit BUY must never fill above the limit price.""" + if bar_low > bar_high: + bar_low, bar_high = bar_high, bar_low + + broker = _make_broker() + close = (bar_high + bar_low) / 2 + open_ = close + + _set_bar(broker, "TEST", close, open_=open_, high=bar_high, low=bar_low) + broker.submit_order("TEST", 10.0, OrderSide.BUY, OrderType.LIMIT, limit_price=limit) + broker._process_orders() + + limit_fills = [f for f in broker.fills if f.order_type == "limit"] + for fill in limit_fills: + assert fill.price <= limit + 1e-8, f"Limit BUY filled at {fill.price} > limit {limit}" + + +@settings(max_examples=200) +@given( + limit=st.floats(min_value=50.0, max_value=150.0, allow_nan=False, allow_infinity=False), + bar_high=st.floats(min_value=50.0, max_value=200.0, allow_nan=False, allow_infinity=False), + bar_low=st.floats(min_value=10.0, max_value=150.0, allow_nan=False, allow_infinity=False), +) +def test_limit_sell_never_fills_below_limit(limit: float, bar_high: float, bar_low: float) -> None: + """A limit SELL must never fill below the limit price.""" + if bar_low > bar_high: + bar_low, bar_high = bar_high, bar_low + + broker = _make_broker() + close = (bar_high + bar_low) / 2 + open_ = close + + _set_bar(broker, "TEST", close, open_=open_, high=bar_high, low=bar_low) + broker.submit_order("TEST", 10.0, OrderSide.SELL, OrderType.LIMIT, limit_price=limit) + broker._process_orders() + + limit_fills = [f for f in broker.fills if f.order_type == "limit"] + for fill in limit_fills: + assert fill.price >= limit - 1e-8, f"Limit SELL filled at {fill.price} < limit {limit}" + + +# --------------------------------------------------------------------------- +# Stop order bounds +# --------------------------------------------------------------------------- + + +@settings(max_examples=200) +@given( + stop=st.floats(min_value=50.0, max_value=200.0, allow_nan=False, allow_infinity=False), + bar_open=st.floats(min_value=50.0, max_value=200.0, allow_nan=False, allow_infinity=False), + bar_high=st.floats(min_value=50.0, max_value=200.0, allow_nan=False, allow_infinity=False), + bar_low=st.floats(min_value=10.0, max_value=200.0, allow_nan=False, allow_infinity=False), +) +def test_stop_buy_never_fills_below_stop( + stop: float, bar_open: float, bar_high: float, bar_low: float +) -> None: + """A stop BUY must never fill below the stop price.""" + if bar_low > bar_high: + bar_low, bar_high = bar_high, bar_low + bar_open = max(bar_low, min(bar_open, bar_high)) + close = (bar_high + bar_low) / 2 + + broker = _make_broker() + _set_bar(broker, "TEST", close, open_=bar_open, high=bar_high, low=bar_low) + broker.submit_order("TEST", 10.0, OrderSide.BUY, OrderType.STOP, stop_price=stop) + broker._process_orders() + + stop_fills = [f for f in broker.fills if f.order_type == "stop"] + for fill in stop_fills: + assert fill.price >= stop - 1e-8, f"Stop BUY filled at {fill.price} < stop {stop}" + + +@settings(max_examples=200) +@given( + stop=st.floats(min_value=10.0, max_value=150.0, allow_nan=False, allow_infinity=False), + bar_open=st.floats(min_value=10.0, max_value=200.0, allow_nan=False, allow_infinity=False), + bar_high=st.floats(min_value=50.0, max_value=200.0, allow_nan=False, allow_infinity=False), + bar_low=st.floats(min_value=10.0, max_value=150.0, allow_nan=False, allow_infinity=False), +) +def test_stop_sell_never_fills_above_stop( + stop: float, bar_open: float, bar_high: float, bar_low: float +) -> None: + """A stop SELL must never fill above the stop price.""" + if bar_low > bar_high: + bar_low, bar_high = bar_high, bar_low + bar_open = max(bar_low, min(bar_open, bar_high)) + close = (bar_high + bar_low) / 2 + + broker = _make_broker() + _set_bar(broker, "TEST", close, open_=bar_open, high=bar_high, low=bar_low) + broker.submit_order("TEST", 10.0, OrderSide.SELL, OrderType.STOP, stop_price=stop) + broker._process_orders() + + stop_fills = [f for f in broker.fills if f.order_type == "stop"] + for fill in stop_fills: + assert fill.price <= stop + 1e-8, f"Stop SELL filled at {fill.price} > stop {stop}" + + +# --------------------------------------------------------------------------- +# Bracket: exactly one exit fires +# --------------------------------------------------------------------------- + + +@settings(max_examples=100) +@given( + entry_price=st.floats(min_value=50.0, max_value=150.0, allow_nan=False, allow_infinity=False), + tp_offset=st.floats(min_value=1.0, max_value=20.0, allow_nan=False, allow_infinity=False), + sl_offset=st.floats(min_value=1.0, max_value=20.0, allow_nan=False, allow_infinity=False), + exit_high=st.floats(min_value=50.0, max_value=200.0, allow_nan=False, allow_infinity=False), + exit_low=st.floats(min_value=10.0, max_value=150.0, allow_nan=False, allow_infinity=False), +) +def test_bracket_exactly_one_exit( + entry_price: float, + tp_offset: float, + sl_offset: float, + exit_high: float, + exit_low: float, +) -> None: + """If a bracket's entry fills and an exit triggers, exactly one exit fills.""" + if exit_low > exit_high: + exit_low, exit_high = exit_high, exit_low + + tp_price = entry_price + tp_offset + sl_price = entry_price - sl_offset + + broker = _make_broker(500_000.0) + + # Bar 0: entry + _set_bar(broker, "TEST", entry_price) + orders = broker.submit_bracket( + "TEST", 10.0, take_profit=tp_price, stop_loss=sl_price, validate_prices=False + ) + assert orders is not None + entry, tp, sl = orders + broker._process_orders() + + if entry.status != broker.__class__.__dict__.get("_x", None): + # Entry should have filled (market order at current price) + pass + + if entry.filled_price is None: + return # Entry didn't fill (shouldn't happen with market) + + # Bar 1: exit bar with random high/low + exit_close = (exit_high + exit_low) / 2 + exit_open = exit_close + _set_bar(broker, "TEST", exit_close, open_=exit_open, high=exit_high, low=exit_low) + broker._process_orders() + + # Check: at most one exit filled + tp_filled = tp.status.value == "filled" + sl_filled = sl.status.value == "filled" + + if tp_filled or sl_filled: + # Exactly one should have filled, not both + assert not (tp_filled and sl_filled), ( + f"Both TP and SL filled! tp={tp.status}, sl={sl.status}" + ) + # The other should be cancelled + if tp_filled: + assert sl.status.value == "cancelled", f"SL not cancelled: {sl.status}" + else: + assert tp.status.value == "cancelled", f"TP not cancelled: {tp.status}" diff --git a/tests/property/test_pnl_invariants.py b/tests/property/test_pnl_invariants.py new file mode 100644 index 00000000..f7ca6cb0 --- /dev/null +++ b/tests/property/test_pnl_invariants.py @@ -0,0 +1,286 @@ +"""Property-based PnL invariant tests using Hypothesis. + +Replaces the existing 109 lines (2 files) with comprehensive invariant testing +over random inputs. ~800 randomized scenarios per test run, with Hypothesis +shrinking to minimal reproducer on failure. + +Bug coverage: + - Bug 1 (short PnL sign): random shorts hit sign mismatch immediately + - Future bugs: random exploration of edge cases +""" + +from __future__ import annotations + +from datetime import datetime + +from hypothesis import given, settings +from hypothesis import strategies as st + +from ml4t.backtest import Broker, OrderSide +from ml4t.backtest.models import NoCommission, NoSlippage, PercentageCommission + + +def _set_bar( + broker: Broker, + price: float, + *, + asset: str = "TEST", + ts: datetime = datetime(2024, 1, 1), + high: float | None = None, + low: float | None = None, +) -> None: + """Set broker bar state for testing.""" + h = high if high is not None else price + lo = low if low is not None else price + broker._update_time( + ts, + {asset: price}, + {asset: price}, + {asset: h}, + {asset: lo}, + {asset: 1_000_000.0}, + {asset: {}}, + ) + + +# ============================================================================ +# Cash Conservation: initial + pnl == final (no costs) +# ============================================================================ + + +@settings(max_examples=200, deadline=5000) +@given( + entry=st.floats(min_value=1.0, max_value=500.0, allow_nan=False, allow_infinity=False), + exit_=st.floats(min_value=1.0, max_value=500.0, allow_nan=False, allow_infinity=False), + qty=st.floats(min_value=0.1, max_value=100.0, allow_nan=False, allow_infinity=False), + direction=st.sampled_from(["long", "short"]), +) +def test_cash_conservation(entry: float, exit_: float, qty: float, direction: str) -> None: + """Cash is perfectly conserved in a round-trip with no costs.""" + initial_cash = 200_000.0 + broker = Broker( + initial_cash, + NoCommission(), + NoSlippage(), + allow_short_selling=True, + allow_leverage=True, + ) + + entry_side = OrderSide.BUY if direction == "long" else OrderSide.SELL + + _set_bar(broker, entry) + broker.submit_order("TEST", qty, entry_side) + broker._process_orders() + + if broker.get_position("TEST") is None: + return # Order rejected (insufficient cash) + + _set_bar(broker, exit_) + broker.close_position("TEST") + broker._process_orders() + + assert broker.get_position("TEST") is None + assert broker.trades + + trade = broker.trades[-1] + if direction == "long": + expected_pnl = (exit_ - entry) * qty + else: + expected_pnl = (entry - exit_) * qty + + assert abs(trade.pnl - expected_pnl) < 1e-6, ( + f"PnL mismatch: expected {expected_pnl}, got {trade.pnl}" + ) + assert abs((initial_cash + expected_pnl) - broker.cash) < 1e-6, ( + f"Cash mismatch: expected {initial_cash + expected_pnl}, got {broker.cash}" + ) + assert abs(broker.get_account_value() - broker.cash) < 1e-6 + + +# ============================================================================ +# PnL Sign == PnL Percent Sign (skip breakeven) +# ============================================================================ + + +@settings(max_examples=200, deadline=5000) +@given( + entry=st.floats(min_value=1.0, max_value=500.0, allow_nan=False, allow_infinity=False), + exit_=st.floats(min_value=1.0, max_value=500.0, allow_nan=False, allow_infinity=False), + qty=st.floats(min_value=0.1, max_value=100.0, allow_nan=False, allow_infinity=False), + direction=st.sampled_from(["long", "short"]), +) +def test_pnl_sign_matches_pnl_percent_sign( + entry: float, + exit_: float, + qty: float, + direction: str, +) -> None: + """sign(pnl) must equal sign(pnl_percent) for non-zero PnL.""" + if abs(entry - exit_) < 0.01: + return # Skip near-breakeven (floating point noise) + + initial_cash = 200_000.0 + broker = Broker( + initial_cash, + NoCommission(), + NoSlippage(), + allow_short_selling=True, + allow_leverage=True, + ) + + entry_side = OrderSide.BUY if direction == "long" else OrderSide.SELL + + _set_bar(broker, entry) + broker.submit_order("TEST", qty, entry_side) + broker._process_orders() + + if broker.get_position("TEST") is None: + return # Order rejected + + _set_bar(broker, exit_) + broker.close_position("TEST") + broker._process_orders() + + if not broker.trades: + return + + trade = broker.trades[-1] + if abs(trade.pnl) < 1e-8: + return # Breakeven + + pnl_sign = 1 if trade.pnl > 0 else -1 + pct_sign = 1 if trade.pnl_percent > 0 else -1 + + assert pnl_sign == pct_sign, ( + f"Sign mismatch: pnl={trade.pnl} (sign={pnl_sign}), " + f"pnl_percent={trade.pnl_percent} (sign={pct_sign}), " + f"direction={direction}, entry={entry}, exit={exit_}, qty={qty}" + ) + + +# ============================================================================ +# Gross - Fees == Net (with random commission rate) +# ============================================================================ + + +@settings(max_examples=200, deadline=5000) +@given( + entry=st.floats(min_value=10.0, max_value=1000.0, allow_nan=False, allow_infinity=False), + exit_=st.floats(min_value=10.0, max_value=1000.0, allow_nan=False, allow_infinity=False), + qty=st.floats(min_value=1.0, max_value=500.0, allow_nan=False, allow_infinity=False), + direction=st.sampled_from(["long", "short"]), + comm_rate=st.floats(min_value=0.0, max_value=0.01, allow_nan=False, allow_infinity=False), +) +def test_gross_minus_fees_equals_net( + entry: float, + exit_: float, + qty: float, + direction: str, + comm_rate: float, +) -> None: + """gross_pnl - fees == pnl for any commission rate.""" + initial_cash = 500_000.0 + broker = Broker( + initial_cash, + PercentageCommission(comm_rate), + NoSlippage(), + allow_short_selling=True, + allow_leverage=True, + ) + + entry_side = OrderSide.BUY if direction == "long" else OrderSide.SELL + + _set_bar(broker, entry) + broker.submit_order("TEST", qty, entry_side) + broker._process_orders() + + _set_bar(broker, exit_) + broker.close_position("TEST") + broker._process_orders() + + if not broker.trades: + return # Trade may have been rejected + + trade = broker.trades[-1] + expected_net = trade.gross_pnl - trade.fees + assert abs(expected_net - trade.pnl) < max(1e-6, abs(trade.gross_pnl) * 1e-6), ( + f"Decomposition failed: gross({trade.gross_pnl}) - fees({trade.fees}) " + f"= {expected_net} != pnl({trade.pnl})" + ) + + +# ============================================================================ +# Price Scale Invariance: returns unchanged when scaling prices and cash +# ============================================================================ + + +@settings(max_examples=100, deadline=5000) +@given( + base_entry=st.floats(min_value=10.0, max_value=1000.0, allow_nan=False, allow_infinity=False), + base_exit=st.floats(min_value=10.0, max_value=1000.0, allow_nan=False, allow_infinity=False), + qty=st.floats(min_value=1.0, max_value=100.0, allow_nan=False, allow_infinity=False), + scale=st.floats(min_value=0.1, max_value=100.0, allow_nan=False, allow_infinity=False), + direction=st.sampled_from(["long", "short"]), +) +def test_price_scale_invariance( + base_entry: float, + base_exit: float, + qty: float, + scale: float, + direction: str, +) -> None: + """Percentage returns should be the same regardless of price scale.""" + if abs(base_entry - base_exit) < 0.01: + return + + def _run_trip(entry_p, exit_p, cash): + broker = Broker( + cash, + NoCommission(), + NoSlippage(), + allow_short_selling=True, + allow_leverage=True, + ) + side = OrderSide.BUY if direction == "long" else OrderSide.SELL + _set_bar(broker, entry_p) + broker.submit_order("TEST", qty, side) + broker._process_orders() + _set_bar(broker, exit_p) + broker.close_position("TEST") + broker._process_orders() + return broker.trades[-1] if broker.trades else None + + t1 = _run_trip(base_entry, base_exit, 500_000.0) + t2 = _run_trip(base_entry * scale, base_exit * scale, 500_000.0 * scale) + + if t1 is None or t2 is None: + return + + assert abs(t1.pnl_percent - t2.pnl_percent) < 1e-6, ( + f"Returns differ with scale={scale}: {t1.pnl_percent} vs {t2.pnl_percent}" + ) + + +# ============================================================================ +# Idempotent Close: closing an already-flat position has no effect +# ============================================================================ + + +@settings(max_examples=100, deadline=5000) +@given( + price=st.floats(min_value=1.0, max_value=10_000.0, allow_nan=False, allow_infinity=False), +) +def test_idempotent_close(price: float) -> None: + """Closing a position that doesn't exist should have no effect.""" + broker = Broker(100_000.0, NoCommission(), NoSlippage()) + + _set_bar(broker, price) + cash_before = broker.cash + trades_before = len(broker.trades) + + broker.close_position("TEST") + broker._process_orders() + + assert broker.cash == cash_before + assert len(broker.trades) == trades_before + assert broker.get_position("TEST") is None diff --git a/tests/scenarios/__init__.py b/tests/scenarios/__init__.py new file mode 100644 index 00000000..60c17c8b --- /dev/null +++ b/tests/scenarios/__init__.py @@ -0,0 +1,9 @@ +"""Answer-first scenario factory for parametric testing. + +Expected results are computed analytically BEFORE the SUT runs, +ensuring the test is truly independent. +""" + +from .factory import ExpectedResult, Scenario, make_round_trip + +__all__ = ["make_round_trip", "Scenario", "ExpectedResult"] diff --git a/tests/scenarios/factory.py b/tests/scenarios/factory.py new file mode 100644 index 00000000..569d2dcc --- /dev/null +++ b/tests/scenarios/factory.py @@ -0,0 +1,150 @@ +"""Scenario factory with analytically computed expected results. + +Build scenarios where the expected result is computed BEFORE the SUT runs. +The factory uses only basic arithmetic — no shared code with ml4t.backtest. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta + +import polars as pl + + +@dataclass(frozen=True) +class ExpectedResult: + """Analytically computed expected values for a round-trip trade.""" + + direction: str + entry_price: float + exit_price: float + quantity: float + gross_pnl: float + fees: float + net_pnl: float + pnl_percent: float # Gross return on notional (direction-aware) + final_cash: float + + +@dataclass +class Scenario: + """Complete test scenario with prices and expected results.""" + + name: str + prices_df: pl.DataFrame + expected: ExpectedResult + config_overrides: dict + entry_bar: int = 0 + exit_bar: int = 2 + + +def make_round_trip( + entry_price: float, + exit_price: float, + quantity: float = 100.0, + direction: str = "long", + commission_rate: float = 0.0, + slippage_rate: float = 0.0, + initial_cash: float = 100_000.0, + asset: str = "TEST", +) -> Scenario: + """Create a round-trip scenario with analytically computed expected results. + + Args: + entry_price: Price at entry bar. + exit_price: Price at exit bar. + quantity: Unsigned trade quantity. + direction: "long" or "short". + commission_rate: Percentage commission (0.001 = 0.1%). + slippage_rate: Percentage slippage (0.001 = 0.1%). + initial_cash: Starting cash. + asset: Asset symbol. + + Returns: + Scenario with prices DataFrame and analytically computed expected results. + """ + # --- Compute fill prices with slippage --- + if direction == "long": + # Buy: slippage increases price; Sell: slippage decreases price + actual_entry = entry_price * (1.0 + slippage_rate) + actual_exit = exit_price * (1.0 - slippage_rate) + else: + # Sell (entry): slippage decreases price; Buy (exit): slippage increases price + actual_entry = entry_price * (1.0 - slippage_rate) + actual_exit = exit_price * (1.0 + slippage_rate) + + # --- Compute fees --- + entry_fee = commission_rate * actual_entry * quantity + exit_fee = commission_rate * actual_exit * quantity + total_fees = entry_fee + exit_fee + + # --- Compute PnL --- + if direction == "long": + gross_pnl = (actual_exit - actual_entry) * quantity + else: + gross_pnl = (actual_entry - actual_exit) * quantity + + net_pnl = gross_pnl - total_fees + + # --- Compute return --- + notional = actual_entry * quantity + pnl_percent = gross_pnl / notional if notional > 0 else 0.0 + + # --- Compute final cash --- + if direction == "long": + cash_after_entry = initial_cash - actual_entry * quantity - entry_fee + final_cash = cash_after_entry + actual_exit * quantity - exit_fee + else: + cash_after_entry = initial_cash + actual_entry * quantity - entry_fee + final_cash = cash_after_entry - actual_exit * quantity - exit_fee + + # --- Build prices DataFrame --- + # 3 bars: entry, intermediate, exit + start = datetime(2024, 1, 1) + timestamps = [start + timedelta(days=i) for i in range(3)] + + # All bars at entry_price except exit bar at exit_price + closes = [entry_price, (entry_price + exit_price) / 2, exit_price] + + prices_df = pl.DataFrame( + { + "timestamp": timestamps, + "asset": [asset] * 3, + "open": closes, + "high": closes, + "low": closes, + "close": closes, + "volume": [1_000_000.0] * 3, + } + ) + + expected = ExpectedResult( + direction=direction, + entry_price=actual_entry, + exit_price=actual_exit, + quantity=quantity, + gross_pnl=gross_pnl, + fees=total_fees, + net_pnl=net_pnl, + pnl_percent=pnl_percent, + final_cash=final_cash, + ) + + config_overrides = { + "commission_rate": commission_rate, + "slippage_rate": slippage_rate, + "initial_cash": initial_cash, + "allow_short_selling": True, + "allow_leverage": True, + "execution_mode": "SAME_BAR", + } + + name = f"{direction}_{entry_price:.0f}_{exit_price:.0f}_q{quantity:.0f}_c{commission_rate}_s{slippage_rate}" + + return Scenario( + name=name, + prices_df=prices_df, + expected=expected, + config_overrides=config_overrides, + ) diff --git a/tests/scenarios/test_parametric.py b/tests/scenarios/test_parametric.py new file mode 100644 index 00000000..8a2d6547 --- /dev/null +++ b/tests/scenarios/test_parametric.py @@ -0,0 +1,124 @@ +"""Parametric sweep: directions x prices x quantities x commissions. + +2 directions x 5 price pairs x 3 quantities x 3 commission rates = 90 scenarios, +each with analytically computed expected results. +""" + +from __future__ import annotations + +from itertools import product + +import pytest + +from ml4t.backtest import BacktestConfig, DataFeed, Engine + +from ..helpers.strategies import RoundTripStrategy +from .factory import Scenario, make_round_trip + +# ============================================================================ +# Build parametric sweep +# ============================================================================ + +DIRECTIONS = ["long", "short"] +PRICE_PAIRS = [(100.0, 110.0), (100.0, 90.0), (100.0, 100.0), (50.0, 75.0), (200.0, 180.0)] +QUANTITIES = [10.0, 100.0, 500.0] +COMMISSIONS = [0.0, 0.001, 0.005] + +SCENARIOS: list[Scenario] = [] +for direction, (ep, xp), qty, cr in product(DIRECTIONS, PRICE_PAIRS, QUANTITIES, COMMISSIONS): + SCENARIOS.append(make_round_trip(ep, xp, qty, direction, commission_rate=cr)) + + +@pytest.mark.parametrize("scenario", SCENARIOS, ids=[s.name for s in SCENARIOS]) +def test_round_trip_matches_expected(scenario: Scenario): + """Run SUT and compare each field against analytically computed expected values.""" + config = BacktestConfig(**scenario.config_overrides) + feed = DataFeed(prices_df=scenario.prices_df) + strategy = RoundTripStrategy( + asset="TEST", + qty=scenario.expected.quantity, + entry_bar=scenario.entry_bar, + exit_bar=scenario.exit_bar, + direction=scenario.expected.direction, + ) + engine = Engine(feed, strategy, config) + result = engine.run() + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1, f"Expected 1 closed trade, got {len(closed)}" + trade = closed[0] + + exp = scenario.expected + tol = max(0.02, abs(exp.gross_pnl) * 1e-4) # Adaptive tolerance + + # Direction + assert trade.direction == exp.direction + + # Gross PnL + assert trade.gross_pnl == pytest.approx(exp.gross_pnl, abs=tol), ( + f"Gross PnL: SUT={trade.gross_pnl}, expected={exp.gross_pnl}" + ) + + # Fees + assert trade.fees == pytest.approx(exp.fees, abs=tol), ( + f"Fees: SUT={trade.fees}, expected={exp.fees}" + ) + + # Net PnL (= pnl field on Trade) + assert trade.pnl == pytest.approx(exp.net_pnl, abs=tol), ( + f"Net PnL: SUT={trade.pnl}, expected={exp.net_pnl}" + ) + + # PnL percent (gross return) + if abs(exp.pnl_percent) > 1e-8: + assert trade.pnl_percent == pytest.approx(exp.pnl_percent, abs=1e-4), ( + f"PnL%: SUT={trade.pnl_percent}, expected={exp.pnl_percent}" + ) + + # Final portfolio value + if result.equity_curve: + final_value = result.equity_curve[-1][1] + assert final_value == pytest.approx(exp.final_cash, abs=tol), ( + f"Final cash: SUT={final_value}, expected={exp.final_cash}" + ) + + +# ============================================================================ +# Slippage scenarios (separate sweep since they interact with fill prices) +# ============================================================================ + +SLIPPAGE_SCENARIOS: list[Scenario] = [] +for direction, (ep, xp) in product(DIRECTIONS, [(100.0, 110.0), (100.0, 90.0)]): + for slip in [0.001, 0.005]: + SLIPPAGE_SCENARIOS.append(make_round_trip(ep, xp, 100.0, direction, slippage_rate=slip)) + + +@pytest.mark.parametrize( + "scenario", + SLIPPAGE_SCENARIOS, + ids=[s.name for s in SLIPPAGE_SCENARIOS], +) +def test_slippage_scenario_matches_expected(scenario: Scenario): + """Verify slippage scenarios match expected results.""" + config = BacktestConfig(**scenario.config_overrides) + feed = DataFeed(prices_df=scenario.prices_df) + strategy = RoundTripStrategy( + asset="TEST", + qty=scenario.expected.quantity, + entry_bar=scenario.entry_bar, + exit_bar=scenario.exit_bar, + direction=scenario.expected.direction, + ) + engine = Engine(feed, strategy, config) + result = engine.run() + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + trade = closed[0] + exp = scenario.expected + tol = max(0.05, abs(exp.gross_pnl) * 1e-3) + + assert trade.direction == exp.direction + assert trade.pnl == pytest.approx(exp.net_pnl, abs=tol), ( + f"Net PnL: SUT={trade.pnl}, expected={exp.net_pnl}" + ) diff --git a/tests/test_bracket_lifecycle.py b/tests/test_bracket_lifecycle.py new file mode 100644 index 00000000..98c9973d --- /dev/null +++ b/tests/test_bracket_lifecycle.py @@ -0,0 +1,302 @@ +"""Bracket order lifecycle tests. + +Full lifecycle verification: entry fills → both exits active → one fires +→ sibling auto-cancels. Uses set_broker_bar for bar-by-bar control. +""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from ml4t.backtest import Broker, OrderStatus, OrderType +from ml4t.backtest.models import NoCommission, NoSlippage + +from .helpers import set_broker_bar + + +@pytest.fixture(params=["long", "short"]) +def direction(request): + return request.param + + +def _make_broker(cash: float = 100_000.0) -> Broker: + return Broker(cash, NoCommission(), NoSlippage(), allow_short_selling=True) + + +def _ts(day: int = 1) -> datetime: + return datetime(2024, 1, day) + + +def _submit_bracket(broker, direction, price=100.0, tp_offset=5.0, sl_offset=5.0): + """Submit a bracket order and return (entry, tp, sl) orders.""" + if direction == "long": + qty = 100.0 + tp_price = price + tp_offset + sl_price = price - sl_offset + else: + qty = -100.0 + tp_price = price - tp_offset + sl_price = price + sl_offset + + return broker.submit_bracket( + "TEST", qty, take_profit=tp_price, stop_loss=sl_price, validate_prices=False + ) + + +class TestBracketTPFills: + """Verify take-profit fills and stop-loss cancellation.""" + + def test_bracket_tp_fills_cancels_sl(self, direction): + """TP fires → SL cancelled.""" + broker = _make_broker() + + # Bar 0: set price and submit bracket + set_broker_bar(broker, 100.0, ts=_ts(1)) + orders = _submit_bracket(broker, direction) + assert orders is not None + entry, tp, sl = orders + + # Bar 1: fill entry + broker._process_orders() + assert entry.status == OrderStatus.FILLED + + # Bar 2: price moves to hit TP + if direction == "long": + # TP at 105, make high reach 106 + set_broker_bar(broker, 105.0, ts=_ts(2), high=106.0, low=100.0) + else: + # TP at 95, make low reach 94 + set_broker_bar(broker, 95.0, ts=_ts(2), high=100.0, low=94.0) + + broker._process_orders() + + # TP should be filled, SL should be cancelled + assert tp.status == OrderStatus.FILLED + assert sl.status == OrderStatus.CANCELLED + + # Position should be closed + assert broker.get_position("TEST") is None + + # Trade should exist with exit_reason + assert len(broker.trades) == 1 + + def test_bracket_sl_fills_cancels_tp(self, direction): + """SL fires → TP cancelled.""" + broker = _make_broker() + + set_broker_bar(broker, 100.0, ts=_ts(1)) + orders = _submit_bracket(broker, direction) + assert orders is not None + entry, tp, sl = orders + + broker._process_orders() + assert entry.status == OrderStatus.FILLED + + # Bar 2: price moves to hit SL + if direction == "long": + # SL at 95, make low reach 94 + set_broker_bar(broker, 95.0, ts=_ts(2), high=100.0, low=94.0) + else: + # SL at 105, make high reach 106 + set_broker_bar(broker, 105.0, ts=_ts(2), high=106.0, low=100.0) + + broker._process_orders() + + assert sl.status == OrderStatus.FILLED + assert tp.status == OrderStatus.CANCELLED + assert broker.get_position("TEST") is None + assert len(broker.trades) == 1 + + +class TestBracketEntryPending: + """Verify bracket behavior when entry hasn't filled.""" + + def test_bracket_entry_not_filled_exits_stay_pending(self, direction): + """Entry pending → exits stay pending.""" + broker = _make_broker() + + set_broker_bar(broker, 100.0, ts=_ts(1)) + + # Use a limit entry that won't fill + if direction == "long": + qty = 100.0 + entry_limit = 90.0 # Too far below market + tp_price = 110.0 + sl_price = 85.0 + else: + qty = -100.0 + entry_limit = 110.0 # Too far above market + tp_price = 90.0 + sl_price = 115.0 + + orders = broker.submit_bracket( + "TEST", + qty, + take_profit=tp_price, + stop_loss=sl_price, + entry_type=OrderType.LIMIT, + entry_limit=entry_limit, + validate_prices=False, + ) + assert orders is not None + entry, tp, sl = orders + + # Bar 1-2: price doesn't reach entry limit + set_broker_bar(broker, 100.0, ts=_ts(2), high=101.0, low=99.0) + broker._process_orders() + + # Entry not filled, but exits are pending (they won't trigger without position) + assert entry.status == OrderStatus.PENDING + assert len(broker.trades) == 0 + + +class TestBracketGapThrough: + """Verify gap-through behavior on bracket exits.""" + + def test_bracket_gap_through_sl(self, direction): + """Gap through SL → fill at open, TP cancelled.""" + broker = _make_broker() + + set_broker_bar(broker, 100.0, ts=_ts(1)) + orders = _submit_bracket(broker, direction) + assert orders is not None + entry, tp, sl = orders + + broker._process_orders() + assert entry.status == OrderStatus.FILLED + + # Bar 2: gap through SL + if direction == "long": + # SL at 95, gap open at 90 + set_broker_bar(broker, 91.0, ts=_ts(2), open_=90.0, high=92.0, low=89.0) + else: + # SL at 105, gap open at 110 + set_broker_bar(broker, 109.0, ts=_ts(2), open_=110.0, high=111.0, low=108.0) + + broker._process_orders() + + assert sl.status == OrderStatus.FILLED + assert tp.status == OrderStatus.CANCELLED + + # Fill should be at gap-through open price + trade = broker.trades[0] + if direction == "long": + assert trade.exit_price == 90.0 # gap open + else: + assert trade.exit_price == 110.0 # gap open + + +class TestBracketSequentialExits: + """Verify sequential exit processing in brackets.""" + + def test_bracket_tp_then_sl_bar(self, direction): + """TP fires on bar 2, SL would fire on bar 3 but is already cancelled.""" + broker = _make_broker() + + set_broker_bar(broker, 100.0, ts=_ts(1)) + orders = _submit_bracket(broker, direction) + assert orders is not None + entry, tp, sl = orders + + broker._process_orders() + assert entry.status == OrderStatus.FILLED + + # Bar 2: only TP triggers + if direction == "long": + # TP at 105, price hits 106 but doesn't drop to 95 (SL) + set_broker_bar(broker, 106.0, ts=_ts(2), open_=103.0, high=106.0, low=102.0) + else: + # TP at 95, price drops to 94 but doesn't rise to 105 (SL) + set_broker_bar(broker, 94.0, ts=_ts(2), open_=97.0, high=98.0, low=94.0) + + broker._process_orders() + + assert tp.status == OrderStatus.FILLED + assert sl.status == OrderStatus.CANCELLED + assert broker.get_position("TEST") is None + assert len(broker.trades) == 1 + + def test_bracket_sl_then_tp_bar(self, direction): + """SL fires on bar 2, TP would fire on bar 3 but is already cancelled.""" + broker = _make_broker() + + set_broker_bar(broker, 100.0, ts=_ts(1)) + orders = _submit_bracket(broker, direction) + assert orders is not None + entry, tp, sl = orders + + broker._process_orders() + assert entry.status == OrderStatus.FILLED + + # Bar 2: only SL triggers + if direction == "long": + # SL at 95, price drops to 94 but doesn't rise to 105 (TP) + set_broker_bar(broker, 94.0, ts=_ts(2), open_=97.0, high=98.0, low=94.0) + else: + # SL at 105, price rises to 106 but doesn't drop to 95 (TP) + set_broker_bar(broker, 106.0, ts=_ts(2), open_=103.0, high=106.0, low=102.0) + + broker._process_orders() + + assert sl.status == OrderStatus.FILLED + assert tp.status == OrderStatus.CANCELLED + assert broker.get_position("TEST") is None + assert len(broker.trades) == 1 + + +class TestBracketPnLConsistency: + """Verify trade PnL matches expected values for each exit type.""" + + def test_bracket_tp_pnl(self, direction): + """TP fill → PnL matches expected profit.""" + broker = _make_broker() + entry_price = 100.0 + tp_offset = 5.0 + + set_broker_bar(broker, entry_price, ts=_ts(1)) + orders = _submit_bracket(broker, direction, price=entry_price, tp_offset=tp_offset) + assert orders is not None + entry, tp, sl = orders + + broker._process_orders() + + # Hit TP + if direction == "long": + set_broker_bar(broker, 106.0, ts=_ts(2), high=106.0, low=100.0) + else: + set_broker_bar(broker, 94.0, ts=_ts(2), high=100.0, low=94.0) + + broker._process_orders() + + trade = broker.trades[0] + expected_pnl = tp_offset * 100.0 # qty=100, offset=5 + assert abs(trade.pnl - expected_pnl) < 0.01 + + def test_bracket_sl_pnl(self, direction): + """SL fill → PnL matches expected loss.""" + broker = _make_broker() + entry_price = 100.0 + sl_offset = 5.0 + + set_broker_bar(broker, entry_price, ts=_ts(1)) + orders = _submit_bracket(broker, direction, price=entry_price, sl_offset=sl_offset) + assert orders is not None + entry, tp, sl = orders + + broker._process_orders() + + # Hit SL (open inside range to avoid gap-through) + if direction == "long": + # SL at 95, open at 98 (inside range), low=94 triggers stop + set_broker_bar(broker, 94.0, ts=_ts(2), open_=98.0, high=100.0, low=94.0) + else: + # SL at 105, open at 102 (inside range), high=106 triggers stop + set_broker_bar(broker, 106.0, ts=_ts(2), open_=102.0, high=106.0, low=100.0) + + broker._process_orders() + + trade = broker.trades[0] + expected_pnl = -sl_offset * 100.0 # loss + assert abs(trade.pnl - expected_pnl) < 0.01 diff --git a/tests/test_broker.py b/tests/test_broker.py index 60c1d885..a7117cb1 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -5,7 +5,7 @@ import pytest from ml4t.backtest.broker import Broker -from ml4t.backtest.models import NoCommission, NoSlippage +from ml4t.backtest.models import NoCommission, NoSlippage, PercentageCommission from ml4t.backtest.types import ( ExecutionMode, Order, @@ -759,6 +759,55 @@ def test_position_scaling_up(self): expected_entry = (100 * 150.0 + 50 * 160.0) / 150 assert abs(pos.entry_price - expected_entry) < 0.01 + def test_position_scaling_up_accumulates_entry_commission(self): + """Scale-ins should include all entry-side commissions in final trade PnL.""" + broker = Broker( + initial_cash=1000.0, + commission_model=PercentageCommission(0.01), + slippage_model=NoSlippage(), + ) + + broker._update_time( + timestamp=datetime(2024, 1, 1, 9, 30), + prices={"AAPL": 10.0}, + opens={"AAPL": 10.0}, + volumes={"AAPL": 1_000_000}, + highs={"AAPL": 10.0}, + lows={"AAPL": 10.0}, + signals={}, + ) + broker.submit_order("AAPL", 10.0, OrderSide.BUY) + broker._process_orders() + + broker._update_time( + timestamp=datetime(2024, 1, 2, 9, 30), + prices={"AAPL": 10.0}, + opens={"AAPL": 10.0}, + volumes={"AAPL": 1_000_000}, + highs={"AAPL": 10.0}, + lows={"AAPL": 10.0}, + signals={}, + ) + broker.submit_order("AAPL", 10.0, OrderSide.BUY) + broker._process_orders() + + broker._update_time( + timestamp=datetime(2024, 1, 3, 9, 30), + prices={"AAPL": 10.0}, + opens={"AAPL": 10.0}, + volumes={"AAPL": 1_000_000}, + highs={"AAPL": 10.0}, + lows={"AAPL": 10.0}, + signals={}, + ) + broker.submit_order("AAPL", 20.0, OrderSide.SELL) + broker._process_orders() + + assert len(broker.trades) == 1 + trade = broker.trades[0] + assert trade.fees == pytest.approx(4.0) + assert trade.pnl == pytest.approx(-4.0) + def test_position_scaling_down_short(self): """Test adding to short position (scaling down).""" broker = Broker( diff --git a/tests/test_calendar.py b/tests/test_calendar.py index f58184be..a367ef77 100644 --- a/tests/test_calendar.py +++ b/tests/test_calendar.py @@ -473,6 +473,22 @@ def test_generate_trading_minutes_invalid_freq(self): with pytest.raises(ValueError): generate_trading_minutes("NYSE", date(2024, 6, 3), date(2024, 6, 3), freq="2m") + def test_generate_trading_minutes_excludes_breaks(self): + """Test minute generation skips intraday exchange break windows.""" + minutes = generate_trading_minutes( + "XTKS", + date(2025, 1, 6), + date(2025, 1, 6), + freq="30m", + include_close=False, + ) + minute_set = set(minutes.to_list()) + + # XTKS lunch break is 11:30-12:30 JST (02:30-03:30 UTC) + assert datetime(2025, 1, 6, 3, 0, tzinfo=UTC) not in minute_set + assert datetime(2025, 1, 6, 2, 0, tzinfo=UTC) in minute_set + assert datetime(2025, 1, 6, 3, 30, tzinfo=UTC) in minute_set + class TestEdgeCases: """Tests for edge cases and boundary conditions.""" diff --git a/tests/test_datafeed_memory.py b/tests/test_datafeed_memory.py index 20875411..f815f73d 100644 --- a/tests/test_datafeed_memory.py +++ b/tests/test_datafeed_memory.py @@ -230,3 +230,22 @@ def test_single_bar_single_asset(self): ts, data, ctx = next(iter(feed)) assert ts == datetime(2020, 1, 1) assert data["AAPL"]["close"] == 100.5 + + def test_zero_close_is_kept_in_price_view(self): + """A valid zero close should still be available to broker price views.""" + prices = pl.DataFrame( + { + "timestamp": [datetime(2020, 1, 1)], + "asset": ["AAPL"], + "open": [0.0], + "high": [0.0], + "low": [0.0], + "close": [0.0], + "volume": [1_000_000], + } + ) + feed = DataFeed(prices_df=prices) + + _ts, data, _ctx = next(iter(feed)) + assert data["AAPL"]["close"] == 0.0 + assert data._prices["AAPL"] == 0.0 diff --git a/tests/test_direction_matrix.py b/tests/test_direction_matrix.py new file mode 100644 index 00000000..d403c89f --- /dev/null +++ b/tests/test_direction_matrix.py @@ -0,0 +1,464 @@ +"""Direction parameterization matrix: long x short for every behavioral feature. + +If something works for longs but silently fails for shorts, it fails here. +Every test runs a full engine cycle (not isolated components) to catch +composition bugs. + +Bug coverage: + - Bug 1 (short P&L sign): TestRoundTripPnL, TestMFEMAEDirectional + - Bug 3 (trailing stop defer_fill): TestRiskRulesDirectional +""" + +from __future__ import annotations + +import polars as pl +import pytest + +from ml4t.backtest import ( + BacktestConfig, + DataFeed, + Engine, + StopFillMode, + StopLoss, + TakeProfit, + TrailingStop, +) + +from .helpers.data import make_ohlcv_prices, make_prices +from .helpers.strategies import RoundTripStrategy + +# ============================================================================ +# Fixtures +# ============================================================================ + +DIRECTIONS = ["long", "short"] + + +def _short_config(**overrides) -> BacktestConfig: + """Config that allows short selling with SAME_BAR fills.""" + defaults = { + "allow_short_selling": True, + "allow_leverage": True, + "commission_rate": 0.0, + "slippage_rate": 0.0, + "execution_mode": "SAME_BAR", + } + defaults.update(overrides) + return BacktestConfig(**defaults) + + +def _run_round_trip( + prices_df: pl.DataFrame, + direction: str, + entry_bar: int = 0, + exit_bar: int = 2, + qty: float = 100.0, + config: BacktestConfig | None = None, + risk_rules: list | None = None, +): + """Run a round-trip strategy and return the result.""" + if config is None: + config = _short_config() + + feed = DataFeed(prices_df=prices_df) + strategy = RoundTripStrategy( + asset="TEST", + qty=qty, + entry_bar=entry_bar, + exit_bar=exit_bar, + direction=direction, + ) + + engine = Engine(feed, strategy, config) + + if risk_rules: + from ml4t.backtest.risk.position.composite import RuleChain + + chain = RuleChain(risk_rules) + engine.broker.set_position_rules(chain) + + return engine.run() + + +# ============================================================================ +# TestRoundTripPnL: Profitable, losing, breakeven — both directions +# ============================================================================ + + +class TestRoundTripPnL: + """Verify PnL correctness for both long and short round trips.""" + + @pytest.mark.parametrize("direction", DIRECTIONS) + def test_profitable_trade(self, direction): + """Long: buy low sell high. Short: sell high buy low.""" + if direction == "long": + closes = [100.0, 105.0, 110.0] + else: + closes = [110.0, 105.0, 100.0] + + prices = make_prices(closes) + result = _run_round_trip(prices, direction) + + assert len([t for t in result.trades if t.status == "closed"]) == 1 + trade = [t for t in result.trades if t.status == "closed"][0] + + assert trade.pnl > 0, f"Expected profitable trade, got pnl={trade.pnl}" + assert trade.pnl_percent > 0, f"Expected positive return, got {trade.pnl_percent}" + assert trade.direction == direction + + @pytest.mark.parametrize("direction", DIRECTIONS) + def test_losing_trade(self, direction): + """Long: buy high sell low. Short: sell low buy high.""" + if direction == "long": + closes = [110.0, 105.0, 100.0] + else: + closes = [100.0, 105.0, 110.0] + + prices = make_prices(closes) + result = _run_round_trip(prices, direction) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + trade = closed[0] + + assert trade.pnl < 0, f"Expected losing trade, got pnl={trade.pnl}" + assert trade.pnl_percent < 0, f"Expected negative return, got {trade.pnl_percent}" + + @pytest.mark.parametrize("direction", DIRECTIONS) + def test_breakeven_trade(self, direction): + """Entry and exit at same price.""" + closes = [100.0, 105.0, 100.0] + prices = make_prices(closes) + result = _run_round_trip(prices, direction) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + trade = closed[0] + + assert abs(trade.pnl) < 1e-8, f"Expected breakeven, got pnl={trade.pnl}" + assert abs(trade.pnl_percent) < 1e-8 + + @pytest.mark.parametrize("direction", DIRECTIONS) + def test_pnl_dollar_amount(self, direction): + """Verify exact dollar PnL: (exit - entry) * signed_qty.""" + if direction == "long": + closes = [100.0, 110.0, 120.0] + expected_pnl = (120.0 - 100.0) * 100.0 # +2000 + else: + closes = [120.0, 110.0, 100.0] + expected_pnl = (120.0 - 100.0) * 100.0 # +2000 (short sells at 120, buys at 100) + + prices = make_prices(closes) + result = _run_round_trip(prices, direction) + + closed = [t for t in result.trades if t.status == "closed"] + trade = closed[0] + assert abs(trade.pnl - expected_pnl) < 1e-6 + + +# ============================================================================ +# TestMFEMAEDirectional: MFE/MAE tracking for both directions +# ============================================================================ + + +class TestMFEMAEDirectional: + """Verify MFE/MAE tracking is direction-aware.""" + + @pytest.mark.parametrize("direction", DIRECTIONS) + def test_mfe_positive_for_favorable_move(self, direction): + """MFE should capture the best favorable excursion.""" + if direction == "long": + # Price goes up then back — MFE should capture the peak + closes = [100.0, 115.0, 110.0, 105.0] + else: + # Price goes down then back — MFE should capture the trough + closes = [100.0, 85.0, 90.0, 95.0] + + prices = make_prices(closes) + result = _run_round_trip(prices, direction, exit_bar=3) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + trade = closed[0] + + assert trade.mfe > 0, f"MFE should be positive, got {trade.mfe}" + + @pytest.mark.parametrize("direction", DIRECTIONS) + def test_mae_negative_for_adverse_move(self, direction): + """MAE should capture the worst adverse excursion.""" + if direction == "long": + # Price drops then recovers + closes = [100.0, 90.0, 95.0, 110.0] + else: + # Price rises then recovers + closes = [100.0, 110.0, 105.0, 90.0] + + prices = make_prices(closes) + result = _run_round_trip(prices, direction, exit_bar=3) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + trade = closed[0] + + assert trade.mae < 0, f"MAE should be negative, got {trade.mae}" + + +# ============================================================================ +# TestCostDecompositionDirectional +# ============================================================================ + + +class TestCostDecompositionDirectional: + """Verify gross - fees == net for both directions x commission rates.""" + + @pytest.mark.parametrize("direction", DIRECTIONS) + @pytest.mark.parametrize("commission_rate", [0.0, 0.001, 0.005]) + def test_gross_minus_fees_equals_net(self, direction, commission_rate): + if direction == "long": + closes = [100.0, 105.0, 110.0] + else: + closes = [110.0, 105.0, 100.0] + + config = _short_config(commission_rate=commission_rate) + prices = make_prices(closes) + result = _run_round_trip(prices, direction, config=config) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + trade = closed[0] + + expected_net = trade.gross_pnl - trade.fees + assert abs(expected_net - trade.pnl) < 1e-6, ( + f"Decomposition failed: gross({trade.gross_pnl}) - fees({trade.fees}) " + f"= {expected_net} != pnl({trade.pnl})" + ) + + +# ============================================================================ +# TestRiskRulesDirectional: SL, TP, TrailingStop x direction +# ============================================================================ + + +class TestRiskRulesDirectional: + """Verify risk rules trigger correctly for both directions.""" + + @pytest.mark.parametrize("direction", DIRECTIONS) + def test_stop_loss_triggers(self, direction): + """SL should trigger when price moves against the position.""" + if direction == "long": + # Entry at 100, drops to 90 (10% loss) + bars = [ + (100.0, 100.0, 100.0, 100.0), # Entry bar + (99.0, 99.0, 89.0, 92.0), # SL triggers (low=89 < 95) + (92.0, 93.0, 91.0, 92.5), # Should already be out + ] + else: + # Entry at 100, rises to 110 (10% loss for short) + bars = [ + (100.0, 100.0, 100.0, 100.0), + (101.0, 111.0, 101.0, 108.0), # SL triggers (high=111 > 105) + (108.0, 109.0, 107.0, 107.5), + ] + + config = _short_config() + prices = make_ohlcv_prices(bars) + sl = StopLoss(pct=0.05) + + result = _run_round_trip( + prices, + direction, + exit_bar=99, + risk_rules=[sl], + config=config, + ) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + trade = closed[0] + assert trade.exit_reason == "stop_loss" + assert trade.pnl < 0 + + @pytest.mark.parametrize("direction", DIRECTIONS) + def test_take_profit_triggers(self, direction): + """TP should trigger when price moves in favor.""" + if direction == "long": + bars = [ + (100.0, 100.0, 100.0, 100.0), + (101.0, 112.0, 101.0, 108.0), # TP triggers (high=112 > 110) + (108.0, 109.0, 107.0, 108.0), + ] + else: + bars = [ + (100.0, 100.0, 100.0, 100.0), + (99.0, 99.0, 88.0, 92.0), # TP triggers (low=88 < 90) + (92.0, 93.0, 91.0, 92.0), + ] + + config = _short_config() + prices = make_ohlcv_prices(bars) + tp = TakeProfit(pct=0.10) + + result = _run_round_trip( + prices, + direction, + exit_bar=99, + risk_rules=[tp], + config=config, + ) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + trade = closed[0] + assert trade.exit_reason == "take_profit" + assert trade.pnl > 0 + + @pytest.mark.parametrize("direction", DIRECTIONS) + def test_trailing_stop_triggers(self, direction): + """Trailing stop should track HWM/LWM and trigger on reversal.""" + if direction == "long": + bars = [ + (100.0, 100.0, 100.0, 100.0), # Entry + (101.0, 112.0, 101.0, 110.0), # HWM = 110 (close) + (109.0, 109.0, 103.0, 104.0), # Trail triggers: 103 < 110*(1-0.05)=104.5 + ] + else: + bars = [ + (100.0, 100.0, 100.0, 100.0), # Entry + (99.0, 99.0, 88.0, 90.0), # LWM = 90 (close) + (91.0, 97.0, 91.0, 96.0), # Trail triggers: 97 > 90*(1+0.05)=94.5 + ] + + config = _short_config() + prices = make_ohlcv_prices(bars) + ts = TrailingStop(pct=0.05) + + result = _run_round_trip( + prices, + direction, + exit_bar=99, + risk_rules=[ts], + config=config, + ) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + trade = closed[0] + assert trade.exit_reason == "trailing_stop" + + +# ============================================================================ +# TestFillTimingDirectional: SAME_BAR vs NEXT_BAR_OPEN x direction +# ============================================================================ + + +class TestFillTimingDirectional: + """Verify fill timing modes work for both directions.""" + + @pytest.mark.parametrize("direction", DIRECTIONS) + def test_same_bar_fills_at_close(self, direction): + if direction == "long": + closes = [100.0, 110.0, 120.0] + else: + closes = [120.0, 110.0, 100.0] + + config = _short_config(execution_mode="SAME_BAR") + prices = make_prices(closes) + result = _run_round_trip(prices, direction, config=config) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + + @pytest.mark.parametrize("direction", DIRECTIONS) + def test_next_bar_fills_at_open(self, direction): + """In NEXT_BAR mode, orders submitted on bar N fill at bar N+1 open.""" + if direction == "long": + # entry_bar=0 submits → fills bar 1 open; exit_bar=2 submits → fills bar 3 open + opens = [100.0, 102.0, 108.0, 115.0] + closes = [101.0, 105.0, 110.0, 114.0] + else: + opens = [115.0, 112.0, 108.0, 100.0] + closes = [114.0, 110.0, 105.0, 101.0] + + config = _short_config(execution_mode="NEXT_BAR") + prices = make_prices(closes, opens=opens) + result = _run_round_trip(prices, direction, entry_bar=0, exit_bar=2, config=config) + + # Should have a closed trade (entry fills at bar 1 open, exit fills at bar 3 open) + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + + +# ============================================================================ +# TestPositionFlip: Long-to-short and short-to-long reversals +# ============================================================================ + + +class TestPositionFlip: + """Verify position reversal works correctly.""" + + def test_long_to_short_reversal(self): + """Close long and open short in sequence.""" + closes = [100.0, 110.0, 105.0, 100.0, 95.0] + prices = make_prices(closes) + + config = _short_config() + + # Run long first, then short + long_result = _run_round_trip(prices, "long", exit_bar=2, config=config) + short_result = _run_round_trip(prices, "short", entry_bar=2, exit_bar=4, config=config) + + long_closed = [t for t in long_result.trades if t.status == "closed"] + short_closed = [t for t in short_result.trades if t.status == "closed"] + + assert len(long_closed) == 1 + assert len(short_closed) == 1 + assert long_closed[0].direction == "long" + assert short_closed[0].direction == "short" + + +# ============================================================================ +# TestStopFillModeDirectional: All StopFillModes x direction +# ============================================================================ + + +class TestStopFillModeDirectional: + """Verify stop fill modes work for both directions.""" + + @pytest.mark.parametrize("direction", DIRECTIONS) + @pytest.mark.parametrize( + "fill_mode", + [StopFillMode.STOP_PRICE, StopFillMode.CLOSE_PRICE, StopFillMode.NEXT_BAR_OPEN], + ) + def test_stop_loss_fill_modes(self, direction, fill_mode): + """SL triggers and fills correctly under all fill modes.""" + if direction == "long": + bars = [ + (100.0, 100.0, 100.0, 100.0), + (99.0, 99.0, 93.0, 95.0), # SL triggers + (95.0, 95.0, 94.0, 94.5), # Next bar for NEXT_BAR_OPEN + (94.5, 95.0, 94.0, 94.5), + ] + else: + bars = [ + (100.0, 100.0, 100.0, 100.0), + (101.0, 107.0, 101.0, 105.0), # SL triggers + (105.0, 106.0, 104.0, 105.5), + (105.5, 106.0, 105.0, 105.5), + ] + + config = _short_config(stop_fill_mode=fill_mode) + prices = make_ohlcv_prices(bars) + sl = StopLoss(pct=0.05) + + result = _run_round_trip( + prices, + direction, + exit_bar=99, + risk_rules=[sl], + config=config, + ) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + assert closed[0].exit_reason == "stop_loss" + assert closed[0].pnl < 0 diff --git a/tests/test_equity_curve.py b/tests/test_equity_curve.py new file mode 100644 index 00000000..96a98d4f --- /dev/null +++ b/tests/test_equity_curve.py @@ -0,0 +1,27 @@ +"""Tests for EquityCurve annualization behavior.""" + +from datetime import datetime, timedelta + +from ml4t.backtest.analytics.equity import EquityCurve + + +class TestEquityCurveAnnualization: + """Tests for time-aware annualization on intraday bars.""" + + def test_years_uses_elapsed_time_for_intraday_bars(self): + """Years should be based on elapsed time, not raw bar count.""" + eq = EquityCurve() + start = datetime(2025, 1, 2, 9, 30) + for i in range(390): + eq.append(start + timedelta(minutes=i), 100_000.0 + float(i)) + + assert 0.0 < eq.years < 0.01 + + def test_periods_per_year_infers_intraday_frequency(self): + """Annualization factor should rise for high-frequency bars.""" + eq = EquityCurve() + start = datetime(2025, 1, 2, 9, 30) + for i in range(6): + eq.append(start + timedelta(minutes=i), 100_000.0 + float(i)) + + assert eq.periods_per_year > 252.0 diff --git a/tests/test_oracle_differential.py b/tests/test_oracle_differential.py new file mode 100644 index 00000000..364b9ec9 --- /dev/null +++ b/tests/test_oracle_differential.py @@ -0,0 +1,135 @@ +"""Differential tests: compare ml4t-backtest (SUT) against the reference oracle. + +Each test runs the same scenario through both engines and compares: +pnl, gross_pnl, pnl_percent, fees, final_cash. + +This produces 2 directions x 5 price pairs x 3 commission rates = 30 test cases +from a single parametrized function. + +Bug coverage: + - Bug 1 (short PnL sign): independent pnl_percent computation + - Bug 2 (cost decomposition): independent fee tracking +""" + +from __future__ import annotations + +from itertools import product + +import pytest + +from ml4t.backtest import BacktestConfig, DataFeed, Engine + +from .helpers.data import make_prices +from .helpers.strategies import RoundTripStrategy +from .oracle.engine import OracleBar, OracleFillRule, OracleSignal, run_oracle + +# ============================================================================ +# Parameters +# ============================================================================ + +DIRECTIONS = ["long", "short"] +PRICE_PAIRS = [ + (100.0, 110.0), # Up 10% + (100.0, 90.0), # Down 10% + (100.0, 100.0), # Flat + (50.0, 75.0), # Up 50% + (200.0, 180.0), # Down 10% +] +COMMISSION_RATES = [0.0, 0.001, 0.005] +QUANTITIES = [100.0] + +# Absolute tolerance for dollar comparisons +_TOL = 0.02 # 2 cents — accounts for slippage rounding differences + + +def _make_test_id(direction, prices, commission): + return f"{direction}-{prices[0]:.0f}_{prices[1]:.0f}-comm{commission}" + + +# Build parameter list +_PARAMS = list(product(DIRECTIONS, PRICE_PAIRS, COMMISSION_RATES)) +_IDS = [_make_test_id(d, p, c) for d, p, c in _PARAMS] + + +@pytest.mark.parametrize( + "direction,price_pair,commission_rate", + _PARAMS, + ids=_IDS, +) +def test_round_trip_matches_oracle(direction, price_pair, commission_rate): + """Compare SUT round-trip against oracle for PnL, fees, and final cash.""" + entry_price, exit_price = price_pair + qty = 100.0 + initial_cash = 100_000.0 + + # ---- Run Oracle ---- + oracle_bars = [ + OracleBar(entry_price, entry_price, entry_price, entry_price), + OracleBar(exit_price, exit_price, exit_price, exit_price), # Intermediate + OracleBar(exit_price, exit_price, exit_price, exit_price), # Exit bar + ] + oracle_signals = [ + OracleSignal(0, direction, "entry", qty), + OracleSignal(2, direction, "exit", qty), + ] + oracle_rule = OracleFillRule(commission_rate=commission_rate) + oracle_result = run_oracle(oracle_bars, oracle_signals, oracle_rule, initial_cash) + + assert len(oracle_result.trades) == 1 + oracle_trade = oracle_result.trades[0] + + # ---- Run SUT ---- + closes = [entry_price, exit_price, exit_price] + prices_df = make_prices(closes) + + config = BacktestConfig( + allow_short_selling=True, + allow_leverage=True, + commission_rate=commission_rate, + slippage_rate=0.0, + initial_cash=initial_cash, + execution_mode="SAME_BAR", + ) + feed = DataFeed(prices_df=prices_df) + strategy = RoundTripStrategy( + asset="TEST", + qty=qty, + entry_bar=0, + exit_bar=2, + direction=direction, + ) + engine = Engine(feed, strategy, config) + sut_result = engine.run() + + sut_closed = [t for t in sut_result.trades if t.status == "closed"] + assert len(sut_closed) == 1, f"Expected 1 closed trade, got {len(sut_closed)}" + sut_trade = sut_closed[0] + + # ---- Compare ---- + assert sut_trade.direction == oracle_trade.direction + + # Gross PnL (price move before costs) + assert sut_trade.gross_pnl == pytest.approx(oracle_trade.gross_pnl, abs=_TOL), ( + f"Gross PnL mismatch: SUT={sut_trade.gross_pnl}, Oracle={oracle_trade.gross_pnl}" + ) + + # Fees + assert sut_trade.fees == pytest.approx(oracle_trade.fees, abs=_TOL), ( + f"Fees mismatch: SUT={sut_trade.fees}, Oracle={oracle_trade.fees}" + ) + + # Net PnL + assert sut_trade.pnl == pytest.approx(oracle_trade.net_pnl, abs=_TOL), ( + f"Net PnL mismatch: SUT={sut_trade.pnl}, Oracle={oracle_trade.net_pnl}" + ) + + # PnL percent (direction-aware gross return) + assert sut_trade.pnl_percent == pytest.approx(oracle_trade.pnl_percent, abs=1e-6), ( + f"PnL% mismatch: SUT={sut_trade.pnl_percent}, Oracle={oracle_trade.pnl_percent}" + ) + + # Final portfolio value + sut_final = sut_result.equity_curve[-1][1] if sut_result.equity_curve else 0 + assert sut_final == pytest.approx(oracle_result.final_cash, abs=_TOL), ( + f"Final value mismatch: SUT={sut_final}, Oracle={oracle_result.final_cash}" + ) diff --git a/tests/test_order_type_matrix.py b/tests/test_order_type_matrix.py new file mode 100644 index 00000000..10fd419d --- /dev/null +++ b/tests/test_order_type_matrix.py @@ -0,0 +1,353 @@ +"""Direction x Order Type matrix tests. + +Tests that limit/stop orders fill at correct prices in both long and short +directions, with explicit OHLC bars and round-trip PnL verification. +""" + +from __future__ import annotations + +import pytest + +from ml4t.backtest import BacktestConfig, DataFeed, Engine, ExecutionMode, OrderType +from ml4t.backtest.config import ExecutionPrice + +from .helpers import OrderTypeStrategy, make_ohlcv_prices + +# Use SAME_BAR + CLOSE execution for deterministic fill prices in tests. +_CONFIG = BacktestConfig( + initial_cash=100_000.0, + commission_rate=0.0, + slippage_rate=0.0, + execution_mode=ExecutionMode.SAME_BAR, + execution_price=ExecutionPrice.CLOSE, + allow_short_selling=True, +) + + +def _run(bars, strategy, config=None): + """Helper to build Engine and run.""" + prices = make_ohlcv_prices(bars) + if config is None: + config = _CONFIG + return Engine(DataFeed(prices_df=prices), strategy, config).run() + + +@pytest.fixture(params=["long", "short"]) +def direction(request): + return request.param + + +# --------------------------------------------------------------------------- +# Limit orders +# --------------------------------------------------------------------------- + + +class TestLimitFills: + """Verify limit orders fill at exactly the limit price.""" + + def test_limit_fills_at_limit_price(self, direction): + """Bar touches limit -> fill at exactly limit price.""" + if direction == "long": + bars = [ + (100.0, 101.0, 99.0, 100.0), # bar 0: submit limit buy at 98 + (100.0, 100.0, 97.0, 99.0), # bar 1: low=97 touches 98 -> fill + (99.0, 102.0, 99.0, 101.0), # bar 2: hold + (101.0, 103.0, 101.0, 102.0), # bar 3: hold + (102.0, 103.0, 101.0, 102.0), # bar 4: exit + ] + limit_price = 98.0 + else: + bars = [ + (100.0, 101.0, 99.0, 100.0), # bar 0: submit limit sell at 102 + (100.0, 103.0, 100.0, 101.0), # bar 1: high=103 touches 102 -> fill + (101.0, 101.0, 98.0, 99.0), # bar 2: hold + (99.0, 99.0, 97.0, 98.0), # bar 3: hold + (98.0, 99.0, 97.0, 98.0), # bar 4: exit + ] + limit_price = 102.0 + + strategy = OrderTypeStrategy( + direction=direction, + order_type=OrderType.LIMIT, + limit_price=limit_price, + entry_bar=0, + exit_bar=4, + ) + result = _run(bars, strategy) + + assert len(result.trades) == 1 + assert result.trades[0].entry_price == limit_price + assert result.trades[0].status == "closed" + + def test_limit_no_fill_if_not_touched(self, direction): + """Bar doesn't touch limit -> no position opened.""" + if direction == "long": + bars = [ + (100.0, 101.0, 99.0, 100.0), + (100.0, 102.0, 98.0, 101.0), + (101.0, 103.0, 100.0, 102.0), + (102.0, 104.0, 101.0, 103.0), + ] + limit_price = 95.0 + else: + bars = [ + (100.0, 101.0, 99.0, 100.0), + (100.0, 102.0, 98.0, 101.0), + (101.0, 103.0, 100.0, 102.0), + (102.0, 104.0, 101.0, 103.0), + ] + limit_price = 105.0 + + strategy = OrderTypeStrategy( + direction=direction, + order_type=OrderType.LIMIT, + limit_price=limit_price, + entry_bar=0, + exit_bar=3, + ) + result = _run(bars, strategy) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 0 + + def test_limit_entry_market_exit_pnl(self, direction): + """Full round-trip: limit entry, market exit, verify PnL.""" + if direction == "long": + bars = [ + (100.0, 101.0, 99.0, 100.0), # bar 0: submit limit buy at 98 + (100.0, 100.0, 97.0, 99.0), # bar 1: fill at 98 + (99.0, 103.0, 99.0, 102.0), # bar 2: hold + (102.0, 104.0, 102.0, 103.0), # bar 3: exit at close=103 + ] + limit_price = 98.0 + expected_pnl = (103.0 - 98.0) * 100.0 # +500 + else: + bars = [ + (100.0, 101.0, 99.0, 100.0), # bar 0: submit limit sell at 102 + (100.0, 103.0, 100.0, 101.0), # bar 1: fill at 102 + (101.0, 101.0, 97.0, 98.0), # bar 2: hold + (98.0, 98.0, 96.0, 97.0), # bar 3: exit at close=97 + ] + limit_price = 102.0 + expected_pnl = (102.0 - 97.0) * 100.0 # +500 + + strategy = OrderTypeStrategy( + direction=direction, + order_type=OrderType.LIMIT, + limit_price=limit_price, + entry_bar=0, + exit_bar=3, + ) + result = _run(bars, strategy) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + assert abs(closed[0].pnl - expected_pnl) < 0.01 + + +# --------------------------------------------------------------------------- +# Stop orders +# --------------------------------------------------------------------------- + + +class TestStopFills: + """Verify stop orders fill at correct prices.""" + + def test_stop_fills_when_triggered(self, direction): + """Bar breaches stop -> fill at stop price.""" + if direction == "long": + bars = [ + (100.0, 101.0, 99.0, 100.0), + (100.0, 103.0, 99.0, 101.0), # high=103 triggers 102 + (101.0, 104.0, 101.0, 103.0), + (103.0, 105.0, 103.0, 104.0), + (104.0, 105.0, 103.0, 104.0), + ] + stop_price = 102.0 + else: + bars = [ + (100.0, 101.0, 99.0, 100.0), + (100.0, 101.0, 97.0, 99.0), # low=97 triggers 98 + (99.0, 99.0, 96.0, 97.0), + (97.0, 97.0, 95.0, 96.0), + (96.0, 97.0, 95.0, 96.0), + ] + stop_price = 98.0 + + strategy = OrderTypeStrategy( + direction=direction, + order_type=OrderType.STOP, + stop_price=stop_price, + entry_bar=0, + exit_bar=4, + ) + result = _run(bars, strategy) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + assert closed[0].entry_price == stop_price + + def test_stop_gap_through_fills_at_open(self, direction): + """Gap through stop -> fill at bar open (not stop price).""" + if direction == "long": + bars = [ + (100.0, 101.0, 99.0, 100.0), + (105.0, 107.0, 104.0, 106.0), # gap open 105 > stop 102 + (106.0, 108.0, 106.0, 107.0), + (107.0, 108.0, 106.0, 107.0), + ] + stop_price = 102.0 + expected_fill = 105.0 + else: + bars = [ + (100.0, 101.0, 99.0, 100.0), + (95.0, 96.0, 94.0, 95.0), # gap open 95 < stop 98 + (95.0, 95.0, 93.0, 94.0), + (94.0, 95.0, 93.0, 94.0), + ] + stop_price = 98.0 + expected_fill = 95.0 + + strategy = OrderTypeStrategy( + direction=direction, + order_type=OrderType.STOP, + stop_price=stop_price, + entry_bar=0, + exit_bar=3, + ) + result = _run(bars, strategy) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + assert closed[0].entry_price == expected_fill + + def test_stop_no_fill_if_not_triggered(self, direction): + """Stop level not breached -> no fill.""" + if direction == "long": + bars = [ + (100.0, 101.0, 99.0, 100.0), + (100.0, 103.0, 99.0, 102.0), + (102.0, 104.0, 101.0, 103.0), + ] + stop_price = 105.0 + else: + bars = [ + (100.0, 101.0, 99.0, 100.0), + (100.0, 101.0, 97.0, 99.0), + (99.0, 100.0, 96.0, 98.0), + ] + stop_price = 95.0 + + strategy = OrderTypeStrategy( + direction=direction, + order_type=OrderType.STOP, + stop_price=stop_price, + entry_bar=0, + exit_bar=2, + ) + result = _run(bars, strategy) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 0 + + def test_stop_entry_market_exit_pnl(self, direction): + """Full round-trip: stop entry, market exit, verify PnL.""" + if direction == "long": + bars = [ + (100.0, 101.0, 99.0, 100.0), + (100.0, 103.0, 100.0, 102.0), # triggered at 102 + (102.0, 105.0, 102.0, 104.0), # exit at close=104 + ] + stop_price = 102.0 + expected_pnl = (104.0 - 102.0) * 100.0 + else: + bars = [ + (100.0, 101.0, 99.0, 100.0), + (100.0, 100.0, 97.0, 98.0), # triggered at 98 + (98.0, 98.0, 95.0, 96.0), # exit at close=96 + ] + stop_price = 98.0 + expected_pnl = (98.0 - 96.0) * 100.0 + + strategy = OrderTypeStrategy( + direction=direction, + order_type=OrderType.STOP, + stop_price=stop_price, + entry_bar=0, + exit_bar=2, + ) + result = _run(bars, strategy) + + closed = [t for t in result.trades if t.status == "closed"] + assert len(closed) == 1 + assert abs(closed[0].pnl - expected_pnl) < 0.01 + + +# --------------------------------------------------------------------------- +# Fill metadata verification +# --------------------------------------------------------------------------- + + +class TestFillMetadata: + """Verify Fill dataclass carries order-type metadata.""" + + def test_limit_fill_has_metadata(self): + """Limit fill carries order_type and limit_price.""" + bars = [ + (100.0, 101.0, 99.0, 100.0), + (100.0, 100.0, 97.0, 99.0), + (99.0, 102.0, 99.0, 101.0), + ] + strategy = OrderTypeStrategy( + direction="long", + order_type=OrderType.LIMIT, + limit_price=98.0, + entry_bar=0, + exit_bar=2, + ) + result = _run(bars, strategy) + + limit_fills = [f for f in result.fills if f.order_type == "limit"] + assert len(limit_fills) == 1 + assert limit_fills[0].limit_price == 98.0 + assert limit_fills[0].price == 98.0 + + def test_stop_fill_has_metadata(self): + """Stop fill carries order_type and stop_price.""" + bars = [ + (100.0, 101.0, 99.0, 100.0), + (100.0, 103.0, 100.0, 102.0), + (102.0, 104.0, 102.0, 103.0), + ] + strategy = OrderTypeStrategy( + direction="long", + order_type=OrderType.STOP, + stop_price=102.0, + entry_bar=0, + exit_bar=2, + ) + result = _run(bars, strategy) + + stop_fills = [f for f in result.fills if f.order_type == "stop"] + assert len(stop_fills) == 1 + assert stop_fills[0].stop_price == 102.0 + assert stop_fills[0].price == 102.0 + + def test_market_fill_has_metadata(self): + """Market fill carries order_type='market'.""" + bars = [ + (100.0, 101.0, 99.0, 100.0), + (100.0, 102.0, 99.0, 101.0), + ] + strategy = OrderTypeStrategy( + direction="long", + order_type=OrderType.MARKET, + entry_bar=0, + exit_bar=1, + ) + result = _run(bars, strategy) + + market_fills = [f for f in result.fills if f.order_type == "market"] + assert len(market_fills) >= 1 + assert market_fills[0].limit_price is None + assert market_fills[0].stop_price is None diff --git a/tests/test_result.py b/tests/test_result.py index 45c867fb..41a1367e 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -145,6 +145,12 @@ def test_trades_dataframe_basic(self, backtest_result: BacktestResult): "slippage", "mfe", "mae", + "entry_slippage", + "multiplier", + "gross_pnl", + "net_return", + "total_slippage_cost", + "cost_drag", "exit_reason", "status", ] @@ -740,10 +746,10 @@ def _sortino(_arr, annualization_factor): num_trades=7, win_rate=0.57, profit_factor=1.8, - expectancy=12.0, - avg_trade=9.0, - avg_win=21.0, - avg_loss=-8.0, + expectancy=0.012, + avg_trade=0.009, + avg_win=0.021, + avg_loss=-0.008, total_fees=34.0, ) @@ -751,6 +757,43 @@ def _sortino(_arr, annualization_factor): assert metrics["num_trades"] == 7 assert metrics["total_fees"] == 34.0 + def test_to_portfolio_analysis_cme_uses_session_aligned_returns(self, monkeypatch): + """Portfolio analysis should use the same daily alignment logic as daily returns.""" + import builtins + from datetime import UTC + + from ml4t.backtest.config import BacktestConfig + + class FakePortfolioAnalysis: + def __init__(self, returns, **kwargs): + self.returns = returns + self.kwargs = kwargs + + real_import = builtins.__import__ + + def _import(name, *args, **kwargs): + if name == "ml4t.diagnostic.evaluation": + return SimpleNamespace(PortfolioAnalysis=FakePortfolioAnalysis) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _import) + + result = BacktestResult( + trades=[], + equity_curve=[ + (datetime(2025, 1, 6, 22, 30, tzinfo=UTC), 100.0), + (datetime(2025, 1, 6, 23, 30, tzinfo=UTC), 110.0), + (datetime(2025, 1, 7, 21, 0, tzinfo=UTC), 120.0), + (datetime(2025, 1, 7, 23, 30, tzinfo=UTC), 130.0), + ], + fills=[], + metrics={}, + config=BacktestConfig(calendar="CME_Equity", timezone="America/Chicago"), + ) + expected = result.to_daily_returns(calendar="CME_Equity").to_list() + analysis = result.to_portfolio_analysis(calendar="CME_Equity") + assert list(analysis.returns) == pytest.approx(expected) + class TestBacktestResultSchemas: """Tests for schema definitions.""" @@ -796,3 +839,47 @@ def _raising_import(name, *args, **kwargs): ImportError, match="ml4t-diagnostic is required for tearsheet generation" ): result.to_tearsheet() + + def test_to_tearsheet_fallback_uses_total_slippage_cost(self, monkeypatch): + """Fallback metric extraction should report slippage in dollar cost units.""" + import builtins + + captured: dict[str, object] = {} + + def _generate_backtest_tearsheet(**kwargs): + captured.update(kwargs) + return "" + + real_import = builtins.__import__ + + def _import(name, *args, **kwargs): + if name == "ml4t.diagnostic.visualization.backtest": + return SimpleNamespace(generate_backtest_tearsheet=_generate_backtest_tearsheet) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _import) + + trade = Trade( + symbol="AAPL", + entry_time=datetime(2024, 1, 1, 9, 30), + exit_time=datetime(2024, 1, 2, 9, 30), + entry_price=100.0, + exit_price=101.0, + quantity=10.0, + pnl=10.0, + pnl_percent=0.01, + bars_held=1, + slippage=0.20, + entry_slippage=0.10, + ) + result = BacktestResult( + trades=[trade], + equity_curve=[(datetime(2024, 1, 1, 9, 30), 100000.0)], + fills=[], + metrics={}, + ) + result.to_tearsheet() + + metrics = captured["metrics"] + assert isinstance(metrics, dict) + assert metrics["total_slippage"] == pytest.approx(trade.total_slippage_cost) diff --git a/tests/test_trade_cost_decomposition.py b/tests/test_trade_cost_decomposition.py new file mode 100644 index 00000000..4dfdc6b0 --- /dev/null +++ b/tests/test_trade_cost_decomposition.py @@ -0,0 +1,549 @@ +"""Tests for trade cost decomposition and short P&L correctness. + +Verifies: +- Direction-aware pnl_percent for short trades +- MFE/MAE tracking for short positions +- Trade computed properties: gross_pnl, net_return, total_slippage_cost, cost_drag +- Decomposition identity: gross_pnl - fees == pnl (when slippage is baked into prices) +- Futures multiplier propagation +- Parquet roundtrip of new fields +- Backward compatibility with old Parquet files +""" + +from datetime import datetime + +import pytest + +from ml4t.backtest.types import Position, Trade + +# === Position.pnl_percent short fix === + + +class TestPositionPnlPercentShort: + def test_long_positive_return(self): + """Long position, price goes up → positive return.""" + pos = Position("AAPL", 100.0, 100.0, datetime(2024, 1, 1)) + assert pos.pnl_percent(110.0) == pytest.approx(0.10) + + def test_long_negative_return(self): + """Long position, price goes down → negative return.""" + pos = Position("AAPL", 100.0, 100.0, datetime(2024, 1, 1)) + assert pos.pnl_percent(90.0) == pytest.approx(-0.10) + + def test_short_profitable(self): + """Short position, price goes down → positive return.""" + pos = Position("AAPL", -100.0, 100.0, datetime(2024, 1, 1)) + assert pos.pnl_percent(90.0) == pytest.approx(0.10) + + def test_short_loss(self): + """Short position, price goes up → negative return.""" + pos = Position("AAPL", -100.0, 100.0, datetime(2024, 1, 1)) + assert pos.pnl_percent(110.0) == pytest.approx(-0.10) + + def test_short_at_entry(self): + """Short position, price unchanged → zero return.""" + pos = Position("AAPL", -100.0, 100.0, datetime(2024, 1, 1)) + assert pos.pnl_percent(100.0) == pytest.approx(0.0) + + +# === Trade pnl_percent sign === + + +class TestTradePnlPercentSign: + def test_short_profitable_trade(self): + """Short at 100, exit at 90 → pnl_percent should be +0.10.""" + trade = Trade( + symbol="TEST", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 2), + entry_price=100.0, + exit_price=90.0, + quantity=-100.0, + pnl=1000.0, + pnl_percent=0.10, # Direction-aware: positive for profitable short + bars_held=1, + ) + assert trade.pnl_percent > 0 # Profitable short = positive pnl_percent + + def test_short_losing_trade(self): + """Short at 100, exit at 110 → pnl_percent should be -0.10.""" + trade = Trade( + symbol="TEST", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 2), + entry_price=100.0, + exit_price=110.0, + quantity=-100.0, + pnl=-1000.0, + pnl_percent=-0.10, # Direction-aware: negative for losing short + bars_held=1, + ) + assert trade.pnl_percent < 0 # Losing short = negative pnl_percent + + +# === MFE/MAE tracking for shorts === + + +class TestShortMFEMAE: + def test_short_mfe_positive_when_price_drops(self): + """For short positions, MFE should be positive when price drops (favorable).""" + pos = Position("TEST", -100.0, 100.0, datetime(2024, 1, 1)) + # Price drops to 90 → favorable for short → MFE should be positive + # use_low_for_lwm=True uses bar_low for MFE source (OHLC mode) + pos.update_water_marks(95.0, bar_high=100.0, bar_low=90.0, use_low_for_lwm=True) + assert pos.max_favorable_excursion > 0, ( + f"Short MFE should be positive when price drops, got {pos.max_favorable_excursion}" + ) + assert pos.max_favorable_excursion == pytest.approx(0.10) # (100-90)/100 + + def test_short_mae_negative_when_price_rises(self): + """For short positions, MAE should be negative when price rises (adverse).""" + pos = Position("TEST", -100.0, 100.0, datetime(2024, 1, 1)) + # Price rises to 110 → adverse for short → MAE should be negative + # use_high_for_hwm=True uses bar_high for MAE source (OHLC mode) + pos.update_water_marks(105.0, bar_high=110.0, bar_low=100.0, use_high_for_hwm=True) + assert pos.max_adverse_excursion < 0, ( + f"Short MAE should be negative when price rises, got {pos.max_adverse_excursion}" + ) + assert pos.max_adverse_excursion == pytest.approx(-0.10) # -(110-100)/100 + + def test_short_mfe_not_always_zero(self): + """Regression: MFE was always 0 for shorts due to wrong pnl_percent sign.""" + pos = Position("TEST", -100.0, 100.0, datetime(2024, 1, 1)) + pos.update_water_marks(95.0, bar_high=100.0, bar_low=90.0) + pos.update_water_marks(92.0, bar_high=96.0, bar_low=88.0) + assert pos.max_favorable_excursion != 0.0, "Short MFE should not be stuck at zero" + + def test_short_mae_not_always_zero(self): + """Regression: MAE was always 0 for shorts due to wrong pnl_percent sign.""" + pos = Position("TEST", -100.0, 100.0, datetime(2024, 1, 1)) + pos.update_water_marks(105.0, bar_high=110.0, bar_low=102.0) + assert pos.max_adverse_excursion != 0.0, "Short MAE should not be stuck at zero" + + +# === Trade computed properties === + + +class TestTradeComputedProperties: + @pytest.fixture + def long_trade(self): + return Trade( + symbol="AAPL", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 5), + entry_price=100.0, + exit_price=110.0, + quantity=100.0, + pnl=980.0, # 1000 gross - 20 fees + pnl_percent=0.10, + bars_held=4, + fees=20.0, + slippage=0.05, + entry_slippage=0.03, + multiplier=1.0, + ) + + @pytest.fixture + def short_trade(self): + return Trade( + symbol="TSLA", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 5), + entry_price=100.0, + exit_price=90.0, + quantity=-100.0, + pnl=980.0, # 1000 gross - 20 fees + pnl_percent=0.10, + bars_held=4, + fees=20.0, + slippage=0.05, + entry_slippage=0.03, + multiplier=1.0, + ) + + @pytest.fixture + def futures_trade(self): + return Trade( + symbol="ES", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 5), + entry_price=5000.0, + exit_price=5010.0, + quantity=2.0, + pnl=991.0, # (10 * 2 * 50) - 9 fees = 1000 - 9 + pnl_percent=0.002, # 10/5000 + bars_held=4, + fees=9.0, + slippage=0.25, + entry_slippage=0.25, + multiplier=50.0, + ) + + def test_gross_pnl_long(self, long_trade): + assert long_trade.gross_pnl == pytest.approx(1000.0) # (110-100)*100*1 + + def test_gross_pnl_short(self, short_trade): + # (90-100) * (-100) * 1 = (-10)*(-100) = 1000 + assert short_trade.gross_pnl == pytest.approx(1000.0) + + def test_gross_pnl_futures(self, futures_trade): + # (5010-5000) * 2 * 50 = 1000 + assert futures_trade.gross_pnl == pytest.approx(1000.0) + + def test_net_pnl_alias(self, long_trade): + assert long_trade.net_pnl == long_trade.pnl + + def test_gross_return_alias(self, long_trade): + assert long_trade.gross_return == long_trade.pnl_percent + + def test_net_return_long(self, long_trade): + # 980 / (100 * 100 * 1) = 0.098 + assert long_trade.net_return == pytest.approx(0.098) + + def test_net_return_short(self, short_trade): + # 980 / (100 * 100 * 1) = 0.098 + assert short_trade.net_return == pytest.approx(0.098) + + def test_net_return_futures(self, futures_trade): + # 991 / (5000 * 2 * 50) = 991/500000 = 0.001982 + assert futures_trade.net_return == pytest.approx(991.0 / 500000.0) + + def test_total_slippage_cost_long(self, long_trade): + # (0.03 + 0.05) * 100 * 1 = 8.0 + assert long_trade.total_slippage_cost == pytest.approx(8.0) + + def test_total_slippage_cost_short(self, short_trade): + # (0.03 + 0.05) * 100 * 1 = 8.0 (uses abs(quantity)) + assert short_trade.total_slippage_cost == pytest.approx(8.0) + + def test_total_slippage_cost_futures(self, futures_trade): + # (0.25 + 0.25) * 2 * 50 = 50.0 + assert futures_trade.total_slippage_cost == pytest.approx(50.0) + + def test_cost_drag_long(self, long_trade): + # (20 + 8) / (100 * 100 * 1) = 28/10000 = 0.0028 + assert long_trade.cost_drag == pytest.approx(28.0 / 10000.0) + + def test_cost_drag_futures(self, futures_trade): + # (9 + 50) / (5000 * 2 * 50) = 59/500000 + assert futures_trade.cost_drag == pytest.approx(59.0 / 500000.0) + + def test_cost_drag_zero_notional(self): + trade = Trade( + symbol="X", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 2), + entry_price=0.0, + exit_price=0.0, + quantity=0.0, + pnl=0.0, + pnl_percent=0.0, + bars_held=1, + ) + assert trade.cost_drag == 0.0 + + def test_net_return_zero_notional(self): + trade = Trade( + symbol="X", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 2), + entry_price=0.0, + exit_price=0.0, + quantity=0.0, + pnl=0.0, + pnl_percent=0.0, + bars_held=1, + ) + assert trade.net_return == 0.0 + + +# === Decomposition identity === + + +class TestDecompositionIdentity: + def test_gross_minus_fees_equals_pnl(self): + """When slippage is baked into fill prices, gross_pnl - fees == pnl.""" + trade = Trade( + symbol="TEST", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 2), + entry_price=100.0, + exit_price=110.0, + quantity=100.0, + pnl=980.0, # 1000 - 20 fees + pnl_percent=0.10, + bars_held=1, + fees=20.0, + ) + assert trade.gross_pnl - trade.fees == pytest.approx(trade.pnl) + + def test_gross_minus_fees_short(self): + """Decomposition identity holds for short trades.""" + trade = Trade( + symbol="TEST", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 2), + entry_price=100.0, + exit_price=90.0, + quantity=-100.0, + pnl=985.0, # 1000 - 15 fees + pnl_percent=0.10, + bars_held=1, + fees=15.0, + ) + assert trade.gross_pnl - trade.fees == pytest.approx(trade.pnl) + + def test_gross_minus_fees_futures(self): + """Decomposition identity holds for futures.""" + trade = Trade( + symbol="ES", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 2), + entry_price=5000.0, + exit_price=5010.0, + quantity=2.0, + pnl=991.0, # (10*2*50) - 9 = 1000-9 + pnl_percent=0.002, + bars_held=1, + fees=9.0, + multiplier=50.0, + ) + assert trade.gross_pnl - trade.fees == pytest.approx(trade.pnl) + + +# === Entry slippage field === + + +class TestEntrySlippage: + def test_position_entry_slippage_default(self): + pos = Position("TEST", 100.0, 100.0, datetime(2024, 1, 1)) + assert pos.entry_slippage == 0.0 + + def test_position_entry_slippage_set(self): + pos = Position("TEST", 100.0, 100.0, datetime(2024, 1, 1), entry_slippage=0.05) + assert pos.entry_slippage == 0.05 + + def test_trade_entry_slippage_default(self): + trade = Trade( + symbol="TEST", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 2), + entry_price=100.0, + exit_price=110.0, + quantity=100.0, + pnl=1000.0, + pnl_percent=0.10, + bars_held=1, + ) + assert trade.entry_slippage == 0.0 + + def test_trade_multiplier_default(self): + trade = Trade( + symbol="TEST", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 2), + entry_price=100.0, + exit_price=110.0, + quantity=100.0, + pnl=1000.0, + pnl_percent=0.10, + bars_held=1, + ) + assert trade.multiplier == 1.0 + + +# === Parquet roundtrip === + + +class TestParquetRoundtrip: + def test_new_fields_survive_roundtrip(self, tmp_path): + """entry_slippage and multiplier survive write/read.""" + from ml4t.backtest.result import BacktestResult + + trades = [ + Trade( + symbol="ES", + entry_time=datetime(2024, 1, 1), + exit_time=datetime(2024, 1, 2), + entry_price=5000.0, + exit_price=5010.0, + quantity=2.0, + pnl=991.0, + pnl_percent=0.002, + bars_held=1, + fees=9.0, + slippage=0.25, + entry_slippage=0.25, + multiplier=50.0, + ) + ] + result = BacktestResult( + trades=trades, + equity_curve=[(datetime(2024, 1, 1), 100000.0)], + fills=[], + metrics={"initial_cash": 100000.0}, + ) + + result.to_parquet(tmp_path / "test_result") + loaded = BacktestResult.from_parquet(tmp_path / "test_result") + + assert len(loaded.trades) == 1 + t = loaded.trades[0] + assert t.entry_slippage == pytest.approx(0.25) + assert t.multiplier == pytest.approx(50.0) + assert t.gross_pnl == pytest.approx(1000.0) + + def test_backward_compat_missing_fields(self, tmp_path): + """Old Parquet files without entry_slippage/multiplier load with defaults.""" + import polars as pl + + # Write a Parquet without the new columns (simulates old format) + old_df = pl.DataFrame( + { + "symbol": ["AAPL"], + "entry_time": [datetime(2024, 1, 1)], + "exit_time": [datetime(2024, 1, 2)], + "entry_price": [100.0], + "exit_price": [110.0], + "quantity": [100.0], + "direction": ["long"], + "pnl": [1000.0], + "pnl_percent": [0.10], + "bars_held": [1], + "fees": [0.0], + "slippage": [0.0], + "mfe": [0.12], + "mae": [-0.03], + "exit_reason": ["signal"], + "status": ["closed"], + } + ) + + result_dir = tmp_path / "old_result" + result_dir.mkdir() + old_df.write_parquet(result_dir / "trades.parquet") + + from ml4t.backtest.result import BacktestResult + + loaded = BacktestResult.from_parquet(result_dir) + assert len(loaded.trades) == 1 + t = loaded.trades[0] + assert t.entry_slippage == 0.0 # Default + assert t.multiplier == 1.0 # Default + assert t.gross_pnl == pytest.approx(1000.0) + + +# === Integration: actual backtest with shorts === + + +class TestShortBacktestIntegration: + def test_short_trade_pnl_percent_positive_for_profit(self): + """Full engine run with short trade produces correct pnl_percent sign.""" + import polars as pl + + from ml4t.backtest import BacktestConfig, DataFeed, Engine, Strategy + + class ShortStrategy(Strategy): + def on_data(self, timestamp, data, context, broker): + for asset, bar in data.items(): + signals = bar.get("signals", {}) + signal = signals.get("signal", 0) + if signal == -1 and asset not in broker.positions: + broker.submit_order(asset, -100) + elif signal == 1 and asset in broker.positions: + broker.submit_order(asset, 100) + + # Extra bar after close signal so SAME_BAR mode can process the order + prices = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, d) for d in range(1, 7)], + "asset": ["TEST"] * 6, + "open": [100.0, 100.0, 95.0, 92.0, 90.0, 90.0], + "high": [101.0, 100.5, 96.0, 93.0, 91.0, 91.0], + "low": [99.0, 94.0, 91.0, 89.0, 88.0, 89.0], + "close": [100.0, 95.0, 92.0, 90.0, 90.0, 90.0], + "volume": [1000] * 6, + } + ) + signals = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, d) for d in range(1, 7)], + "asset": ["TEST"] * 6, + "signal": [-1, 0, 0, 0, 1, 0], + } + ) + + config = BacktestConfig( + initial_cash=100000.0, + commission_rate=0.0, + slippage_rate=0.0, + allow_short_selling=True, + ) + feed = DataFeed(prices_df=prices, signals_df=signals) + result = Engine(feed, ShortStrategy(), config).run() + + trades = [t for t in result.trades if t.status == "closed"] + assert len(trades) == 1 + trade = trades[0] + + # Short at 100, exit at 90 → profitable → pnl_percent > 0 + assert trade.pnl > 0, f"Short trade should be profitable, got pnl={trade.pnl}" + assert trade.pnl_percent > 0, ( + f"Profitable short should have positive pnl_percent, got {trade.pnl_percent}" + ) + assert trade.direction == "short" + assert trade.quantity < 0 + + def test_short_mfe_mae_nonzero(self): + """Short trade MFE/MAE should not be stuck at zero.""" + import polars as pl + + from ml4t.backtest import BacktestConfig, DataFeed, Engine, Strategy + + class ShortStrategy(Strategy): + def on_data(self, timestamp, data, context, broker): + for asset, bar in data.items(): + signals = bar.get("signals", {}) + signal = signals.get("signal", 0) + if signal == -1 and asset not in broker.positions: + broker.submit_order(asset, -100) + elif signal == 1 and asset in broker.positions: + broker.submit_order(asset, 100) + + # Extra bar after close signal for SAME_BAR order processing + prices = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, d) for d in range(1, 9)], + "asset": ["TEST"] * 8, + "open": [100.0, 100.0, 97.0, 95.0, 105.0, 93.0, 90.0, 90.0], + "high": [101.0, 101.0, 98.0, 96.0, 108.0, 94.0, 91.0, 91.0], + "low": [99.0, 96.0, 94.0, 93.0, 95.0, 91.0, 88.0, 89.0], + "close": [100.0, 97.0, 95.0, 95.0, 103.0, 92.0, 90.0, 90.0], + "volume": [1000] * 8, + } + ) + signals = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, d) for d in range(1, 9)], + "asset": ["TEST"] * 8, + "signal": [-1, 0, 0, 0, 0, 0, 1, 0], + } + ) + + config = BacktestConfig( + initial_cash=100000.0, + commission_rate=0.0, + slippage_rate=0.0, + allow_short_selling=True, + ) + feed = DataFeed(prices_df=prices, signals_df=signals) + result = Engine(feed, ShortStrategy(), config).run() + + trades = [t for t in result.trades if t.status == "closed"] + assert len(trades) == 1 + trade = trades[0] + + # MFE should be positive (price dropped to 88 → favorable for short) + assert trade.mfe > 0, f"Short MFE should be positive, got {trade.mfe}" + # MAE should be negative (price rose to 108 → adverse for short) + assert trade.mae < 0, f"Short MAE should be negative, got {trade.mae}" diff --git a/tests/test_trade_mfe_mae.py b/tests/test_trade_mfe_mae.py index cdb605f5..8f3bef01 100644 --- a/tests/test_trade_mfe_mae.py +++ b/tests/test_trade_mfe_mae.py @@ -124,15 +124,9 @@ def test_mae_recovery_ratio(self, sample_trades): """Test MAE recovery ratio for losing trades.""" analyzer = TradeAnalyzer(sample_trades) # Only losing trade (GOOG): MAE=-0.08, final=-0.05 - # Recovery = (-0.08 - -0.05) / |-0.08| = -0.03 / 0.08 = -0.375 - # Wait, that formula gives negative. Let me recalculate. - # Recovery = (MAE - final_loss) / |MAE| = (-0.08 - (-0.05)) / 0.08 = -0.03/0.08 = -0.375 - # This means the loss got worse from MAE. Hmm, the formula may need adjustment. - # Actually MAE is the worst point, so final should be >= MAE (less negative) - # Let's check: MAE=-0.08 (worst), final=-0.05 (better) - # Recovery should be positive: recovered 3% out of 8% drawdown + # Recovery = (final - MAE) / |MAE| = (-0.05 - -0.08) / 0.08 = 0.375 ratio = analyzer.mae_recovery_ratio - assert ratio is not None # At least it computes + assert ratio == pytest.approx(0.375) def test_empty_trades(self): """Test MFE/MAE methods with empty trades."""