diff --git a/README.md b/README.md index e976dae4..3f54ff57 100644 --- a/README.md +++ b/README.md @@ -39,37 +39,35 @@ pip install ml4t-backtest ## Quick Start ```python +import polars as pl from ml4t.backtest import Engine, Strategy, BacktestConfig, DataFeed -from ml4t.backtest.risk import StopLoss, TakeProfit, RuleChain - -class TrendFollowing(Strategy): - def __init__(self, fast=10, slow=30): - self.fast = fast - self.slow = slow +class SignalStrategy(Strategy): def on_data(self, timestamp, data, context, broker): - close = data["close"] - fast_ma = close.rolling(self.fast).mean().iloc[-1] - slow_ma = close.rolling(self.slow).mean().iloc[-1] - - position = broker.get_position("SPY") - - if fast_ma > slow_ma and position is None: - broker.submit_order("SPY", quantity=100, side="BUY") - elif fast_ma < slow_ma and position is not None: - broker.close_position("SPY") + for asset, bar in data.items(): + signal = bar.get("signals", {}).get("prediction", 0) + price = bar.get("close", 0) + position = broker.get_position(asset) + + if position is None and signal > 0.5: + shares = (broker.get_account_value() * 0.10) / price + if shares > 0: + broker.submit_order(asset, shares) + elif position is not None and signal < -0.5: + broker.close_position(asset) config = BacktestConfig( initial_cash=100_000, commission_rate=0.001, + slippage_rate=0.0005, ) -feed = DataFeed(price_data) -engine = Engine(feed, TrendFollowing(), config) +feed = DataFeed(prices_df=prices, signals_df=signals) +engine = Engine(feed, SignalStrategy(), config) result = engine.run() -print(f"Total Return: {result.total_return:.2%}") -print(f"Sharpe Ratio: {result.metrics['sharpe_ratio']:.2f}") +print(f"Total Return: {result.metrics['total_return_pct']:.2f}%") +print(f"Sharpe Ratio: {result.metrics['sharpe']:.2f}") ``` ## Risk Management @@ -77,7 +75,7 @@ print(f"Sharpe Ratio: {result.metrics['sharpe_ratio']:.2f}") Position-level exit rules: ```python -from ml4t.backtest.risk import StopLoss, TakeProfit, TrailingStop, RuleChain +from ml4t.backtest import Strategy, StopLoss, TakeProfit, TrailingStop, RuleChain class MyStrategy(Strategy): def on_start(self, broker): @@ -91,7 +89,7 @@ class MyStrategy(Strategy): Portfolio-level controls: ```python -from ml4t.backtest.risk import MaxPositions, MaxDrawdown, DailyLossLimit +from ml4t.backtest.risk.portfolio.limits import MaxDrawdownLimit, DailyLossLimit ``` ## Framework Profiles @@ -140,26 +138,50 @@ config = BacktestConfig( ## Commission and Slippage ```python -from ml4t.backtest import PercentCommission, PercentSlippage +from ml4t.backtest import BacktestConfig, CommissionType + +config = BacktestConfig( + commission_rate=0.001, # 10 bps percentage + slippage_rate=0.0005, # 5 bps slippage + stop_slippage_rate=0.001, # Additional slippage for stop exits +) +# Or per-share (Interactive Brokers style) config = BacktestConfig( - commission_model=PercentCommission(rate=0.001), - slippage_model=PercentSlippage(rate=0.0005), + commission_type=CommissionType.PER_SHARE, + commission_per_share=0.005, + commission_minimum=1.0, ) ``` -## Multi-Asset Support +## Multi-Asset Rebalancing ```python -class RankingStrategy(Strategy): - def on_data(self, timestamp, data, context, broker): - returns = data["close"].pct_change(20) - ranked = returns.iloc[-1].sort_values(ascending=False) +from ml4t.backtest import Strategy, TargetWeightExecutor, RebalanceConfig + +class WeightStrategy(Strategy): + def __init__(self): + self.executor = TargetWeightExecutor(RebalanceConfig( + min_trade_value=100, + min_weight_change=0.01, + )) + self.bar_count = 0 - # Long top 10 - for asset in ranked.head(10).index: - if broker.get_position(asset) is None: - broker.submit_order(asset, quantity=100, side="BUY") + def on_data(self, timestamp, data, context, broker): + self.bar_count += 1 + if self.bar_count % 21 != 1: # Monthly rebalance + return + + # ML predictions → portfolio weights + weights = {} + for asset, bar in data.items(): + signal = bar.get("signals", {}).get("prediction", 0) + if signal and signal > 0: + weights[asset] = signal + if weights: + total = sum(weights.values()) + weights = {a: w / total for a, w in weights.items()} + self.executor.execute(weights, data, broker) ``` ## Cross-Framework Validation @@ -209,6 +231,18 @@ Benchmark on 250 assets x 20 years daily data (1.26M bars): | vs Zipline | 8x faster | | vs LEAN | 5x faster | +## Documentation + +- [Getting Started](docs/getting-started/quickstart.md) — your first backtest +- [Strategies](docs/user-guide/strategies.md) — strategy interface and templates +- [Stateful Strategies](docs/user-guide/stateful-strategies.md) — advanced event-driven patterns (Kelly sizing, pairs trading, circuit breakers) +- [Execution Semantics](docs/user-guide/execution-semantics.md) — fill timing, ordering, stops +- [Configuration](docs/user-guide/configuration.md) — 40+ behavioral knobs +- [Risk Management](docs/user-guide/risk-management.md) — stops, trails, portfolio limits +- [Rebalancing](docs/user-guide/rebalancing.md) — weight-based portfolio management +- [Market Impact](docs/user-guide/market-impact.md) — commission, slippage, and impact models +- [Profiles](docs/user-guide/profiles.md) — framework parity presets + ## Technical Characteristics - **Event-driven**: Each bar processes sequentially with exit-first logic diff --git a/docs/index.md b/docs/index.md index f4c7c8f0..2f4d3f57 100644 --- a/docs/index.md +++ b/docs/index.md @@ -70,7 +70,14 @@ pip install ml4t-backtest - [Configuration](user-guide/configuration.md) -- all 40+ knobs explained - [Profiles](user-guide/profiles.md) -- framework parity and presets - [Strategies](user-guide/strategies.md) -- writing strategies and templates +- [Stateful Strategies](user-guide/stateful-strategies.md) -- advanced event-driven patterns - [Risk Management](user-guide/risk-management.md) -- stops, trails, portfolio limits +- [Rebalancing](user-guide/rebalancing.md) -- weight-based portfolio management +- [Data Feed](user-guide/data-feed.md) -- preparing price and signal data +- [Results & Analysis](user-guide/results.md) -- metrics, trades, equity export +- [Market Impact](user-guide/market-impact.md) -- commission, slippage, and impact models +- [Orders](user-guide/orders.md) -- order types and bracket orders +- [Accounts](user-guide/accounts.md) -- cash, crypto, and margin accounts - [API Reference](api/index.md) -- full API documentation ## Part of the ML4T Ecosystem diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index ebc6f89c..b1e50953 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -238,6 +238,16 @@ config = BacktestConfig( ) ``` +## See It in Action + +The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book uses BacktestConfig across all case studies: + +- **Ch16 case studies** — each case study loads config from `setup.yaml` via `get_backtest_config()`, setting initial_cash, commission_rate, slippage_rate, and execution_mode +- **Ch16 / NB13** (`futures_backtesting`) — ContractSpec with CommissionType.PER_CONTRACT for CME futures +- **Ch19 case studies** — risk management config (stop fill modes, trailing stop timing) + +The book pattern: `BacktestConfig()` with 4 overrides (initial_cash, commission_rate, slippage_rate, execution_mode), loaded from YAML. Costs come from `setup.yaml` via a utility function. This covers the vast majority of use cases. + ## Next Steps - [Profiles](profiles.md) -- pre-built configs for each framework diff --git a/docs/user-guide/data-feed.md b/docs/user-guide/data-feed.md index f78f5802..50602700 100644 --- a/docs/user-guide/data-feed.md +++ b/docs/user-guide/data-feed.md @@ -139,6 +139,14 @@ result = run_backtest("data/prices.parquet", strategy, signals="data/signals.par DataFeed pre-partitions data by timestamp at initialization and pre-extracts column indices for O(1) per-bar access. For 1M bars, this uses roughly 100 MB (10x less than converting everything to Python dicts upfront). +## See It in Action + +The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book prepares DataFeed inputs in every Engine case study: + +- **Ch16 case studies** — each case study loads OHLCV from Parquet, constructs a signals DataFrame from ML predictions, and passes both to DataFeed +- **Ch16 / NB13** (`futures_backtesting`) — multi-contract futures data with session boundaries and overnight gaps +- The common pattern: `prices_df` is a stacked multi-asset OHLCV DataFrame, `signals_df` contains prediction columns aligned by (timestamp, asset) + ## Next Steps - [Quickstart](../getting-started/quickstart.md) -- end-to-end examples diff --git a/docs/user-guide/execution-semantics.md b/docs/user-guide/execution-semantics.md index 652dfd51..0e427233 100644 --- a/docs/user-guide/execution-semantics.md +++ b/docs/user-guide/execution-semantics.md @@ -259,6 +259,14 @@ config = BacktestConfig(share_type=ShareType.FRACTIONAL) config = BacktestConfig(share_type=ShareType.INTEGER) ``` +## See It in Action + +The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book demonstrates execution semantics across chapters: + +- **Ch16 / NB11** (`engine_divergence_anatomy`) — detailed analysis of how SAME_BAR vs NEXT_BAR and fill ordering affect backtest results +- **Ch18** (`portfolio_construction`) — LinearImpact and SquareRootImpact market impact models with VolumeParticipationLimit +- **Ch16 case studies** — each case study uses setup.yaml to configure commission_rate, slippage_rate, and execution_mode + ## Next Steps - [Configuration](configuration.md) -- complete reference for all 40+ parameters diff --git a/docs/user-guide/market-impact.md b/docs/user-guide/market-impact.md new file mode 100644 index 00000000..83844cde --- /dev/null +++ b/docs/user-guide/market-impact.md @@ -0,0 +1,203 @@ +# Market Impact & Execution Costs + +Realistic backtesting requires modeling the costs of executing trades. ml4t-backtest provides three layers of cost modeling: commission, slippage, and market impact. + +## Cost Layers + +| Layer | What It Models | Config | +|-------|---------------|--------| +| **Commission** | Broker fees | `commission_type`, `commission_rate` | +| **Slippage** | Bid-ask spread crossing | `slippage_type`, `slippage_rate` | +| **Market impact** | Price movement from your order | `market_impact_model=` kwarg | + +Commission and slippage are configured via BacktestConfig. Market impact is an optional model passed to the Engine. + +## Commission Models + +### Percentage (Default) + +```python +from ml4t.backtest import BacktestConfig + +config = BacktestConfig( + commission_rate=0.001, # 10 bps per trade +) +``` + +### Per-Share + +```python +from ml4t.backtest import BacktestConfig, CommissionType + +config = BacktestConfig( + commission_type=CommissionType.PER_SHARE, + commission_per_share=0.005, # $0.005 per share + commission_minimum=1.0, # $1 minimum per trade +) +``` + +### Per-Contract (Futures) + +```python +config = BacktestConfig( + commission_type=CommissionType.PER_CONTRACT, + commission_per_share=2.50, # $2.50 per contract +) +``` + +`PER_CONTRACT` is an alias for `PER_SHARE` — same math, clearer intent for futures. + +### Custom Models + +For volume-tiered or combined commission structures, use model objects: + +```python +from ml4t.backtest.models import TieredCommission, CombinedCommission + +# Volume-tiered (Interactive Brokers style) +tiered = TieredCommission(tiers=[ + (300, 0.0035), # First 300 shares: $0.0035/share + (3000, 0.0020), # 301-3000: $0.0020/share + (float('inf'), 0.0015), # 3001+: $0.0015/share +]) + +# Combined (base + percentage) +combined = CombinedCommission( + fixed=1.0, # $1 base + per_share=0.005, # Plus $0.005/share +) +``` + +## Slippage Models + +Slippage models the bid-ask spread you cross when executing. A buy order fills slightly above the mid-price; a sell order fills slightly below. + +### Percentage (Default) + +```python +config = BacktestConfig( + slippage_rate=0.001, # 10 bps for market orders + stop_slippage_rate=0.001, # Additional 10 bps for stop exits +) +``` + +Stop exits can have additional slippage because stops trigger during fast markets. + +### Fixed + +```python +from ml4t.backtest.config import SlippageType + +config = BacktestConfig( + slippage_type=SlippageType.FIXED, + slippage_fixed=0.01, # $0.01 per share +) +``` + +## Market Impact Models + +Market impact captures the price movement caused by your order itself — large orders move the market. This is the most important cost for institutional-size strategies. + +Import from `ml4t.backtest.execution`: + +```python +from ml4t.backtest.execution import LinearImpact, SquareRootImpact, NoImpact +``` + +### No Impact (Default) + +```python +engine = Engine(feed, strategy, config) +# Equivalent to: market_impact_model=NoImpact() +``` + +### Linear Impact + +Price impact proportional to order size relative to bar volume: + +$$\text{impact} = \eta \times \frac{Q}{V}$$ + +where $Q$ = order quantity, $V$ = bar volume, $\eta$ = impact coefficient. + +```python +from ml4t.backtest.execution import LinearImpact + +engine = Engine( + feed, strategy, config, + market_impact_model=LinearImpact(eta=0.1), +) +``` + +An order that is 10% of bar volume with `eta=0.1` moves the fill price by 1%. + +### Square-Root Impact + +The standard institutional model — impact scales with the square root of participation rate: + +$$\text{impact} = \eta \times \sigma \times \sqrt{\frac{Q}{V}}$$ + +where $\sigma$ = daily volatility, $\eta$ = impact coefficient. + +```python +from ml4t.backtest.execution import SquareRootImpact + +engine = Engine( + feed, strategy, config, + market_impact_model=SquareRootImpact(eta=0.5), +) +``` + +Square-root impact is the empirical consensus for equity markets (Almgren-Chriss, Barra). + +### Volume Participation Limits + +Prevent orders from consuming too much bar volume: + +```python +from ml4t.backtest.execution import VolumeParticipationLimit + +engine = Engine( + feed, strategy, config, + execution_limits=VolumeParticipationLimit(max_participation=0.10), +) +``` + +Orders exceeding 10% of bar volume are partially filled (the remainder stays pending). + +## Cost Impact Analysis + +To measure cost impact, run the same strategy with and without costs: + +```python +# Full costs +config_real = BacktestConfig( + commission_rate=0.002, + slippage_rate=0.002, +) + +# Zero costs +config_zero = BacktestConfig( + commission_rate=0.0, + slippage_rate=0.0, +) + +result_real = Engine(feed, strategy, config_real).run() +result_zero = Engine(feed2, strategy2, config_zero).run() + +cost_drag = result_zero.metrics['total_return_pct'] - result_real.metrics['total_return_pct'] +print(f"Cost drag: {cost_drag:.2f}%") +``` + +## See It in Action + +The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book demonstrates market impact in Ch18: + +- **Cost notebooks** — LinearImpact and SquareRootImpact models applied to multi-asset portfolios +- **VolumeParticipationLimit** — preventing oversized orders in illiquid assets +- **Cost drag analysis** — comparing gross vs net returns across case studies + +## Next Steps + +- [Execution Semantics](execution-semantics.md) — fill timing, ordering, and stop modes +- [Configuration](configuration.md) — all commission and slippage parameters +- [Rebalancing](rebalancing.md) — how costs interact with weight-based rebalancing diff --git a/docs/user-guide/rebalancing.md b/docs/user-guide/rebalancing.md index fac3d7f6..797dda5e 100644 --- a/docs/user-guide/rebalancing.md +++ b/docs/user-guide/rebalancing.md @@ -94,6 +94,15 @@ class MyOptimizer: return {"AAPL": 0.3, "MSFT": 0.3, "GOOG": 0.4} ``` +## See It in Action + +The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book uses TargetWeightExecutor extensively: + +- **Ch16 case studies** — all 6 Engine-based cases (ETFs, FX, equities, crypto, futures, options) use TargetWeightExecutor for ML prediction → portfolio weight → rebalance +- **Ch17** (`portfolio_construction`) — portfolio optimization with weight constraints + +The common pattern: ML model generates predictions, predictions are converted to portfolio weights, TargetWeightExecutor handles the order generation and execution. + ## Next Steps - [Strategies](strategies.md) -- strategy patterns and templates diff --git a/docs/user-guide/results.md b/docs/user-guide/results.md index 8dc5fa9b..6f781462 100644 --- a/docs/user-guide/results.md +++ b/docs/user-guide/results.md @@ -166,6 +166,14 @@ print(result.config.describe()) print(result.config.preset_name) ``` +## See It in Action + +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 / NB06** (`sharpe_ratio_inference`) — statistical inference on backtest results + ## Next Steps - [Quickstart](../getting-started/quickstart.md) -- end-to-end examples diff --git a/docs/user-guide/risk-management.md b/docs/user-guide/risk-management.md index add27690..4caefeea 100644 --- a/docs/user-guide/risk-management.md +++ b/docs/user-guide/risk-management.md @@ -287,6 +287,17 @@ Each limit check returns a `LimitResult` with an action: | `reduce` | Reduce position sizes by a percentage | | `halt` | Stop opening new positions | +## See It in Action + +The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book demonstrates risk management in Ch19 case studies: + +- **ETFs** — RuleChain with StopLoss + TrailingStop on multi-asset ETF portfolios +- **FX Pairs** — StopLoss + TakeProfit + TrailingStop for currency strategies +- **CME Futures** — Risk rules with ContractSpec and per-contract commission +- **US Equities** — MaxDrawdownLimit and DailyLossLimit portfolio protection + +The case studies show progressive complexity: basic stop-loss → trailing stops → rule chains → portfolio limits. + ## Next Steps - [Execution Semantics](execution-semantics.md) -- stop fill modes and trailing stop timing diff --git a/docs/user-guide/stateful-strategies.md b/docs/user-guide/stateful-strategies.md new file mode 100644 index 00000000..6946b9cc --- /dev/null +++ b/docs/user-guide/stateful-strategies.md @@ -0,0 +1,409 @@ +# Stateful Strategies + +Stateful strategies are the reason event-driven backtesting exists. In a vectorized framework, every signal is computed in advance from historical data alone. In an event-driven engine, each trading decision can depend on the **entire history of prior decisions** — fills, P&L, position state, equity path. This feedback loop is impossible to vectorize. + +## When You Need Event-Driven + +Use vectorized backtesting when your signal is a pure function of price history: + +``` +signal[t] = f(prices[0:t]) # No feedback — vectorizable +``` + +Use event-driven backtesting when your trading decision depends on execution state: + +``` +action[t] = g(prices[0:t], fills[0:t], equity[0:t]) # Feedback — requires event loop +``` + +Five categories of stateful patterns: + +| Pattern | State Dependency | Example | +|---------|-----------------|---------| +| Feedback loops | Position size depends on realized P&L | Kelly sizing | +| Conditional chains | Entry N depends on P&L of entries 1..N-1 | Pyramiding | +| Cross-asset coordination | Asset A's order depends on asset B's fill | Pairs trading | +| Path-dependent sizing | Equity curve drives future position sizes | Drawdown circuit breaker | +| Reactive order management | Each fill triggers new orders | Grid trading | + +## Pattern 1: Feedback Loops (Adaptive Kelly Sizing) + +Position size adapts based on realized win rate and payoff ratio. The feedback: `position_size → P&L → Kelly_fraction → next_position_size`. + +```python +from ml4t.backtest import Strategy + +class AdaptiveKellySizingStrategy(Strategy): + def __init__(self, base_size=0.10, min_size=0.02, max_size=0.25, + kelly_fraction=0.5, min_trades=5): + self.base_size = base_size + self.min_size = min_size + self.max_size = max_size + self.kelly_fraction = kelly_fraction + self.min_trades = min_trades + + def _kelly_size(self, broker, asset): + """Half-Kelly position sizing from realized trade stats.""" + stats = broker.get_asset_stats(asset) + if stats.total_trades < self.min_trades: + return self.base_size + + w = stats.recent_win_rate + wins = [p for p in stats.recent_pnls if p > 0] + losses = [p for p in stats.recent_pnls if p <= 0] + if not wins or not losses: + return self.base_size + + r = sum(wins) / len(wins) / abs(sum(losses) / len(losses)) + f_star = max(0.0, w - (1 - w) / r) * self.kelly_fraction + return max(self.min_size, min(self.max_size, f_star)) + + def on_data(self, timestamp, data, context, broker): + for asset, bar in data.items(): + signal = bar.get("signals", {}).get("signal", 0) or 0 + price = bar.get("close", 0) + if price <= 0: + continue + + position = broker.get_position(asset) + if position is None and signal > 0.5: + size_frac = self._kelly_size(broker, asset) + shares = (broker.get_account_value() * size_frac) / price + if shares > 0: + broker.submit_order(asset, shares) + elif position is not None and signal < -0.5: + broker.close_position(asset) +``` + +**Why vectorized fails**: The Kelly fraction at bar N depends on the win rate from trades 0..N-1, but each trade's P&L depends on its size, which was set by the Kelly fraction at entry time. This circular dependency requires sequential execution. + +## Pattern 2: Conditional Chains (Pyramiding) + +Add to winners: each new entry triggers only when prior entries have accumulated enough unrealized profit. The chain: `entry_1 → profit_check → entry_2 → profit_check → entry_3`. + +```python +from collections import defaultdict +from ml4t.backtest import Strategy + +class PyramidingStrategy(Strategy): + def __init__(self, max_levels=3, profit_threshold=0.02, + base_size=0.10, size_decay=0.5): + self.max_levels = max_levels + self.profit_threshold = profit_threshold + self.base_size = base_size + self.size_decay = size_decay + self.pyramid_levels = defaultdict(int) + + def on_data(self, timestamp, data, context, broker): + for asset, bar in data.items(): + signal = bar.get("signals", {}).get("signal", 0) or 0 + price = bar.get("close", 0) + if price <= 0: + continue + + position = broker.get_position(asset) + + if position is None: + if signal > 0.5: + equity = broker.get_account_value() + shares = (equity * self.base_size) / price + if shares > 0: + broker.submit_order(asset, shares) + self.pyramid_levels[asset] = 1 + continue + + if signal < -0.5: + broker.close_position(asset) + self.pyramid_levels[asset] = 0 + continue + + # Pyramid up on profit + level = self.pyramid_levels[asset] + pnl_pct = position.pnl_percent() + if level < self.max_levels and pnl_pct > self.profit_threshold * level: + decay = self.size_decay ** level + equity = broker.get_account_value() + shares = (equity * self.base_size * decay) / price + if shares > 0: + broker.submit_order(asset, shares) + self.pyramid_levels[asset] = level + 1 +``` + +**Why vectorized fails**: Whether entry 2 happens depends on the unrealized P&L of entry 1, which depends on entry 1's fill price and size. The fill price includes slippage, which may depend on volume and order size. Each link in the chain is only knowable at execution time. + +## Pattern 3: Cross-Asset Coordination (Pairs Trading) + +Trade the spread between two correlated assets. Entry and exit of asset A is conditioned on the price relationship with asset B. + +```python +from ml4t.backtest import Strategy + +class PairsTradingStrategy(Strategy): + def __init__(self, asset_a="A", asset_b="B", lookback=20, + entry_zscore=2.0, exit_zscore=0.5, position_size=0.10): + self.asset_a = asset_a + self.asset_b = asset_b + self.lookback = lookback + self.entry_zscore = entry_zscore + self.exit_zscore = exit_zscore + self.position_size = position_size + self.price_history_a = [] + self.price_history_b = [] + self.pair_status = "flat" + + def _compute_zscore(self): + if len(self.price_history_a) < self.lookback: + return None + ratios = [b / a for a, b in zip( + self.price_history_a[-self.lookback:], + self.price_history_b[-self.lookback:]) if a > 0] + if len(ratios) < 2: + return None + mean_r = sum(ratios) / len(ratios) + std_r = (sum((r - mean_r) ** 2 for r in ratios) / (len(ratios) - 1)) ** 0.5 + if std_r == 0: + return None + return (self.price_history_b[-1] / self.price_history_a[-1] - mean_r) / std_r + + def on_data(self, timestamp, data, context, broker): + bar_a, bar_b = data.get(self.asset_a), data.get(self.asset_b) + if bar_a is None or bar_b is None: + return + + price_a, price_b = bar_a.get("close", 0), bar_b.get("close", 0) + if price_a <= 0 or price_b <= 0: + return + + self.price_history_a.append(price_a) + self.price_history_b.append(price_b) + + z = self._compute_zscore() + if z is None: + return + + equity = broker.get_account_value() + + if self.pair_status == "flat": + if z > self.entry_zscore: + shares_a = (equity * self.position_size) / price_a + shares_b = (equity * self.position_size) / price_b + if shares_a > 0 and shares_b > 0: + broker.submit_order(self.asset_a, shares_a) + broker.submit_order(self.asset_b, -shares_b) + self.pair_status = "short_spread" + elif z < -self.entry_zscore: + shares_a = (equity * self.position_size) / price_a + shares_b = (equity * self.position_size) / price_b + if shares_a > 0 and shares_b > 0: + broker.submit_order(self.asset_a, -shares_a) + broker.submit_order(self.asset_b, shares_b) + self.pair_status = "long_spread" + elif abs(z) < self.exit_zscore: + broker.close_position(self.asset_a) + broker.close_position(self.asset_b) + self.pair_status = "flat" +``` + +**Why vectorized fails**: Position in A affects available capital for B. If A's order gets rejected (insufficient cash, margin limits), B shouldn't be entered either — the pair is meaningless as a single leg. Capital allocation across the two legs depends on execution outcomes. + +## Pattern 4: Path-Dependent Sizing (Drawdown Circuit Breaker) + +Reduce or halt trading when portfolio drawdown exceeds thresholds. The feedback: `equity_curve → drawdown → sizing_multiplier → future_equity_curve`. + +```python +from ml4t.backtest import Strategy + +class DrawdownCircuitBreakerStrategy(Strategy): + def __init__(self, base_size=0.10, caution_threshold=0.05, + halt_threshold=0.10, reduction_factor=0.5, recovery_rate=0.01): + self.base_size = base_size + self.caution_threshold = caution_threshold + self.halt_threshold = halt_threshold + self.reduction_factor = reduction_factor + self.recovery_rate = recovery_rate + self.peak_equity = 0.0 + self.sizing_multiplier = 1.0 + + def on_data(self, timestamp, data, context, broker): + equity = broker.get_account_value() + + # Update peak and compute drawdown + if equity > self.peak_equity: + self.peak_equity = equity + dd = (self.peak_equity - equity) / self.peak_equity if self.peak_equity > 0 else 0.0 + + # Adjust sizing multiplier + if dd < self.caution_threshold: + self.sizing_multiplier = min(1.0, self.sizing_multiplier + self.recovery_rate) + elif dd < self.halt_threshold: + range_pct = (dd - self.caution_threshold) / (self.halt_threshold - self.caution_threshold) + self.sizing_multiplier = self.reduction_factor * (1 - range_pct) + else: + self.sizing_multiplier = 0.0 + + for asset, bar in data.items(): + signal = bar.get("signals", {}).get("signal", 0) or 0 + price = bar.get("close", 0) + if price <= 0: + continue + + position = broker.get_position(asset) + if position is None and signal > 0.5: + if self.sizing_multiplier <= 0: + continue # Trading halted + effective_size = self.base_size * self.sizing_multiplier + shares = (equity * effective_size) / price + if shares > 0: + broker.submit_order(asset, shares) + elif position is not None and signal < -0.5: + broker.close_position(asset) +``` + +**Why vectorized fails**: The sizing multiplier at bar N depends on the drawdown from bars 0..N-1, but the equity at each prior bar depends on the sizing decisions made at those bars. The equity path and the sizing path are co-determined — you can't compute one without the other. + +## Pattern 5: Reactive Order Management (Grid Trading) + +Place limit orders on a grid; each fill triggers a new order at the adjacent level. The grid state is fully dynamic and depends on fill history. + +```python +from ml4t.backtest import Strategy +from ml4t.backtest.types import OrderType + +class GridTradingStrategy(Strategy): + def __init__(self, asset="ASSET", grid_spacing=0.01, num_levels=5, + order_size=100, max_position=500, recenter_threshold=0.05): + self.asset = asset + self.grid_spacing = grid_spacing + self.num_levels = num_levels + self.order_size = order_size + self.max_position = max_position + self.recenter_threshold = recenter_threshold + self.reference_price = 0.0 + self.grid_orders = {} # level → order_id + self.initialized = False + + def _place_grid(self, broker, price): + self.reference_price = price + self.grid_orders.clear() + for i in range(1, self.num_levels + 1): + buy_order = broker.submit_order( + self.asset, self.order_size, + order_type=OrderType.LIMIT, + limit_price=price * (1 - self.grid_spacing * i)) + if buy_order: + self.grid_orders[-i] = buy_order.order_id + sell_order = broker.submit_order( + self.asset, -self.order_size, + order_type=OrderType.LIMIT, + limit_price=price * (1 + self.grid_spacing * i)) + if sell_order: + self.grid_orders[i] = sell_order.order_id + + def on_data(self, timestamp, data, context, broker): + bar = data.get(self.asset) + if bar is None: + return + price = bar.get("close", 0) + if price <= 0: + return + + if not self.initialized: + self._place_grid(broker, price) + self.initialized = True + return + + # React to fills + for level, order_id in list(self.grid_orders.items()): + order = broker.get_order(order_id) + if order is not None and order.status.value == "filled": + del self.grid_orders[level] + # Buy filled → place sell above; Sell filled → place buy below + new_level = level + 1 if level < 0 else level - 1 + if new_level != 0 and new_level not in self.grid_orders: + new_qty = -self.order_size if level < 0 else self.order_size + new_price = self.reference_price * ( + 1 + self.grid_spacing * abs(new_level) * (-1 if new_qty > 0 else 1)) + order = broker.submit_order( + self.asset, new_qty, + order_type=OrderType.LIMIT, limit_price=new_price) + if order: + self.grid_orders[new_level] = order.order_id + + # Recenter if price drifted too far + if abs(price - self.reference_price) / self.reference_price > self.recenter_threshold: + for oid in self.grid_orders.values(): + broker.cancel_order(oid) + self.grid_orders.clear() + self._place_grid(broker, price) +``` + +**Why vectorized fails**: The entire order book is reactive — each fill changes the grid, which changes which orders exist, which changes future fills. The full state evolution requires sequential event processing. + +## Combining Patterns + +Real strategies often combine multiple stateful patterns. For example, a pairs trading strategy with drawdown protection: + +```python +class ProtectedPairsStrategy(Strategy): + def __init__(self, asset_a, asset_b): + self.pairs = PairsTradingStrategy(asset_a, asset_b) + self.breaker = DrawdownCircuitBreakerStrategy() + + def on_data(self, timestamp, data, context, broker): + # Update drawdown state + self.breaker.on_data(timestamp, {}, context, broker) + + # Only trade pairs if circuit breaker allows + if self.breaker.sizing_multiplier > 0: + self.pairs.on_data(timestamp, data, context, broker) +``` + +## Broker State API + +Stateful strategies depend on querying execution state. Key broker methods: + +| Method | Returns | Used By | +|--------|---------|---------| +| `get_position(asset)` | Position or None | All patterns | +| `get_positions()` | Dict of all positions | Multi-asset | +| `get_account_value()` | Total portfolio value | Sizing | +| `get_cash()` | Available cash | Capital allocation | +| `get_asset_stats(asset)` | Trade statistics | Kelly sizing | +| `get_order(order_id)` | Order status | Grid trading | +| `get_rejected_orders()` | List of rejections | Error handling | + +`Position` objects expose: + +| Attribute | Description | +|-----------|-------------| +| `quantity` | Current shares held | +| `entry_price` | Average entry price | +| `bars_held` | Bars since entry | +| `pnl_percent()` | Unrealized P&L as percentage | +| `high_water_mark` | Highest price since entry | + +## Testing Stateful Strategies + +Stateful strategies need tests that verify state transitions, not just final P&L: + +```python +def test_kelly_adapts_to_losses(): + """After consecutive losses, Kelly should reduce position size.""" + strategy = AdaptiveKellySizingStrategy(base_size=0.10, min_trades=3) + # ... run with losing signals ... + assert strategy.size_history[-1] < strategy.size_history[0] + +def test_pyramiding_respects_max_levels(): + """Should not exceed max_levels even with strong profits.""" + strategy = PyramidingStrategy(max_levels=3) + # ... run with continuously profitable signal ... + assert max(strategy.pyramid_levels.values()) <= 3 +``` + +See `examples/test_stateful_strategies.py` for 45+ test methods covering all five patterns. + +## Next Steps + +- [Strategies](strategies.md) — strategy interface and broker methods +- [Risk Management](risk-management.md) — automatic position rules +- [Execution Semantics](execution-semantics.md) — fill timing and ordering diff --git a/docs/user-guide/strategies.md b/docs/user-guide/strategies.md index a7aec34f..2f60cb67 100644 --- a/docs/user-guide/strategies.md +++ b/docs/user-guide/strategies.md @@ -272,8 +272,18 @@ class AssetSpecificRules(Strategy): 4. **Use NEXT_BAR mode** -- for production strategies, avoid SAME_BAR 5. **Validate with profiles** -- compare results across framework profiles +## See It in Action + +The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book demonstrates these patterns across multiple case studies: + +- **Ch16 / NB03** (`single_asset_ml4t_backtest`) — RSI mean-reversion Strategy with submit_order/close_position +- **Ch16 / NB04** (`framework_parity`) — same strategy compared across VectorBT and ml4t-backtest +- **Ch16 / NB13** (`futures_backtesting`) — futures strategies with ContractSpec and per-contract costs +- **Ch16 case studies** — 6 Engine-based case studies (ETFs, FX, equities, crypto, futures, options) using TargetWeightExecutor with ML predictions + ## Next Steps +- [Stateful Strategies](stateful-strategies.md) -- advanced patterns that require event-driven execution - [Risk Management](risk-management.md) -- full rule catalog and composition - [Order Types](orders.md) -- limit, stop, bracket orders in detail - [Data Feed](data-feed.md) -- how data and signals are structured diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 00000000..1094633e --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +"""Example strategies demonstrating event-driven backtesting patterns.""" diff --git a/examples/stateful_strategies.py b/examples/stateful_strategies.py new file mode 100644 index 00000000..12f6fa78 --- /dev/null +++ b/examples/stateful_strategies.py @@ -0,0 +1,562 @@ +"""Stateful strategy examples demonstrating why event-driven backtesting matters. + +Each strategy here maintains state across bars — trading decisions feed back +into future decisions. This is fundamentally impossible in vectorized frameworks +like VectorBT, where all signals must be computed in advance. + +Five reasons you need event-driven backtesting: + +1. **Feedback loops** — position size depends on realized P&L (AdaptiveKellySizing) +2. **Conditional chains** — entry N depends on P&L of entries 1..N-1 (Pyramiding) +3. **Cross-asset coordination** — two legs managed as one position (PairsTrading) +4. **Path-dependent state** — equity curve drives future sizing (DrawdownCircuitBreaker) +5. **Reactive order management** — each fill triggers new orders (GridTrading) + +These are NOT part of the public API. They are importable demonstrations with +full test coverage in test_stateful_strategies.py. +""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from ml4t.backtest.strategy import Strategy +from ml4t.backtest.types import OrderType + +if TYPE_CHECKING: + from ml4t.backtest.broker import Broker + + +# --------------------------------------------------------------------------- +# 1. Adaptive Kelly Sizing — feedback loop +# --------------------------------------------------------------------------- + + +class AdaptiveKellySizingStrategy(Strategy): + """Position size adapts based on realized win rate and payoff ratio. + + The feedback loop: position_size → P&L → Kelly_fraction → next_position_size. + In a vectorized framework, you cannot compute the Kelly fraction because it + depends on future fills that depend on the fraction itself. + + Kelly formula: f* = W - (1 - W) / R + where W = win rate, R = avg_win / avg_loss + We use half-Kelly (kelly_fraction=0.5) for safety. + """ + + def __init__( + self, + signal_column: str = "signal", + entry_threshold: float = 0.5, + exit_threshold: float = -0.5, + base_size: float = 0.10, + min_size: float = 0.02, + max_size: float = 0.25, + kelly_fraction: float = 0.5, + min_trades: int = 5, + ): + self.signal_column = signal_column + self.entry_threshold = entry_threshold + self.exit_threshold = exit_threshold + self.base_size = base_size + self.min_size = min_size + self.max_size = max_size + self.kelly_fraction = kelly_fraction + self.min_trades = min_trades + # Track the size used for each entry (for test verification) + self.size_history: list[float] = [] + + def _kelly_size(self, broker: Broker, asset: str) -> float: + """Compute position size as fraction of equity using Kelly criterion.""" + stats = broker.get_asset_stats(asset) + if stats.total_trades < self.min_trades: + return self.base_size + + w = stats.recent_win_rate + # Compute average win / average loss ratio from recent PnLs + wins = [p for p in stats.recent_pnls if p > 0] + losses = [p for p in stats.recent_pnls if p <= 0] + if not wins or not losses: + return self.base_size + + avg_win = sum(wins) / len(wins) + avg_loss = abs(sum(losses) / len(losses)) + if avg_loss == 0: + return self.max_size + + r = avg_win / avg_loss + f_star = w - (1 - w) / r # Kelly fraction + f_star = max(0.0, f_star) * self.kelly_fraction # half-Kelly + size = max(self.min_size, min(self.max_size, f_star)) + return size + + def on_data( + self, + timestamp: datetime, + data: dict[str, dict], + context: dict[str, Any], + broker: Broker, + ) -> None: + for asset, bar in data.items(): + signals = bar.get("signals", {}) + signal = signals.get(self.signal_column, 0) if signals else 0 + if signal is None: + signal = 0 + + price = bar.get("close", 0) + if price <= 0: + continue + + position = broker.get_position(asset) + + if position is None and signal > self.entry_threshold: + size_frac = self._kelly_size(broker, asset) + self.size_history.append(size_frac) + equity = broker.get_account_value() + shares = (equity * size_frac) / price + if shares > 0: + broker.submit_order(asset, shares) + elif position is not None and signal < self.exit_threshold: + broker.close_position(asset) + + +# --------------------------------------------------------------------------- +# 2. Pyramiding — conditional chains +# --------------------------------------------------------------------------- + + +class PyramidingStrategy(Strategy): + """Add to winners: each pyramid level triggers when unrealized P&L hits a threshold. + + The conditional chain: entry_1 → profit check → entry_2 → profit check → entry_3. + Each entry depends on the P&L of all prior entries, which depends on their fill + prices, which are only known at execution time. + """ + + def __init__( + self, + signal_column: str = "signal", + entry_threshold: float = 0.5, + exit_threshold: float = -0.5, + max_levels: int = 3, + profit_threshold: float = 0.02, + base_size: float = 0.10, + size_decay: float = 0.5, + adverse_threshold: float = 0.03, + ): + self.signal_column = signal_column + self.entry_threshold = entry_threshold + self.exit_threshold = exit_threshold + self.max_levels = max_levels + self.profit_threshold = profit_threshold + self.base_size = base_size + self.size_decay = size_decay + self.adverse_threshold = adverse_threshold + # State: current pyramid level per asset + self.pyramid_levels: dict[str, int] = defaultdict(int) + # Track entry prices per level for the adverse check + self.level_entries: dict[str, list[float]] = defaultdict(list) + + def on_data( + self, + timestamp: datetime, + data: dict[str, dict], + context: dict[str, Any], + broker: Broker, + ) -> None: + for asset, bar in data.items(): + signals = bar.get("signals", {}) + signal = signals.get(self.signal_column, 0) if signals else 0 + if signal is None: + signal = 0 + + price = bar.get("close", 0) + if price <= 0: + continue + + position = broker.get_position(asset) + + if position is None: + # No position — enter on signal + if signal > self.entry_threshold: + equity = broker.get_account_value() + shares = (equity * self.base_size) / price + if shares > 0: + broker.submit_order(asset, shares) + self.pyramid_levels[asset] = 1 + self.level_entries[asset] = [price] + continue + + # Have a position — check exit first + if signal < self.exit_threshold: + broker.close_position(asset) + self.pyramid_levels[asset] = 0 + self.level_entries[asset] = [] + continue + + level = self.pyramid_levels[asset] + pnl_pct = position.pnl_percent() + + # Scale out on adverse move + if ( + position.high_water_mark is not None + and price < position.high_water_mark * (1 - self.adverse_threshold) + and level > 1 + ): + broker.reduce_position(asset, 0.5) + self.pyramid_levels[asset] = max(1, level - 1) + continue + + # Pyramid up on profit + if level < self.max_levels and pnl_pct > self.profit_threshold * level: + decay = self.size_decay**level + equity = broker.get_account_value() + shares = (equity * self.base_size * decay) / price + if shares > 0: + broker.submit_order(asset, shares) + self.pyramid_levels[asset] = level + 1 + self.level_entries[asset].append(price) + + +# --------------------------------------------------------------------------- +# 3. Pairs Trading — cross-asset coordination +# --------------------------------------------------------------------------- + + +class PairsTradingStrategy(Strategy): + """Trade the spread between two correlated assets. + + Cross-asset coordination: the entry/exit of asset A is conditioned on the + price of asset B. You cannot vectorize this because position in A affects + available capital for B, and fills in A affect the timing of orders for B. + """ + + def __init__( + self, + asset_a: str = "A", + asset_b: str = "B", + lookback: int = 20, + entry_zscore: float = 2.0, + exit_zscore: float = 0.5, + position_size: float = 0.10, + ): + self.asset_a = asset_a + self.asset_b = asset_b + self.lookback = lookback + self.entry_zscore = entry_zscore + self.exit_zscore = exit_zscore + self.position_size = position_size + # State + self.price_history_a: list[float] = [] + self.price_history_b: list[float] = [] + self.pair_status: str = "flat" # "flat", "long_spread", "short_spread" + self.entry_zscore_value: float = 0.0 + + def _compute_zscore(self) -> float | None: + """Compute z-score of the price ratio B/A.""" + if len(self.price_history_a) < self.lookback: + return None + ratios = [ + b / a + for a, b in zip( + self.price_history_a[-self.lookback :], + self.price_history_b[-self.lookback :], + ) + if a > 0 + ] + if len(ratios) < 2: + return None + mean_r = sum(ratios) / len(ratios) + var_r = sum((r - mean_r) ** 2 for r in ratios) / (len(ratios) - 1) + std_r = var_r**0.5 + if std_r == 0: + return None + current_ratio = self.price_history_b[-1] / self.price_history_a[-1] + return (current_ratio - mean_r) / std_r + + def on_data( + self, + timestamp: datetime, + data: dict[str, dict], + context: dict[str, Any], + broker: Broker, + ) -> None: + bar_a = data.get(self.asset_a) + bar_b = data.get(self.asset_b) + if bar_a is None or bar_b is None: + return + + price_a = bar_a.get("close", 0) + price_b = bar_b.get("close", 0) + if price_a <= 0 or price_b <= 0: + return + + self.price_history_a.append(price_a) + self.price_history_b.append(price_b) + + z = self._compute_zscore() + if z is None: + return + + equity = broker.get_account_value() + + if self.pair_status == "flat": + if z > self.entry_zscore: + # Spread too wide (B expensive relative to A) → short B, long A + shares_a = (equity * self.position_size) / price_a + shares_b = (equity * self.position_size) / price_b + if shares_a > 0 and shares_b > 0: + broker.submit_order(self.asset_a, shares_a) + broker.submit_order(self.asset_b, -shares_b) + self.pair_status = "short_spread" + self.entry_zscore_value = z + elif z < -self.entry_zscore: + # Spread too narrow (A expensive relative to B) → long B, short A + shares_a = (equity * self.position_size) / price_a + shares_b = (equity * self.position_size) / price_b + if shares_a > 0 and shares_b > 0: + broker.submit_order(self.asset_a, -shares_a) + broker.submit_order(self.asset_b, shares_b) + self.pair_status = "long_spread" + self.entry_zscore_value = z + else: + # Check for exit: z-score reverted toward zero + if abs(z) < self.exit_zscore: + broker.close_position(self.asset_a) + broker.close_position(self.asset_b) + self.pair_status = "flat" + self.entry_zscore_value = 0.0 + + +# --------------------------------------------------------------------------- +# 4. Drawdown Circuit Breaker — path-dependent state +# --------------------------------------------------------------------------- + + +class DrawdownCircuitBreakerStrategy(Strategy): + """Reduce or halt trading when portfolio drawdown exceeds thresholds. + + Path-dependent feedback: equity_curve → drawdown → sizing_multiplier → + future_equity_curve. The sizing multiplier at bar N depends on the entire + equity path from bar 0 to bar N-1, which depends on all prior sizing + decisions. Impossible to vectorize. + """ + + def __init__( + self, + signal_column: str = "signal", + entry_threshold: float = 0.5, + exit_threshold: float = -0.5, + base_size: float = 0.10, + caution_threshold: float = 0.05, + halt_threshold: float = 0.10, + reduction_factor: float = 0.5, + recovery_rate: float = 0.01, + ): + self.signal_column = signal_column + self.entry_threshold = entry_threshold + self.exit_threshold = exit_threshold + self.base_size = base_size + self.caution_threshold = caution_threshold + self.halt_threshold = halt_threshold + self.reduction_factor = reduction_factor + self.recovery_rate = recovery_rate + # State + self.peak_equity: float = 0.0 + self.sizing_multiplier: float = 1.0 + # Track for test verification + self.multiplier_history: list[float] = [] + + def on_data( + self, + timestamp: datetime, + data: dict[str, dict], + context: dict[str, Any], + broker: Broker, + ) -> None: + equity = broker.get_account_value() + + # Update peak and compute drawdown + if equity > self.peak_equity: + self.peak_equity = equity + drawdown = (self.peak_equity - equity) / self.peak_equity if self.peak_equity > 0 else 0.0 + + # Update sizing multiplier based on drawdown + if drawdown < self.caution_threshold: + # Below caution: recover toward 1.0 + self.sizing_multiplier = min(1.0, self.sizing_multiplier + self.recovery_rate) + elif drawdown < self.halt_threshold: + # Between caution and halt: linearly interpolate from reduction_factor to 0 + range_pct = (drawdown - self.caution_threshold) / ( + self.halt_threshold - self.caution_threshold + ) + self.sizing_multiplier = self.reduction_factor * (1 - range_pct) + else: + # At or beyond halt: no new entries + self.sizing_multiplier = 0.0 + + self.multiplier_history.append(self.sizing_multiplier) + + for asset, bar in data.items(): + signals = bar.get("signals", {}) + signal = signals.get(self.signal_column, 0) if signals else 0 + if signal is None: + signal = 0 + + price = bar.get("close", 0) + if price <= 0: + continue + + position = broker.get_position(asset) + + if position is None and signal > self.entry_threshold: + if self.sizing_multiplier <= 0: + continue # Halted + effective_size = self.base_size * self.sizing_multiplier + shares = (equity * effective_size) / price + if shares > 0: + broker.submit_order(asset, shares) + elif position is not None and signal < self.exit_threshold: + broker.close_position(asset) + + +# --------------------------------------------------------------------------- +# 5. Grid Trading — reactive order management +# --------------------------------------------------------------------------- + + +class GridTradingStrategy(Strategy): + """Place limit orders on a grid; each fill triggers a new order at the adjacent level. + + Reactive order management: when a buy limit fills, place a sell limit one + grid level above. When a sell limit fills, place a buy limit one grid level + below. The grid state is fully dynamic and depends on the fill history. + """ + + def __init__( + self, + asset: str = "ASSET", + grid_spacing: float = 0.01, + num_levels: int = 5, + order_size: float = 100, + max_position: float = 500, + recenter_threshold: float = 0.05, + ): + self.asset = asset + self.grid_spacing = grid_spacing + self.num_levels = num_levels + self.order_size = order_size + self.max_position = max_position + self.recenter_threshold = recenter_threshold + # State + self.reference_price: float = 0.0 + self.grid_orders: dict[int, str] = {} # level → order_id + self.initialized: bool = False + # Track for test verification + self.fills_seen: int = 0 + + def _place_grid(self, broker: Broker, price: float) -> None: + """Place buy limits below and sell limits above reference price.""" + self.reference_price = price + self.grid_orders.clear() + + for i in range(1, self.num_levels + 1): + # Buy limits below + buy_price = price * (1 - self.grid_spacing * i) + order = broker.submit_order( + self.asset, + self.order_size, + order_type=OrderType.LIMIT, + limit_price=buy_price, + ) + if order: + self.grid_orders[-i] = order.order_id + + # Sell limits above + sell_price = price * (1 + self.grid_spacing * i) + order = broker.submit_order( + self.asset, + -self.order_size, + order_type=OrderType.LIMIT, + limit_price=sell_price, + ) + if order: + self.grid_orders[i] = order.order_id + + def _cancel_all(self, broker: Broker) -> None: + """Cancel all outstanding grid orders.""" + for order_id in self.grid_orders.values(): + broker.cancel_order(order_id) + self.grid_orders.clear() + + def on_data( + self, + timestamp: datetime, + data: dict[str, dict], + context: dict[str, Any], + broker: Broker, + ) -> None: + bar = data.get(self.asset) + if bar is None: + return + + price = bar.get("close", 0) + if price <= 0: + return + + # Initialize grid on first bar + if not self.initialized: + self._place_grid(broker, price) + self.initialized = True + return + + # Check for filled orders and react + filled_levels: list[int] = [] + for level, order_id in list(self.grid_orders.items()): + order = broker.get_order(order_id) + if order is not None and order.status.value == "filled": + filled_levels.append(level) + self.fills_seen += 1 + + # React to fills + position = broker.get_position(self.asset) + current_qty = position.quantity if position else 0.0 + + for level in filled_levels: + del self.grid_orders[level] + + if level < 0: + # Buy filled → place sell one level above (closer to reference) + new_level = level + 1 + if new_level != 0 and new_level not in self.grid_orders: + sell_price = self.reference_price * (1 + self.grid_spacing * abs(new_level)) + if abs(current_qty - self.order_size) <= self.max_position: + order = broker.submit_order( + self.asset, + -self.order_size, + order_type=OrderType.LIMIT, + limit_price=sell_price, + ) + if order: + self.grid_orders[new_level] = order.order_id + else: + # Sell filled → place buy one level below (closer to reference) + new_level = level - 1 + if new_level != 0 and new_level not in self.grid_orders: + buy_price = self.reference_price * (1 - self.grid_spacing * abs(new_level)) + if abs(current_qty + self.order_size) <= self.max_position: + order = broker.submit_order( + self.asset, + self.order_size, + order_type=OrderType.LIMIT, + limit_price=buy_price, + ) + if order: + self.grid_orders[new_level] = order.order_id + + # Recenter if price drifted too far + if abs(price - self.reference_price) / self.reference_price > self.recenter_threshold: + self._cancel_all(broker) + self._place_grid(broker, price) diff --git a/examples/test_stateful_strategies.py b/examples/test_stateful_strategies.py new file mode 100644 index 00000000..cc8d483d --- /dev/null +++ b/examples/test_stateful_strategies.py @@ -0,0 +1,715 @@ +"""Tests for stateful strategy examples. + +Each test group verifies the key stateful behavior that makes the strategy +impossible to implement in a vectorized framework. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +import numpy as np +import polars as pl + +from ml4t.backtest import BacktestConfig, DataFeed, Engine + +from .stateful_strategies import ( + AdaptiveKellySizingStrategy, + DrawdownCircuitBreakerStrategy, + GridTradingStrategy, + PairsTradingStrategy, + PyramidingStrategy, +) + +# --------------------------------------------------------------------------- +# Data helpers +# --------------------------------------------------------------------------- + + +def make_trending_data( + n_bars: int = 100, + drift: float = 0.003, + volatility: float = 0.01, + seed: int = 42, + asset: str = "ASSET", +) -> pl.DataFrame: + """Generate upward-trending price data (good for pyramiding/Kelly).""" + rng = np.random.default_rng(seed) + returns = rng.normal(drift, volatility, n_bars) + prices = 100.0 * np.cumprod(1 + returns) + + base = datetime(2023, 1, 1) + rows = [] + for i in range(n_bars): + p = float(prices[i]) + rows.append( + { + "timestamp": base + timedelta(days=i), + "asset": asset, + "open": p * 0.999, + "high": p * 1.005, + "low": p * 0.995, + "close": p, + "volume": 1_000_000, + } + ) + return pl.DataFrame(rows) + + +def make_alternating_signal( + n_bars: int = 100, + cycle: int = 10, + asset: str = "ASSET", +) -> pl.DataFrame: + """Generate alternating +1/-1 signal with given cycle length.""" + base = datetime(2023, 1, 1) + rows = [] + for i in range(n_bars): + rows.append( + { + "timestamp": base + timedelta(days=i), + "asset": asset, + "signal": 1.0 if (i // cycle) % 2 == 0 else -1.0, + } + ) + return pl.DataFrame(rows) + + +def make_drawdown_data( + n_bars: int = 100, + crash_start: int = 40, + crash_bars: int = 10, + crash_pct: float = 0.20, + seed: int = 42, + asset: str = "ASSET", +) -> pl.DataFrame: + """Generate prices with a crash episode for circuit breaker testing.""" + rng = np.random.default_rng(seed) + prices = [100.0] + for i in range(1, n_bars): + if crash_start <= i < crash_start + crash_bars: + # Crash: steep decline + daily_drop = crash_pct / crash_bars + prices.append(prices[-1] * (1 - daily_drop)) + else: + # Normal: small positive drift + prices.append(prices[-1] * (1 + rng.normal(0.001, 0.005))) + + base = datetime(2023, 1, 1) + rows = [] + for i in range(n_bars): + p = float(prices[i]) + rows.append( + { + "timestamp": base + timedelta(days=i), + "asset": asset, + "open": p * 0.999, + "high": p * 1.005, + "low": p * 0.995, + "close": p, + "volume": 1_000_000, + } + ) + return pl.DataFrame(rows) + + +def make_pair_data( + n_bars: int = 100, + seed: int = 42, + asset_a: str = "A", + asset_b: str = "B", +) -> pl.DataFrame: + """Generate two correlated assets that diverge then converge.""" + rng = np.random.default_rng(seed) + # Shared factor + idiosyncratic noise + common = rng.normal(0, 0.01, n_bars) + noise_a = rng.normal(0, 0.005, n_bars) + noise_b = rng.normal(0, 0.005, n_bars) + + # Inject a divergence/convergence cycle + divergence = np.zeros(n_bars) + for i in range(n_bars): + if 25 <= i < 40: + divergence[i] = 0.003 * (i - 25) # Diverge + elif 40 <= i < 55: + divergence[i] = 0.003 * (55 - i) # Converge back + + prices_a = 100.0 * np.cumprod(1 + common + noise_a) + prices_b = 100.0 * np.cumprod(1 + common + noise_b + divergence) + + base = datetime(2023, 1, 1) + rows = [] + for i in range(n_bars): + pa, pb = float(prices_a[i]), float(prices_b[i]) + for asset, p in [(asset_a, pa), (asset_b, pb)]: + rows.append( + { + "timestamp": base + timedelta(days=i), + "asset": asset, + "open": p * 0.999, + "high": p * 1.005, + "low": p * 0.995, + "close": p, + "volume": 1_000_000, + } + ) + return pl.DataFrame(rows) + + +def make_grid_data( + n_bars: int = 100, + seed: int = 42, + asset: str = "ASSET", + volatility: float = 0.015, +) -> pl.DataFrame: + """Generate mean-reverting prices for grid trading.""" + rng = np.random.default_rng(seed) + prices = [100.0] + for _ in range(1, n_bars): + # Mean-reverting around 100 + reversion = 0.05 * (100.0 - prices[-1]) + prices.append(prices[-1] * (1 + reversion / prices[-1] + rng.normal(0, volatility))) + + base = datetime(2023, 1, 1) + rows = [] + for i in range(n_bars): + p = float(prices[i]) + rows.append( + { + "timestamp": base + timedelta(days=i), + "asset": asset, + "open": p * 0.999, + "high": p * 1.008, + "low": p * 0.992, + "close": p, + "volume": 1_000_000, + } + ) + return pl.DataFrame(rows) + + +def _fast_config(**overrides: object) -> BacktestConfig: + """Get the fast preset with optional overrides.""" + config = BacktestConfig.from_preset("fast") + for key, value in overrides.items(): + setattr(config, key, value) + return config + + +# --------------------------------------------------------------------------- +# 1. Adaptive Kelly Sizing tests +# --------------------------------------------------------------------------- + + +class TestAdaptiveKellySizing: + """Tests for AdaptiveKellySizingStrategy.""" + + def test_starts_at_base_size(self): + """Before min_trades, should use base_size.""" + strategy = AdaptiveKellySizingStrategy( + signal_column="signal", + base_size=0.10, + min_trades=5, + ) + + prices = make_trending_data(30, drift=0.002, seed=1) + signals = make_alternating_signal(30, cycle=5) # Frequent trades + feed = DataFeed(prices_df=prices, signals_df=signals) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + # First entry should use base_size since no trades yet + assert len(strategy.size_history) > 0 + assert strategy.size_history[0] == 0.10 + + def test_adapts_after_enough_trades(self): + """After min_trades, size should adapt based on Kelly formula.""" + strategy = AdaptiveKellySizingStrategy( + signal_column="signal", + base_size=0.10, + min_trades=3, + min_size=0.02, + max_size=0.25, + ) + + # Lots of bars with frequent signal flips to accumulate trades + prices = make_trending_data(200, drift=0.002, seed=10) + signals = make_alternating_signal(200, cycle=8) + feed = DataFeed(prices_df=prices, signals_df=signals) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + # Should have adapted: not all sizes are base_size + if len(strategy.size_history) > 3: + later_sizes = strategy.size_history[3:] + # At least one adapted size should differ from base + assert any(abs(s - 0.10) > 1e-6 for s in later_sizes), ( + f"All sizes stayed at base: {later_sizes}" + ) + + def test_clamps_to_bounds(self): + """Size should always be within [min_size, max_size].""" + strategy = AdaptiveKellySizingStrategy( + signal_column="signal", + min_size=0.03, + max_size=0.20, + min_trades=2, + ) + + prices = make_trending_data(200, drift=0.003, seed=7) + signals = make_alternating_signal(200, cycle=6) + feed = DataFeed(prices_df=prices, signals_df=signals) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + for size in strategy.size_history: + assert 0.03 - 1e-9 <= size <= 0.20 + 1e-9, f"Size {size} out of bounds" + + def test_runs_without_error(self): + """Smoke test: the strategy completes a full backtest.""" + strategy = AdaptiveKellySizingStrategy() + prices = make_trending_data(50) + signals = make_alternating_signal(50) + feed = DataFeed(prices_df=prices, signals_df=signals) + result = Engine.from_config(feed, strategy, _fast_config()).run() + assert result.metrics["final_value"] > 0 + + +# --------------------------------------------------------------------------- +# 2. Pyramiding tests +# --------------------------------------------------------------------------- + + +class TestPyramiding: + """Tests for PyramidingStrategy.""" + + def test_enters_base_level(self): + """Should enter level 1 on signal.""" + strategy = PyramidingStrategy( + signal_column="signal", + max_levels=3, + base_size=0.10, + ) + + prices = make_trending_data(50, drift=0.005, seed=1) + signals = make_alternating_signal(50, cycle=20) # Long signal for first 20 bars + feed = DataFeed(prices_df=prices, signals_df=signals) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + result = engine.run() + + # Should have entered at least once + assert len(result.trades) > 0 or len(result.broker.positions) > 0 + + def test_pyramids_on_profit(self): + """Should add levels when position is profitable.""" + strategy = PyramidingStrategy( + signal_column="signal", + max_levels=3, + profit_threshold=0.01, # Low threshold to trigger pyramids + base_size=0.05, + ) + + # Strong uptrend to ensure profitable positions + prices = make_trending_data(100, drift=0.008, seed=3) + # Signal stays positive for long stretches + signals = make_alternating_signal(100, cycle=40) + feed = DataFeed(prices_df=prices, signals_df=signals) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + # If trend was strong enough, should have pyramided above level 1 + max_level = max(strategy.pyramid_levels.values()) if strategy.pyramid_levels else 0 + # Also check level_entries for evidence of pyramiding + max_entries = max((len(v) for v in strategy.level_entries.values()), default=0) + assert max_level > 1 or max_entries > 1, ( + f"No pyramiding occurred: max_level={max_level}, max_entries={max_entries}" + ) + + def test_respects_max_levels(self): + """Should never exceed max_levels.""" + max_levels = 2 + strategy = PyramidingStrategy( + signal_column="signal", + max_levels=max_levels, + profit_threshold=0.005, + base_size=0.03, + ) + + prices = make_trending_data(150, drift=0.01, seed=5) + signals = make_alternating_signal(150, cycle=60) + feed = DataFeed(prices_df=prices, signals_df=signals) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + for level in strategy.pyramid_levels.values(): + assert level <= max_levels + + def test_exits_reset_levels(self): + """Exit signal should close position and reset pyramid state.""" + strategy = PyramidingStrategy(signal_column="signal", max_levels=3) + + prices = make_trending_data(60) + signals = make_alternating_signal(60, cycle=15) + feed = DataFeed(prices_df=prices, signals_df=signals) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + result = engine.run() + + # After exit, levels should be 0 for closed assets + if result.trades: + # If we have closed trades, at some point levels were reset + assert True # Exit path was exercised + + +# --------------------------------------------------------------------------- +# 3. Pairs Trading tests +# --------------------------------------------------------------------------- + + +class TestPairsTrading: + """Tests for PairsTradingStrategy.""" + + def test_enters_on_divergence(self): + """Should open positions when z-score exceeds threshold.""" + strategy = PairsTradingStrategy( + asset_a="A", + asset_b="B", + lookback=15, + entry_zscore=1.5, + exit_zscore=0.3, + position_size=0.10, + ) + + prices = make_pair_data(80, seed=42) + feed = DataFeed(prices_df=prices) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + result = engine.run() + + # Should have entered the pair trade at some point + assert strategy.pair_status != "flat" or len(result.trades) > 0 + + def test_exits_on_convergence(self): + """Should close positions when z-score reverts.""" + strategy = PairsTradingStrategy( + asset_a="A", + asset_b="B", + lookback=15, + entry_zscore=1.5, + exit_zscore=0.3, + position_size=0.10, + ) + + prices = make_pair_data(100, seed=42) + feed = DataFeed(prices_df=prices) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + result = engine.run() + + # If pair entered, should have at least some trades (entries and exits) + if len(result.trades) >= 2: + # Both legs should appear in trades + assets_traded = {t.symbol for t in result.trades} + assert "A" in assets_traded or "B" in assets_traded + + def test_correct_direction(self): + """When z > threshold (B expensive), should long A and short B.""" + strategy = PairsTradingStrategy( + asset_a="A", + asset_b="B", + lookback=15, + entry_zscore=1.5, + exit_zscore=0.3, + ) + + prices = make_pair_data(100, seed=42) + feed = DataFeed(prices_df=prices) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + # Just verify it runs and produces valid state + assert strategy.pair_status in ("flat", "long_spread", "short_spread") + + def test_no_entry_within_bounds(self): + """Should NOT enter when z-score is within entry threshold.""" + strategy = PairsTradingStrategy( + asset_a="A", + asset_b="B", + lookback=15, + entry_zscore=100.0, # Impossibly high threshold + exit_zscore=0.3, + ) + + prices = make_pair_data(80, seed=42) + feed = DataFeed(prices_df=prices) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + result = engine.run() + + assert len(result.trades) == 0 + assert strategy.pair_status == "flat" + + +# --------------------------------------------------------------------------- +# 4. Drawdown Circuit Breaker tests +# --------------------------------------------------------------------------- + + +class TestDrawdownCircuitBreaker: + """Tests for DrawdownCircuitBreakerStrategy.""" + + def test_normal_sizing_without_drawdown(self): + """Without drawdown, multiplier should stay at 1.0.""" + strategy = DrawdownCircuitBreakerStrategy( + signal_column="signal", + base_size=0.10, + caution_threshold=0.05, + halt_threshold=0.10, + ) + + # Gentle uptrend — no drawdown + prices = make_trending_data(30, drift=0.005, volatility=0.001, seed=1) + signals = make_alternating_signal(30, cycle=10) + feed = DataFeed(prices_df=prices, signals_df=signals) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + # After warmup, multiplier should be at or near 1.0 + if len(strategy.multiplier_history) > 5: + assert strategy.multiplier_history[-1] >= 0.9 + + def test_reduces_at_caution_threshold(self): + """Multiplier should decrease during drawdown.""" + strategy = DrawdownCircuitBreakerStrategy( + signal_column="signal", + base_size=0.80, # Large size so price drop → visible equity drawdown + caution_threshold=0.03, + halt_threshold=0.15, + reduction_factor=0.5, + ) + + # Data with a crash to trigger drawdown (20% crash with 80% invested → ~16% equity drop) + prices = make_drawdown_data(80, crash_start=30, crash_bars=15, crash_pct=0.20, seed=2) + # Stay invested: always-positive signal + base = datetime(2023, 1, 1) + signal_rows = [ + {"timestamp": base + timedelta(days=i), "asset": "ASSET", "signal": 1.0} + for i in range(80) + ] + signals = pl.DataFrame(signal_rows) + feed = DataFeed(prices_df=prices, signals_df=signals) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + # Should have reduced multiplier at some point during the crash + min_mult = min(strategy.multiplier_history) if strategy.multiplier_history else 1.0 + assert min_mult < 1.0, f"Multiplier never reduced: min={min_mult}" + + def test_halts_at_threshold(self): + """Multiplier should hit 0.0 during severe drawdown.""" + strategy = DrawdownCircuitBreakerStrategy( + signal_column="signal", + base_size=0.80, # Large size so crash is felt in equity + caution_threshold=0.02, + halt_threshold=0.10, + reduction_factor=0.5, + ) + + # Severe crash: 30% price drop with 80% invested → ~24% equity drop + prices = make_drawdown_data(80, crash_start=25, crash_bars=10, crash_pct=0.30, seed=3) + base = datetime(2023, 1, 1) + signal_rows = [ + {"timestamp": base + timedelta(days=i), "asset": "ASSET", "signal": 1.0} + for i in range(80) + ] + signals = pl.DataFrame(signal_rows) + feed = DataFeed(prices_df=prices, signals_df=signals) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + # Should have hit zero multiplier during the crash + min_mult = min(strategy.multiplier_history) if strategy.multiplier_history else 1.0 + assert min_mult < 0.01, f"Multiplier never halted: min={min_mult}" + + def test_recovers_after_drawdown(self): + """Multiplier should increase once drawdown recedes.""" + strategy = DrawdownCircuitBreakerStrategy( + signal_column="signal", + base_size=0.80, + caution_threshold=0.03, + halt_threshold=0.15, + recovery_rate=0.05, + ) + + # Crash followed by recovery + prices = make_drawdown_data(120, crash_start=30, crash_bars=10, crash_pct=0.15, seed=4) + base = datetime(2023, 1, 1) + signal_rows = [ + {"timestamp": base + timedelta(days=i), "asset": "ASSET", "signal": 1.0} + for i in range(120) + ] + signals = pl.DataFrame(signal_rows) + feed = DataFeed(prices_df=prices, signals_df=signals) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + # After the crash, multiplier should have recovered toward 1.0 + history = strategy.multiplier_history + if len(history) > 60: + # Find the min during crash and check that post-crash is higher + crash_region = history[30:50] + post_crash = history[60:] + if crash_region and post_crash: + min_crash = min(crash_region) + max_post = max(post_crash) + assert max_post > min_crash, ( + f"No recovery: crash_min={min_crash}, post_max={max_post}" + ) + + +# --------------------------------------------------------------------------- +# 5. Grid Trading tests +# --------------------------------------------------------------------------- + + +class TestGridTrading: + """Tests for GridTradingStrategy.""" + + def test_initializes_grid(self): + """Should place buy and sell limit orders on first bar.""" + strategy = GridTradingStrategy( + asset="ASSET", + grid_spacing=0.02, + num_levels=3, + order_size=50, + ) + + prices = make_grid_data(10, seed=1) + feed = DataFeed(prices_df=prices) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + # Grid should have been initialized + assert strategy.initialized + assert strategy.reference_price > 0 + + def test_reacts_to_fills(self): + """Should place reactive orders when grid levels fill.""" + strategy = GridTradingStrategy( + asset="ASSET", + grid_spacing=0.01, # 1% spacing — volatile data should trigger fills + num_levels=5, + order_size=50, + max_position=500, + ) + + # Use higher volatility to trigger limit fills + prices = make_grid_data(80, seed=42, volatility=0.025) + feed = DataFeed(prices_df=prices) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + result = engine.run() + + # Even if no grid fills triggered, it should complete without error + assert result.metrics["final_value"] > 0 + assert strategy.initialized + + def test_respects_max_position(self): + """Net position should never exceed max_position.""" + max_pos = 200 + strategy = GridTradingStrategy( + asset="ASSET", + grid_spacing=0.01, + num_levels=5, + order_size=50, + max_position=max_pos, + ) + + prices = make_grid_data(80, seed=42, volatility=0.02) + feed = DataFeed(prices_df=prices) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + # Check final position via engine's broker (result doesn't expose broker) + pos = engine.broker.get_position("ASSET") + if pos is not None: + assert abs(pos.quantity) <= max_pos + 50 + 1e-6 # Allow one order size tolerance + + def test_recenters_on_drift(self): + """Should recenter grid when price drifts beyond threshold.""" + strategy = GridTradingStrategy( + asset="ASSET", + grid_spacing=0.01, + num_levels=3, + order_size=50, + recenter_threshold=0.03, # 3% drift triggers recenter + ) + + # Create data with a significant trend to trigger recentering + rng = np.random.default_rng(99) + prices_list = [100.0] + for _ in range(79): + prices_list.append(prices_list[-1] * (1 + rng.normal(0.002, 0.01))) + + base = datetime(2023, 1, 1) + rows = [] + for i in range(80): + p = float(prices_list[i]) + rows.append( + { + "timestamp": base + timedelta(days=i), + "asset": "ASSET", + "open": p * 0.999, + "high": p * 1.008, + "low": p * 0.992, + "close": p, + "volume": 1_000_000, + } + ) + + prices = pl.DataFrame(rows) + feed = DataFeed(prices_df=prices) + config = _fast_config() + + engine = Engine.from_config(feed, strategy, config) + engine.run() + + # If price drifted >3% from initial reference, grid should have recentered + # meaning reference_price should have updated from its initial value + initial_ref = float(prices_list[0]) + final_price = float(prices_list[-1]) + if abs(final_price - initial_ref) / initial_ref > 0.03: + assert abs(strategy.reference_price - initial_ref) > 0.01, ( + "Grid should have recentered but reference_price unchanged" + ) diff --git a/src/ml4t/backtest/__init__.py b/src/ml4t/backtest/__init__.py index 0ea6f29f..8a1f957d 100644 --- a/src/ml4t/backtest/__init__.py +++ b/src/ml4t/backtest/__init__.py @@ -14,9 +14,12 @@ __version__ = "0.0.0.dev0" from .broker import Broker -from .config import BacktestConfig +from .config import BacktestConfig, CommissionType from .datafeed import DataFeed from .engine import Engine, run_backtest + +# Execution: rebalancing +from .execution.rebalancer import RebalanceConfig, TargetWeightExecutor from .result import BacktestResult # Risk management rules (position-level) @@ -25,6 +28,8 @@ from .risk.position.static import StopLoss, TakeProfit from .strategy import Strategy from .types import ( + AssetClass, + ContractSpec, ExecutionMode, ExitReason, Fill, @@ -47,6 +52,7 @@ "run_backtest", "BacktestConfig", "BacktestResult", + "CommissionType", # Canonical domain types "OrderType", "OrderSide", @@ -59,6 +65,12 @@ "Position", "Fill", "Trade", + # Asset specifications + "AssetClass", + "ContractSpec", + # Execution: rebalancing + "RebalanceConfig", + "TargetWeightExecutor", # Risk rules "StopLoss", "TakeProfit", diff --git a/src/ml4t/backtest/broker.py b/src/ml4t/backtest/broker.py index 1e71bb90..bed1a297 100644 --- a/src/ml4t/backtest/broker.py +++ b/src/ml4t/backtest/broker.py @@ -135,6 +135,16 @@ def __init__( self.settlement_reduces_buying_power = settlement_reduces_buying_power self._bar_index: int = 0 + # Auto-populate fixed_margin_schedule from ContractSpec.margin + # This lets users specify margin once on ContractSpec rather than duplicating + # it in both ContractSpec and BacktestConfig.fixed_margin_schedule. + effective_margin_schedule = dict(fixed_margin_schedule or {}) + if contract_specs: + for symbol, spec in contract_specs.items(): + if spec.margin is not None and symbol not in effective_margin_schedule: + # Use spec.margin as initial margin, 50% as maintenance (industry standard) + effective_margin_schedule[symbol] = (spec.margin, spec.margin * 0.5) + # Create AccountState with UnifiedAccountPolicy policy: AccountPolicy = UnifiedAccountPolicy( allow_short_selling=allow_short_selling, @@ -142,7 +152,7 @@ def __init__( initial_margin=initial_margin, long_maintenance_margin=long_maintenance_margin, short_maintenance_margin=short_maintenance_margin, - fixed_margin_schedule=fixed_margin_schedule, + fixed_margin_schedule=effective_margin_schedule or None, short_cash_policy=short_cash_policy.value, ) @@ -1127,11 +1137,14 @@ def order_target_percent( """Order to achieve target portfolio weight. Calculates the order quantity needed to reach the target percentage - of total portfolio value for this asset. + of total portfolio value for this asset. Weights can exceed 1.0 for + leveraged portfolios (e.g., futures, margin accounts). The gatekeeper + validates whether the account has sufficient buying power. Args: asset: Asset symbol - target_percent: Target weight as decimal (0.10 = 10% of portfolio) + target_percent: Target weight as decimal (0.10 = 10% of portfolio). + Can exceed 1.0 for leveraged positions if allow_leverage=True. order_type: Order type (default MARKET) limit_price: Limit price for LIMIT orders @@ -1144,10 +1157,10 @@ def order_target_percent( # Target 0% (close position) broker.order_target_percent("AAPL", 0.0) + + # Leveraged: target 150% in ES futures (requires allow_leverage=True) + broker.order_target_percent("ES", 1.50) """ - if target_percent < -1.0 or target_percent > 1.0: - # Allow up to 100% long or 100% short - return None portfolio_value = self.get_account_value() if portfolio_value <= 0: diff --git a/src/ml4t/backtest/config.py b/src/ml4t/backtest/config.py index ff81de91..1927e403 100644 --- a/src/ml4t/backtest/config.py +++ b/src/ml4t/backtest/config.py @@ -164,7 +164,8 @@ class CommissionType(str, Enum): NONE = "none" # No commission PERCENTAGE = "percentage" # % of trade value - PER_SHARE = "per_share" # Fixed amount per share + PER_SHARE = "per_share" # Fixed amount per share/contract + PER_CONTRACT = "per_share" # Alias for PER_SHARE (futures convention) PER_TRADE = "per_trade" # Fixed amount per trade TIERED = "tiered" # Volume-based tiers diff --git a/src/ml4t/backtest/engine.py b/src/ml4t/backtest/engine.py index d9f18454..ab36a95f 100644 --- a/src/ml4t/backtest/engine.py +++ b/src/ml4t/backtest/engine.py @@ -66,6 +66,10 @@ def __init__( feed: DataFeed, strategy: Strategy, config: BacktestConfig | None = None, + *, + contract_specs: dict[str, Any] | None = None, + market_impact_model: Any | None = None, + execution_limits: Any | None = None, ): from .config import BacktestConfig as ConfigCls @@ -76,7 +80,12 @@ def __init__( self.strategy = strategy self.config = config self.execution_mode = config.execution_mode - self.broker = Broker.from_config(config) + self.broker = Broker.from_config( + config, + contract_specs=contract_specs, + market_impact_model=market_impact_model, + execution_limits=execution_limits, + ) self.equity_curve: list[tuple[datetime, float]] = [] # Calendar session enforcement (lazy initialized in run()) @@ -297,6 +306,10 @@ def from_config( feed: DataFeed, strategy: Strategy, config: BacktestConfig, + *, + contract_specs: dict[str, Any] | None = None, + market_impact_model: Any | None = None, + execution_limits: Any | None = None, ) -> Engine: """Create an Engine instance from a BacktestConfig. @@ -307,11 +320,21 @@ def from_config( feed: DataFeed with price data strategy: Strategy to execute config: BacktestConfig with all behavioral settings + contract_specs: Per-asset contract specifications (futures multipliers, etc.) + market_impact_model: Market impact model for fill simulation + execution_limits: Execution limits (max order size, etc.) Returns: Configured Engine instance """ - return cls(feed, strategy, config) + return cls( + feed, + strategy, + config, + contract_specs=contract_specs, + market_impact_model=market_impact_model, + execution_limits=execution_limits, + ) # === Convenience Function === @@ -323,6 +346,10 @@ def run_backtest( signals: pl.DataFrame | str | None = None, context: pl.DataFrame | str | None = None, config: BacktestConfig | str | None = None, + *, + contract_specs: dict[str, Any] | None = None, + market_impact_model: Any | None = None, + execution_limits: Any | None = None, ) -> BacktestResult: """Run a backtest with minimal setup. @@ -332,6 +359,9 @@ def run_backtest( signals: Optional signals DataFrame or path context: Optional context DataFrame or path config: BacktestConfig instance, preset name (str), or None for defaults + contract_specs: Per-asset contract specifications (futures multipliers, etc.) + market_impact_model: Market impact model for fill simulation + execution_limits: Execution limits (max order size, etc.) Returns: BacktestResult with metrics, trades, equity curve, and export methods. @@ -345,6 +375,11 @@ def run_backtest( config = BacktestConfig.from_preset("backtrader") config.commission_rate = 0.002 result = run_backtest(prices_df, strategy, config=config) + + # Futures with contract specs + from ml4t.backtest import ContractSpec, AssetClass + specs = {"ES": ContractSpec(symbol="ES", asset_class=AssetClass.FUTURE, multiplier=50.0)} + result = run_backtest(prices_df, strategy, config=config, contract_specs=specs) """ feed = DataFeed( prices_path=prices if isinstance(prices, str) else None, @@ -360,4 +395,11 @@ def run_backtest( config = ConfigCls.from_preset(config) - return Engine(feed, strategy, config).run() + return Engine( + feed, + strategy, + config, + contract_specs=contract_specs, + market_impact_model=market_impact_model, + execution_limits=execution_limits, + ).run() diff --git a/src/ml4t/backtest/execution/rebalancer.py b/src/ml4t/backtest/execution/rebalancer.py index daa13caf..abb3caa7 100644 --- a/src/ml4t/backtest/execution/rebalancer.py +++ b/src/ml4t/backtest/execution/rebalancer.py @@ -48,6 +48,10 @@ class RebalanceConfig: lot_size: Lot size for rounding (only used if round_lots=True). allow_short: Allow short positions via negative weights. max_single_weight: Maximum weight allowed for any single asset. + max_gross_leverage: Maximum gross leverage (sum of abs weights). None means + no cap — the gatekeeper's buying power check is the constraint. For cash + accounts, the gatekeeper naturally prevents over-allocation. For margin + accounts, set this as a risk guardrail (e.g., 5.0 for a CTA portfolio). cancel_before_rebalance: Cancel pending orders before rebalancing (safest). account_for_pending: Consider pending orders when calculating current weights. rebalance_mode: How portfolio value is computed during rebalancing. @@ -68,6 +72,7 @@ class RebalanceConfig: # Position constraints allow_short: bool = False max_single_weight: float = 1.0 + max_gross_leverage: float | None = None # None = no cap, gatekeeper decides # Order handling cancel_before_rebalance: bool = True @@ -148,12 +153,13 @@ def execute( else: current_weights = self._get_current_weights(broker, data) - # 3. Validate total weight <= 1.0 (allow cash targeting) - total_target = sum(target_weights.values()) - if total_target > 1.0 + 1e-6: - # Scale down to prevent over-allocation - scale = 1.0 / total_target - target_weights = {k: v * scale for k, v in target_weights.items()} + # 3. Apply gross leverage cap if configured (safety guardrail) + # Without a cap, the gatekeeper's buying power check is the constraint. + if self.config.max_gross_leverage is not None: + gross_weight = sum(abs(w) for w in target_weights.values()) + if gross_weight > self.config.max_gross_leverage + 1e-6: + 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 for asset, target_wt in target_weights.items(): @@ -227,8 +233,9 @@ def _process_asset( if abs(delta_value) < self.config.min_trade_value: return None - # Compute shares - shares = delta_value / price + # Compute shares (account for contract multiplier for futures) + multiplier = broker.get_multiplier(asset) + shares = delta_value / (price * multiplier) # Apply share rounding # Resolve fractional setting: explicit config > broker.share_type > default @@ -267,7 +274,8 @@ def _get_current_weights(self, broker: "Broker", data: dict[str, dict]) -> dict[ weights = {} for asset, pos in broker.positions.items(): price = data.get(asset, {}).get("close", pos.entry_price) - value = pos.quantity * price + multiplier = broker.get_multiplier(asset) + value = pos.quantity * price * multiplier weights[asset] = value / equity return weights @@ -293,15 +301,17 @@ def _get_effective_weights(self, broker: "Broker", data: dict[str, dict]) -> dic effective_value: dict[str, float] = {} for asset, pos in broker.positions.items(): price = data.get(asset, {}).get("close", pos.entry_price) - effective_value[asset] = pos.quantity * price + multiplier = broker.get_multiplier(asset) + effective_value[asset] = pos.quantity * price * multiplier # 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: + multiplier = broker.get_multiplier(order.asset) # BUY adds value, SELL subtracts sign = 1 if order.side == OrderSide.BUY else -1 - delta = order.quantity * price * sign + delta = order.quantity * price * sign * multiplier effective_value[order.asset] = effective_value.get(order.asset, 0) + delta return {k: v / equity for k, v in effective_value.items()} @@ -342,8 +352,9 @@ def preview( weight_delta = target_wt - current_wt if price > 0: + multiplier = broker.get_multiplier(asset) delta_value = equity * weight_delta - shares = delta_value / price + shares = delta_value / (price * multiplier) # Determine if would be skipped skip_reason = None @@ -372,6 +383,7 @@ def preview( pos = broker.get_position(asset) if pos and pos.quantity != 0: price = data.get(asset, {}).get("close", pos.entry_price) + multiplier = broker.get_multiplier(asset) current_wt = current_weights.get(asset, 0.0) previews.append( { @@ -380,7 +392,7 @@ def preview( "target_weight": 0.0, "weight_delta": -current_wt, "shares": -pos.quantity, - "value": -pos.quantity * price, + "value": -pos.quantity * price * multiplier, "skip_reason": None, "action": "close_position", } diff --git a/src/ml4t/backtest/risk/position/dynamic.py b/src/ml4t/backtest/risk/position/dynamic.py index 1cb4d22f..a12ef817 100644 --- a/src/ml4t/backtest/risk/position/dynamic.py +++ b/src/ml4t/backtest/risk/position/dynamic.py @@ -12,13 +12,6 @@ def _get_stop_fill_mode_for_trail(context: dict): return context.get("stop_fill_mode", StopFillMode.STOP_PRICE) -def _get_trail_hwm_source(context: dict): - """Get WaterMarkSource from context, defaulting to CLOSE.""" - from ml4t.backtest.config import WaterMarkSource - - return context.get("trail_hwm_source", WaterMarkSource.CLOSE) - - def _get_trail_stop_timing(context: dict): """Get TrailStopTiming from context, defaulting to LAGGED.""" from ml4t.backtest.config import TrailStopTiming @@ -84,12 +77,19 @@ def _evaluate_long(self, state: PositionState, fill_mode, trail_timing) -> Posit bar_open = state.bar_open if state.bar_open is not None else state.current_price bar_close = state.current_price # current_price is the close + from ml4t.backtest.types import StopFillMode + if trail_timing == TrailStopTiming.VBT_PRO: # VBT_PRO mode: Two-pass algorithm matching VectorBT Pro exactly # # Pass 1: Check with LAGGED water mark against LOW lagged_stop = state.high_water_mark * (1 - self.pct) if bar_low <= lagged_stop or bar_open < lagged_stop: + if fill_mode == StopFillMode.NEXT_BAR_OPEN: + return PositionAction.exit_full( + f"trailing_stop_{self.pct:.1%}", + defer_fill=True, + ) fill_price = self._get_fill_price_long( lagged_stop, bar_close, bar_low, bar_open, fill_mode ) @@ -103,6 +103,11 @@ def _evaluate_long(self, state: PositionState, fill_mode, trail_timing) -> Posit live_stop = live_hwm * (1 - self.pct) # VBT Pro's second pass can only use CLOSE (can_use_ohlc=False) if bar_close <= live_stop: + if fill_mode == StopFillMode.NEXT_BAR_OPEN: + return PositionAction.exit_full( + f"trailing_stop_{self.pct:.1%}", + defer_fill=True, + ) # Fill at close price since that's what triggered it return PositionAction.exit_full( f"trailing_stop_{self.pct:.1%}", @@ -120,9 +125,18 @@ def _evaluate_long(self, state: PositionState, fill_mode, trail_timing) -> Posit # LAGGED mode: use previous bar's HWM stop_price = state.high_water_mark * (1 - self.pct) - # Check both low touch AND gap-through (open below trail) - if bar_low <= stop_price or bar_open < stop_price: - # Use StopFillMode for fill price calculation (both LAGGED and INTRABAR) + # Trigger detection: always use bar_low for long positions. + # If bar_low touches the stop level at any point, the stop fires. + # The HWM source (CLOSE vs BAR_EXTREME) only affects how the trailing + # level tracks — not how the trigger is detected. + triggered = bar_low <= stop_price or bar_open < stop_price + + if triggered: + if fill_mode == StopFillMode.NEXT_BAR_OPEN: + return PositionAction.exit_full( + f"trailing_stop_{self.pct:.1%}", + defer_fill=True, + ) fill_price = self._get_fill_price_long( stop_price, bar_close, bar_low, bar_open, fill_mode ) @@ -136,6 +150,7 @@ def _evaluate_long(self, state: PositionState, fill_mode, trail_timing) -> Posit def _evaluate_short(self, state: PositionState, fill_mode, trail_timing) -> PositionAction: """Evaluate trailing stop for SHORT position.""" from ml4t.backtest.config import TrailStopTiming + from ml4t.backtest.types import StopFillMode bar_low = state.bar_low if state.bar_low is not None else state.current_price bar_high = state.bar_high if state.bar_high is not None else state.current_price @@ -145,15 +160,17 @@ def _evaluate_short(self, state: PositionState, fill_mode, trail_timing) -> Posi if trail_timing == TrailStopTiming.VBT_PRO: # VBT_PRO mode: Two-pass algorithm matching VectorBT Pro exactly # - # VBT Pro uses LAGGED water mark for the initial check: - # Pass 1: Check with LAGGED LWM (previous bar's LWM) against HIGH + # Pass 1: Check with LAGGED LWM against HIGH # Pass 2: Update LWM, check against CLOSE only - # - # This matches the LONG implementation pattern. # Pass 1: Check with LAGGED water mark against HIGH lagged_stop = state.low_water_mark * (1 + self.pct) if bar_high >= lagged_stop or bar_open > lagged_stop: + if fill_mode == StopFillMode.NEXT_BAR_OPEN: + return PositionAction.exit_full( + f"trailing_stop_{self.pct:.1%}", + defer_fill=True, + ) fill_price = self._get_fill_price_short( lagged_stop, bar_close, bar_high, bar_open, fill_mode ) @@ -167,6 +184,11 @@ def _evaluate_short(self, state: PositionState, fill_mode, trail_timing) -> Posi live_stop = live_lwm * (1 + self.pct) # VBT Pro's second pass can only use CLOSE (can_use_ohlc=False) if bar_close >= live_stop: + if fill_mode == StopFillMode.NEXT_BAR_OPEN: + return PositionAction.exit_full( + f"trailing_stop_{self.pct:.1%}", + defer_fill=True, + ) # Fill at close price since that's what triggered it return PositionAction.exit_full( f"trailing_stop_{self.pct:.1%}", @@ -184,9 +206,18 @@ def _evaluate_short(self, state: PositionState, fill_mode, trail_timing) -> Posi # LAGGED mode: use previous bar's LWM stop_price = state.low_water_mark * (1 + self.pct) - # Check both high touch AND gap-through (open above trail) - if bar_high >= stop_price or bar_open > stop_price: - # Use StopFillMode for fill price calculation (both LAGGED and INTRABAR) + # Trigger detection: always use bar_high for short positions. + # If bar_high touches the stop level at any point, the stop fires. + # The HWM source (CLOSE vs BAR_EXTREME) only affects how the trailing + # level tracks — not how the trigger is detected. + triggered = bar_high >= stop_price or bar_open > stop_price + + if triggered: + if fill_mode == StopFillMode.NEXT_BAR_OPEN: + return PositionAction.exit_full( + f"trailing_stop_{self.pct:.1%}", + defer_fill=True, + ) fill_price = self._get_fill_price_short( stop_price, bar_close, bar_high, bar_open, fill_mode ) diff --git a/tests/contracts/test_cross_engine_contracts.py b/tests/contracts/test_cross_engine_contracts.py index 6951e6bd..a88c05c4 100644 --- a/tests/contracts/test_cross_engine_contracts.py +++ b/tests/contracts/test_cross_engine_contracts.py @@ -1,27 +1,30 @@ +"""Cross-engine validation contracts. + +Runs validation scenarios against external backtesting frameworks to verify +trade-level parity. Uses the consolidated validation runner in-process. + +Requires the 'comparison' optional dependency group: + uv sync --dev --extra comparison + +Set ML4T_COMPARISON_INPROC=1 to enable these tests in CI. +""" + from __future__ import annotations import importlib.util import os -import subprocess import sys from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parents[2] -RUNNER = PROJECT_ROOT / "validation" / "run_all_correctness.py" +VALIDATION_DIR = PROJECT_ROOT / "validation" -FRAMEWORK_VENVS = { - "vectorbt_oss": ".venv", - "backtrader": ".venv-backtrader", - "zipline": ".venv-zipline", -} +# Add validation directory to path for imports +sys.path.insert(0, str(VALIDATION_DIR)) +sys.path.insert(0, str(PROJECT_ROOT / "src")) -FRAMEWORK_PROFILES = { - "vectorbt_oss": "vectorbt", - "backtrader": "backtrader", - "zipline": "zipline", -} FRAMEWORK_IMPORTS = { "vectorbt_oss": "vectorbt", @@ -29,53 +32,83 @@ "zipline": "zipline", } +# Core scenarios to test in CI (01=long only, 05=commission, 09=trailing stop) +CI_SCENARIOS = ["01", "05", "09"] + def _framework_available(framework: str) -> bool: module_name = FRAMEWORK_IMPORTS[framework] return importlib.util.find_spec(module_name) is not None +def _run_scenario_inproc(scenario_id: str, framework: str) -> bool: + """Run a single scenario/framework combination in-process. + + Returns True if validation passed. + """ + from common import data_generators + from common.comparator import compare_results + from common.ml4t_runner import run_ml4t + from scenarios.definitions import SCENARIOS + + scenario = SCENARIOS[scenario_id] + + if framework not in scenario.supported_frameworks: + pytest.skip(f"Scenario {scenario_id} does not support {framework}") + + # Generate data + gen_func = getattr(data_generators, scenario.data_generator) + data_result = gen_func(**scenario.data_kwargs) + + if len(data_result) == 3: + prices_df, entries, exits = data_result + else: + prices_df, entries = data_result + exits = None + + # Align to NYSE calendar for Zipline (which only operates on NYSE sessions) + if framework == "zipline": + import exchange_calendars as xcals + + nyse = xcals.get_calendar("XNYS") + start_ts = prices_df.index[0] + end_ts = prices_df.index[-1] + if start_ts.tz is not None: + start_ts = start_ts.tz_convert(None) + end_ts = end_ts.tz_convert(None) + sessions = nyse.sessions_in_range(start_ts, end_ts) + naive_idx = prices_df.index.tz_localize(None) if prices_df.index.tz else prices_df.index + valid_mask = naive_idx.isin(sessions) + prices_df = prices_df[valid_mask].copy() + entries = entries[valid_mask] + if exits is not None: + exits = exits[valid_mask] + + # Run external framework + fw_module_name = f"frameworks.{framework}" + fw_module = importlib.import_module(fw_module_name) + fw_result = fw_module.run(scenario, prices_df, entries, exits) + + # Run ml4t + ml4t_result = run_ml4t(scenario, prices_df, entries, exits, framework=framework) + + # Compare + result = compare_results(scenario, fw_result, ml4t_result) + return result.passed + + @pytest.mark.requires_comparison @pytest.mark.integration @pytest.mark.slow @pytest.mark.parametrize("framework", ["vectorbt_oss", "backtrader", "zipline"]) -def test_cross_engine_scenario_01_contract(framework: str, tmp_path: Path) -> None: +@pytest.mark.parametrize("scenario_id", CI_SCENARIOS) +def test_cross_engine_contract(framework: str, scenario_id: str) -> None: + """Validate ml4t matches external framework for core scenarios.""" if not _framework_available(framework): pytest.skip(f"{framework} dependencies not installed in this environment") - venv_dir = PROJECT_ROOT / FRAMEWORK_VENVS[framework] - inproc = os.getenv("ML4T_COMPARISON_INPROC") == "1" - - if (venv_dir / "bin" / "python").exists() and not inproc: - output_file = tmp_path / f"correctness_{framework}.md" - cmd = [ - sys.executable, - str(RUNNER), - "--framework", - framework, - "--scenarios", - "01", - "--output", - str(output_file), - ] - result = subprocess.run(cmd, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) - combined = f"{result.stdout}\n{result.stderr}" - else: - scenario_script = next( - (PROJECT_ROOT / "validation" / framework).glob("scenario_01_*.py"), None - ) - if scenario_script is None: - pytest.skip(f"scenario_01 script missing for framework={framework}") - env = {**os.environ, "ML4T_PROFILE": FRAMEWORK_PROFILES[framework]} - result = subprocess.run( - [sys.executable, str(scenario_script)], - cwd=PROJECT_ROOT, - env=env, - capture_output=True, - text=True, - check=False, - ) - combined = f"{result.stdout}\n{result.stderr}" - - assert result.returncode == 0, combined - assert "PASS" in combined and "FAIL" not in combined, combined + if os.getenv("ML4T_COMPARISON_INPROC") != "1": + pytest.skip("Set ML4T_COMPARISON_INPROC=1 to run cross-engine contracts") + + passed = _run_scenario_inproc(scenario_id, framework) + assert passed, f"Scenario {scenario_id} failed against {framework}" diff --git a/tests/contracts/test_public_api_surface.py b/tests/contracts/test_public_api_surface.py index 4a577bba..ec6745fb 100644 --- a/tests/contracts/test_public_api_surface.py +++ b/tests/contracts/test_public_api_surface.py @@ -12,6 +12,7 @@ def test_root_api_contains_only_intended_core_surface() -> None: "run_backtest", "BacktestConfig", "BacktestResult", + "CommissionType", "OrderType", "OrderSide", "OrderStatus", @@ -23,6 +24,10 @@ def test_root_api_contains_only_intended_core_surface() -> None: "Position", "Fill", "Trade", + "AssetClass", + "ContractSpec", + "RebalanceConfig", + "TargetWeightExecutor", "StopLoss", "TakeProfit", "TrailingStop", @@ -36,8 +41,6 @@ def test_root_api_contains_only_intended_core_surface() -> None: "PercentageCommission", "PercentageSlippage", "PerShareCommission", - "RebalanceConfig", - "TargetWeightExecutor", "LinearImpact", "VolumeParticipationLimit", "WaterMarkSource", diff --git a/tests/execution/test_rebalancer.py b/tests/execution/test_rebalancer.py index af7eeb07..3ed65ee3 100644 --- a/tests/execution/test_rebalancer.py +++ b/tests/execution/test_rebalancer.py @@ -362,19 +362,34 @@ def test_implicit_cash_holding(self, broker, sample_data): total_value = sum(o.quantity * sample_data[o.asset]["close"] for o in orders) assert 89000 < total_value < 91000 # ~$90k (90% of $100k) - def test_scale_down_over_100_percent(self, broker, sample_data): - """Test that weights > 100% are scaled down.""" - executor = TargetWeightExecutor() + def test_max_gross_leverage_scales_down(self, broker, sample_data): + """Test that weights > max_gross_leverage are scaled down.""" + executor = TargetWeightExecutor(config=RebalanceConfig(max_gross_leverage=1.0)) - # Target 120% invested (impossible without leverage) + # Target 120% invested — exceeds max_gross_leverage=1.0 target_weights = {"AAPL": 0.7, "GOOG": 0.5} # = 120% orders = executor.execute(target_weights, sample_data, broker) - # Should scale to 100% max + # Should scale to 100% max: AAPL=58.3%, GOOG=41.7% total_value = sum(o.quantity * sample_data[o.asset]["close"] for o in orders) - # After scaling: AAPL=58.3%, GOOG=41.7% assert total_value < 101000 # Should not exceed equity + def test_no_cap_allows_over_100_percent(self, broker, sample_data): + """Without max_gross_leverage, weights > 1.0 pass through to gatekeeper.""" + executor = TargetWeightExecutor(config=RebalanceConfig(allow_fractional=True)) + + # Target 120% — no cap, gatekeeper will constrain based on buying power + target_weights = {"AAPL": 0.7, "GOOG": 0.5} # = 120% + orders = executor.execute(target_weights, sample_data, broker) + + # Orders should be submitted for the full requested amounts + # (gatekeeper may reject some, but executor doesn't scale) + assert len(orders) >= 1 # At least some orders submitted + order_map = {o.asset: o for o in orders} + if "AAPL" in order_map: + # AAPL: 0.7 * $100k / $150 = 466.67 shares (not scaled to 58.3%) + assert order_map["AAPL"].quantity > 400 + class TestTargetWeightExecutorPreview: """Test preview functionality.""" diff --git a/tests/execution/test_rebalancer_futures.py b/tests/execution/test_rebalancer_futures.py new file mode 100644 index 00000000..f092e2a3 --- /dev/null +++ b/tests/execution/test_rebalancer_futures.py @@ -0,0 +1,517 @@ +"""Tests for TargetWeightExecutor with futures contract specs (multiplier awareness). + +Verifies that weight calculation and share sizing correctly account for +contract multipliers. Without this fix, a 30% target weight in ES futures +(multiplier=50) would compute 50x too many contracts. +""" + +from datetime import datetime + +from ml4t.backtest import Broker, OrderSide +from ml4t.backtest.execution.rebalancer import RebalanceConfig, TargetWeightExecutor +from ml4t.backtest.models import NoCommission, NoSlippage +from ml4t.backtest.types import AssetClass, ContractSpec + +# --- Fixtures --- + +ES_SPEC = ContractSpec(symbol="ES", asset_class=AssetClass.FUTURE, multiplier=50.0) +CL_SPEC = ContractSpec(symbol="CL", asset_class=AssetClass.FUTURE, multiplier=1000.0) +GC_SPEC = ContractSpec(symbol="GC", asset_class=AssetClass.FUTURE, multiplier=100.0) + +DEMO_SPECS = {"ES": ES_SPEC, "CL": CL_SPEC, "GC": GC_SPEC} + + +def _make_broker(initial_cash: float = 1_000_000, specs: dict | None = None) -> Broker: + return Broker( + initial_cash=initial_cash, + commission_model=NoCommission(), + slippage_model=NoSlippage(), + contract_specs=specs or DEMO_SPECS, + allow_short_selling=True, + allow_leverage=True, + ) + + +def _init_prices(broker: Broker, prices: dict[str, float]) -> None: + """Set up broker with current prices.""" + broker._update_time( + datetime(2024, 1, 2, 9, 30), + prices, # close + prices, # open + prices, # high + prices, # low + dict.fromkeys(prices, 100000), # volume + {}, + ) + + +class TestFuturesWeightCalculation: + """Verify weight computation includes multiplier.""" + + def test_current_weight_includes_multiplier(self): + """Position weight should reflect notional = qty * price * multiplier.""" + broker = _make_broker(initial_cash=1_000_000) + data = {"ES": {"close": 5000.0}} + _init_prices(broker, {"ES": 5000.0}) + + # Buy 2 ES contracts: notional = 2 * 5000 * 50 = $500,000 + broker.submit_order("ES", 2, OrderSide.BUY) + broker._process_orders() + + executor = TargetWeightExecutor() + weights = executor._get_current_weights(broker, data) + + # Equity = cash + positions = $500,000 + $500,000 = $1,000,000 + # Weight should be $500,000 / $1,000,000 = 0.50 + assert "ES" in weights + assert abs(weights["ES"] - 0.50) < 0.02 + + def test_current_weight_without_multiplier_would_be_wrong(self): + """Without multiplier, 2 ES at $5000 would show as 1% weight instead of 50%.""" + broker = _make_broker(initial_cash=1_000_000) + data = {"ES": {"close": 5000.0}} + _init_prices(broker, {"ES": 5000.0}) + + broker.submit_order("ES", 2, OrderSide.BUY) + broker._process_orders() + + executor = TargetWeightExecutor() + weights = executor._get_current_weights(broker, data) + + # The weight should NOT be 2 * 5000 / 1_000_000 = 0.01 + assert weights["ES"] > 0.4 # Must be much larger than 1% + + +class TestFuturesShareSizing: + """Verify share sizing accounts for multiplier.""" + + def test_target_weight_produces_correct_contracts(self): + """30% of $1M in ES (mult=50, price=$5000) = $300K / $250K per contract ≈ 1.2.""" + broker = _make_broker(initial_cash=1_000_000) + data = {"ES": {"close": 5000.0}} + _init_prices(broker, {"ES": 5000.0}) + + executor = TargetWeightExecutor( + config=RebalanceConfig(allow_fractional=True, min_weight_change=0.001) + ) + orders = executor.execute({"ES": 0.30}, data, broker) + + assert len(orders) == 1 + assert orders[0].asset == "ES" + assert orders[0].side == OrderSide.BUY + + # target_value = 0.30 * $1M = $300,000 + # notional_per_contract = 5000 * 50 = $250,000 + # qty = $300,000 / $250,000 = 1.2 contracts + assert abs(orders[0].quantity - 1.2) < 0.01 + + def test_without_multiplier_would_buy_50x_too_many(self): + """Sanity check: qty = $300K / $5000 = 60 contracts (wrong, should be ~1.2).""" + broker = _make_broker(initial_cash=1_000_000) + data = {"ES": {"close": 5000.0}} + _init_prices(broker, {"ES": 5000.0}) + + executor = TargetWeightExecutor( + config=RebalanceConfig(allow_fractional=True, min_weight_change=0.001) + ) + orders = executor.execute({"ES": 0.30}, data, broker) + + # Should NOT be 60 contracts + assert orders[0].quantity < 5 # Must be in the 1-2 range, not 60 + + def test_high_multiplier_product(self): + """CL (mult=1000): 10% of $1M at $70 = $100K / $70K per contract ≈ 1.43.""" + broker = _make_broker(initial_cash=1_000_000) + data = {"CL": {"close": 70.0}} + _init_prices(broker, {"CL": 70.0}) + + executor = TargetWeightExecutor( + config=RebalanceConfig(allow_fractional=True, min_weight_change=0.001) + ) + orders = executor.execute({"CL": 0.10}, data, broker) + + assert len(orders) == 1 + # target = 0.10 * $1M = $100,000 + # per_contract = 70 * 1000 = $70,000 + # qty = $100,000 / $70,000 ≈ 1.43 + assert abs(orders[0].quantity - 100_000 / 70_000) < 0.01 + + def test_whole_contract_rounding(self): + """With allow_fractional=False, contracts round to integers.""" + broker = _make_broker(initial_cash=1_000_000) + data = {"ES": {"close": 5000.0}} + _init_prices(broker, {"ES": 5000.0}) + + executor = TargetWeightExecutor( + config=RebalanceConfig(allow_fractional=False, min_weight_change=0.001) + ) + orders = executor.execute({"ES": 0.30}, data, broker) + + assert len(orders) == 1 + # 1.2 rounds to 1 with int() + assert orders[0].quantity == 1 + assert isinstance(orders[0].quantity, int) + + +class TestFuturesMultiAssetRebalance: + """Test rebalancing across multiple futures products.""" + + def test_equal_weight_three_futures(self): + """Equal-weight across ES, CL, GC should produce correct contract counts.""" + broker = _make_broker(initial_cash=1_000_000) + prices = {"ES": 5000.0, "CL": 70.0, "GC": 2000.0} + data = {a: {"close": p} for a, p in prices.items()} + _init_prices(broker, prices) + + executor = TargetWeightExecutor( + config=RebalanceConfig(allow_fractional=True, min_weight_change=0.001) + ) + orders = executor.execute({"ES": 0.33, "CL": 0.33, "GC": 0.33}, data, broker) + + assert len(orders) == 3 + order_map = {o.asset: o for o in orders} + + # ES: $330K / (5000 * 50) = 1.32 contracts + assert abs(order_map["ES"].quantity - 330_000 / 250_000) < 0.01 + # CL: $330K / (70 * 1000) = 4.71 contracts + assert abs(order_map["CL"].quantity - 330_000 / 70_000) < 0.01 + # GC: $330K / (2000 * 100) = 1.65 contracts + assert abs(order_map["GC"].quantity - 330_000 / 200_000) < 0.01 + + def test_rebalance_from_existing_position(self): + """Rebalancing should compute delta using multiplier-correct weights.""" + broker = _make_broker(initial_cash=1_000_000) + prices = {"ES": 5000.0, "GC": 2000.0} + data = {a: {"close": p} for a, p in prices.items()} + _init_prices(broker, prices) + + executor = TargetWeightExecutor( + config=RebalanceConfig(allow_fractional=True, min_weight_change=0.001) + ) + + # First: buy 2 ES contracts (notional = $500K = 50% of $1M) + broker.submit_order("ES", 2, OrderSide.BUY) + broker._process_orders() + + # Now rebalance to 25% ES, 25% GC + orders = executor.execute({"ES": 0.25, "GC": 0.25}, data, broker) + + order_map = {o.asset: o for o in orders} + + # ES: currently 50%, target 25% → sell ~1 contract + assert "ES" in order_map + assert order_map["ES"].side == OrderSide.SELL + # delta_value = (0.25 - 0.50) * $1M = -$250K + # delta_contracts = -$250K / (5000 * 50) = -1.0 + assert abs(order_map["ES"].quantity - 1.0) < 0.05 + + # GC: currently 0%, target 25% → buy + assert "GC" in order_map + assert order_map["GC"].side == OrderSide.BUY + + def test_close_position_not_in_target(self): + """Closing a futures position should work correctly.""" + broker = _make_broker(initial_cash=1_000_000) + prices = {"ES": 5000.0, "GC": 2000.0} + data = {a: {"close": p} for a, p in prices.items()} + _init_prices(broker, prices) + + # Buy ES + broker.submit_order("ES", 2, OrderSide.BUY) + broker._process_orders() + + executor = TargetWeightExecutor( + config=RebalanceConfig(allow_fractional=True, min_weight_change=0.001) + ) + + # Target only GC — ES should be closed + orders = executor.execute({"GC": 0.25}, data, broker) + + order_map = {o.asset: o for o in orders} + assert "ES" in order_map + assert order_map["ES"].side == OrderSide.SELL + assert order_map["ES"].quantity == 2 # Close all 2 contracts + + +class TestFuturesShortPositions: + """Test short positions with futures multipliers.""" + + def test_negative_weight_creates_short(self): + """Negative target weight should create short position.""" + broker = _make_broker(initial_cash=1_000_000) + data = {"ES": {"close": 5000.0}} + _init_prices(broker, {"ES": 5000.0}) + + executor = TargetWeightExecutor( + config=RebalanceConfig(allow_fractional=True, allow_short=True, min_weight_change=0.001) + ) + orders = executor.execute({"ES": -0.25}, data, broker) + + assert len(orders) == 1 + assert orders[0].side == OrderSide.SELL + # -$250K / (5000 * 50) = -1.0 → sell 1 contract + assert abs(orders[0].quantity - 1.0) < 0.01 + + +class TestFuturesPreview: + """Test preview with futures multipliers.""" + + def test_preview_shows_correct_shares_with_multiplier(self): + """Preview should compute shares using multiplier.""" + broker = _make_broker(initial_cash=1_000_000) + data = {"ES": {"close": 5000.0}} + _init_prices(broker, {"ES": 5000.0}) + + executor = TargetWeightExecutor( + config=RebalanceConfig(allow_fractional=True, min_weight_change=0.001) + ) + previews = executor.preview({"ES": 0.30}, data, broker) + + assert len(previews) == 1 + p = previews[0] + assert p["asset"] == "ES" + assert abs(p["target_weight"] - 0.30) < 0.001 + # shares = $300K / (5000 * 50) = 1.2 + assert abs(p["shares"] - 1.2) < 0.01 + + def test_preview_close_shows_multiplied_value(self): + """Preview close position should show notional value with multiplier.""" + broker = _make_broker(initial_cash=1_000_000) + prices = {"ES": 5000.0, "GC": 2000.0} + data = {a: {"close": p} for a, p in prices.items()} + _init_prices(broker, prices) + + # Buy 2 ES + broker.submit_order("ES", 2, OrderSide.BUY) + broker._process_orders() + + executor = TargetWeightExecutor( + config=RebalanceConfig(allow_fractional=True, min_weight_change=0.001) + ) + # Target only GC — ES will be shown as close + previews = executor.preview({"GC": 0.25}, data, broker) + + es_preview = next(p for p in previews if p["asset"] == "ES") + assert es_preview["action"] == "close_position" + # value = -2 * 5000 * 50 = -$500,000 + assert abs(es_preview["value"] - (-500_000)) < 100 + + +class TestFuturesEffectiveWeights: + """Test effective weights (pending orders) with multipliers.""" + + def test_effective_weight_includes_multiplier(self): + """Pending orders should be valued with multiplier.""" + broker = _make_broker(initial_cash=1_000_000) + data = {"ES": {"close": 5000.0}} + _init_prices(broker, {"ES": 5000.0}) + + # Submit but don't process — order stays pending + broker.submit_order("ES", 2, OrderSide.BUY) + + executor = TargetWeightExecutor( + config=RebalanceConfig(cancel_before_rebalance=False, account_for_pending=True) + ) + weights = executor._get_effective_weights(broker, data) + + # Pending 2 ES: 2 * 5000 * 50 = $500K → ~50% of $1M + assert "ES" in weights + assert weights["ES"] > 0.4 # Must reflect multiplied value + + +class TestEquityBackwardCompatibility: + """Verify equities (multiplier=1) still work identically.""" + + def test_equity_weight_unchanged(self): + """Without contract specs, weight = qty * price / equity (multiplier=1).""" + broker = Broker( + initial_cash=100_000, + commission_model=NoCommission(), + slippage_model=NoSlippage(), + ) + data = {"AAPL": {"close": 150.0}} + _init_prices(broker, {"AAPL": 150.0}) + + broker.submit_order("AAPL", 200, OrderSide.BUY) + broker._process_orders() + + executor = TargetWeightExecutor() + weights = executor._get_current_weights(broker, data) + + # 200 * 150 = $30,000 out of $100,000 = 30% + assert abs(weights["AAPL"] - 0.30) < 0.01 + + def test_equity_share_sizing_unchanged(self): + """Without contract specs, shares = delta_value / price (multiplier=1).""" + broker = Broker( + initial_cash=100_000, + commission_model=NoCommission(), + slippage_model=NoSlippage(), + ) + data = {"AAPL": {"close": 150.0}} + _init_prices(broker, {"AAPL": 150.0}) + + executor = TargetWeightExecutor( + config=RebalanceConfig(allow_fractional=True, min_weight_change=0.001) + ) + orders = executor.execute({"AAPL": 0.30}, data, broker) + + assert len(orders) == 1 + # $30,000 / $150 = 200 shares + assert abs(orders[0].quantity - 200.0) < 0.1 + + +class TestContractSpecMarginWiring: + """Verify ContractSpec.margin auto-populates fixed_margin_schedule.""" + + def test_margin_from_contract_spec(self): + """ContractSpec.margin should be wired into the broker's margin schedule.""" + es_spec = ContractSpec( + symbol="ES", asset_class=AssetClass.FUTURE, multiplier=50.0, margin=15_000.0 + ) + broker = Broker( + initial_cash=100_000, + commission_model=NoCommission(), + slippage_model=NoSlippage(), + contract_specs={"ES": es_spec}, + allow_leverage=True, + ) + + # The margin schedule should have been auto-populated from ContractSpec.margin + policy = broker.account.policy + assert policy.fixed_margin_schedule is not None + assert "ES" in policy.fixed_margin_schedule + # Initial margin = spec.margin, maintenance = 50% of initial + im, mm = policy.fixed_margin_schedule["ES"] + assert im == 15_000.0 + assert mm == 7_500.0 + + def test_explicit_schedule_takes_precedence(self): + """Explicit fixed_margin_schedule should override ContractSpec.margin.""" + es_spec = ContractSpec( + symbol="ES", asset_class=AssetClass.FUTURE, multiplier=50.0, margin=15_000.0 + ) + broker = Broker( + initial_cash=100_000, + commission_model=NoCommission(), + slippage_model=NoSlippage(), + contract_specs={"ES": es_spec}, + fixed_margin_schedule={"ES": (20_000.0, 10_000.0)}, + allow_leverage=True, + ) + + policy = broker.account.policy + im, mm = policy.fixed_margin_schedule["ES"] + # Explicit value should win over ContractSpec.margin + assert im == 20_000.0 + assert mm == 10_000.0 + + def test_margin_enables_leveraged_futures(self): + """With margin set, futures portfolio can exceed 1.0 gross weight.""" + es_spec = ContractSpec( + symbol="ES", asset_class=AssetClass.FUTURE, multiplier=50.0, margin=15_000.0 + ) + broker = Broker( + initial_cash=100_000, + commission_model=NoCommission(), + slippage_model=NoSlippage(), + contract_specs={"ES": es_spec}, + allow_leverage=True, + ) + data = {"ES": {"close": 5000.0}} + _init_prices(broker, {"ES": 5000.0}) + + executor = TargetWeightExecutor( + config=RebalanceConfig( + allow_fractional=True, + min_weight_change=0.001, + max_single_weight=10.0, # Allow leveraged weights + ) + ) + + # Target 200% weight (2x leverage) — 1 ES contract = $250K notional + # With $100K cash and $15K margin per contract, we can afford ~6 contracts + # 200% of $100K = $200K notional = 0.8 contracts + orders = executor.execute({"ES": 2.0}, data, broker) + assert len(orders) == 1 + assert orders[0].side == OrderSide.BUY + # 2.0 * $100K / ($5000 * 50) = 0.8 contracts + assert abs(orders[0].quantity - 0.8) < 0.01 + + +class TestMaxGrossLeverage: + """Test the max_gross_leverage safety guardrail.""" + + def test_cap_scales_weights(self): + """max_gross_leverage should scale down weights proportionally.""" + broker = _make_broker(initial_cash=1_000_000) + data = {"ES": {"close": 5000.0}, "CL": {"close": 70.0}} + _init_prices(broker, {"ES": 5000.0, "CL": 70.0}) + + executor = TargetWeightExecutor( + config=RebalanceConfig( + allow_fractional=True, + min_weight_change=0.001, + max_single_weight=10.0, # Allow leveraged weights + max_gross_leverage=3.0, + ) + ) + + # Target 4.0 gross weight → should scale to 3.0 + orders = executor.execute({"ES": 2.0, "CL": 2.0}, data, broker) + assert len(orders) == 2 + + # After scaling: each should be 1.5 (3.0/4.0 * 2.0) + order_map = {o.asset: o for o in orders} + # ES: 1.5 * $1M / (5000 * 50) = 6.0 contracts + assert abs(order_map["ES"].quantity - 6.0) < 0.1 + # CL: 1.5 * $1M / (70 * 1000) = 21.43 contracts + assert abs(order_map["CL"].quantity - 1_500_000 / 70_000) < 0.1 + + def test_no_cap_passes_through(self): + """Without max_gross_leverage, all weights pass through.""" + broker = _make_broker(initial_cash=1_000_000) + data = {"ES": {"close": 5000.0}} + _init_prices(broker, {"ES": 5000.0}) + + executor = TargetWeightExecutor( + config=RebalanceConfig( + allow_fractional=True, + min_weight_change=0.001, + max_single_weight=10.0, # Allow leveraged weights + # max_gross_leverage=None (default) + ) + ) + + # Target 300% — no cap + orders = executor.execute({"ES": 3.0}, data, broker) + assert len(orders) == 1 + # 3.0 * $1M / (5000 * 50) = 12.0 contracts + assert abs(orders[0].quantity - 12.0) < 0.1 + + def test_cap_handles_long_short(self): + """max_gross_leverage should use absolute weights for long-short portfolios.""" + broker = _make_broker(initial_cash=1_000_000) + data = {"ES": {"close": 5000.0}, "CL": {"close": 70.0}} + _init_prices(broker, {"ES": 5000.0, "CL": 70.0}) + + executor = TargetWeightExecutor( + config=RebalanceConfig( + allow_fractional=True, + allow_short=True, + min_weight_change=0.001, + max_single_weight=10.0, # Allow leveraged weights + max_gross_leverage=2.0, + ) + ) + + # Gross = |1.5| + |-1.5| = 3.0, exceeds cap of 2.0 + # Scale: 2.0/3.0 = 0.667 → ES=1.0, CL=-1.0 + orders = executor.execute({"ES": 1.5, "CL": -1.5}, data, broker) + assert len(orders) == 2 + + order_map = {o.asset: o for o in orders} + assert order_map["ES"].side == OrderSide.BUY + assert order_map["CL"].side == OrderSide.SELL + # ES: 1.0 * $1M / (5000 * 50) = 4.0 contracts + assert abs(order_map["ES"].quantity - 4.0) < 0.1 diff --git a/tests/risk/test_short_trailing_stop.py b/tests/risk/test_short_trailing_stop.py index 84d509e6..de323cbf 100644 --- a/tests/risk/test_short_trailing_stop.py +++ b/tests/risk/test_short_trailing_stop.py @@ -15,7 +15,7 @@ import pytest -from ml4t.backtest.config import TrailStopTiming +from ml4t.backtest.config import TrailStopTiming, WaterMarkSource from ml4t.backtest.risk.position.dynamic import TrailingStop from ml4t.backtest.risk.types import ActionType, PositionState from ml4t.backtest.types import StopFillMode @@ -168,10 +168,10 @@ class TestShortTrailingStopIntrabar: """SHORT trailing stop intrabar detection via bar_high.""" def test_intrabar_trigger_via_bar_high(self): - """Stop triggered when bar_high touches stop level.""" + """Stop triggered when bar_high touches stop level (BAR_EXTREME mode).""" rule = TrailingStop(pct=0.05) # 5% trail # SHORT at 100, LWM at 90, trail at 94.5 - # Close at 92 (safe), but high touched 95 (triggers) + # Close at 92 (safe), but high touched 95 (triggers in BAR_EXTREME mode) state = make_short_position( entry_price=100.0, current_price=92.0, @@ -179,12 +179,13 @@ def test_intrabar_trigger_via_bar_high(self): bar_open=91.0, bar_high=95.0, # Touches above trail bar_low=90.0, + context={"trail_hwm_source": WaterMarkSource.BAR_EXTREME}, ) action = rule.evaluate(state) assert action.action == ActionType.EXIT_FULL def test_no_intrabar_trigger_high_below_stop(self): - """No trigger when bar_high is below stop level.""" + """No trigger when bar_high is below stop level (BAR_EXTREME mode).""" rule = TrailingStop(pct=0.05) # 5% trail # SHORT at 100, LWM at 90, trail at 94.5 # High at 93 is below trail @@ -195,6 +196,7 @@ def test_no_intrabar_trigger_high_below_stop(self): bar_open=90.0, bar_high=93.0, # Below trail bar_low=89.0, + context={"trail_hwm_source": WaterMarkSource.BAR_EXTREME}, ) action = rule.evaluate(state) assert action.action == ActionType.HOLD @@ -413,7 +415,10 @@ def test_lagged_mode_uses_previous_lwm(self): current_price=93.0, low_water_mark=90.0, # From previous bar bar_high=95.0, # Crosses trail at 94.5 - context={"trail_stop_timing": TrailStopTiming.LAGGED}, + context={ + "trail_stop_timing": TrailStopTiming.LAGGED, + "trail_hwm_source": WaterMarkSource.BAR_EXTREME, + }, ) action = rule.evaluate(state) assert action.action == ActionType.EXIT_FULL @@ -429,7 +434,10 @@ def test_vbt_pro_mode_two_pass(self): low_water_mark=90.0, bar_high=95.0, bar_low=88.0, # Would update LWM in pass 2 - context={"trail_stop_timing": TrailStopTiming.VBT_PRO}, + context={ + "trail_stop_timing": TrailStopTiming.VBT_PRO, + "trail_hwm_source": WaterMarkSource.BAR_EXTREME, + }, ) action = rule.evaluate(state) assert action.action == ActionType.EXIT_FULL diff --git a/tests/test_extreme_conditions.py b/tests/test_extreme_conditions.py index ac729bf9..9b5abf88 100644 --- a/tests/test_extreme_conditions.py +++ b/tests/test_extreme_conditions.py @@ -11,6 +11,7 @@ from datetime import datetime from ml4t.backtest import Broker +from ml4t.backtest.config import WaterMarkSource from ml4t.backtest.models import NoCommission, NoSlippage, PercentageSlippage from ml4t.backtest.risk import StopLoss, TakeProfit, TrailingStop from ml4t.backtest.types import ExecutionMode, OrderSide @@ -185,7 +186,12 @@ def test_multiple_gap_downs_without_stop(self): def test_trailing_stop_updates_through_gaps(self): """Trailing stop should properly update HWM through gap-up days.""" - broker = Broker(100000.0, NoCommission(), NoSlippage()) + broker = Broker( + 100000.0, + NoCommission(), + NoSlippage(), + trail_hwm_source=WaterMarkSource.BAR_EXTREME, + ) broker._update_time( timestamp=datetime(2024, 1, 1, 9, 30), diff --git a/uv.lock b/uv.lock index abddbdd7..4e80b03b 100644 --- a/uv.lock +++ b/uv.lock @@ -1990,21 +1990,15 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "arch", specifier = ">=7.2.0" }, - { name = "arch", marker = "extra == 'advanced'", specifier = ">=6.0.0" }, - { name = "cupy-cuda11x", marker = "extra == 'all-ml'", specifier = ">=11.0.0" }, { name = "cupy-cuda11x", marker = "extra == 'gpu'", specifier = ">=11.0.0" }, - { name = "hypothesis", marker = "extra == 'all'", specifier = ">=6.80.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.80.0" }, - { name = "ipdb", marker = "extra == 'all'", specifier = ">=0.13.0" }, { name = "ipdb", marker = "extra == 'dev'", specifier = ">=0.13.0" }, - { name = "ipython", marker = "extra == 'all'", specifier = ">=8.14.0" }, { name = "ipython", marker = "extra == 'dev'", specifier = ">=8.14.0" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "joblib", specifier = ">=1.3.0" }, { name = "kaleido", marker = "extra == 'all'", specifier = ">=0.2.0" }, { name = "kaleido", marker = "extra == 'viz'", specifier = ">=0.2.0" }, { name = "lightgbm", marker = "extra == 'all'", specifier = ">=4.0.0" }, - { name = "lightgbm", marker = "extra == 'all-ml'", specifier = ">=4.0.0" }, { name = "lightgbm", marker = "extra == 'ml'", specifier = ">=4.0.0" }, { name = "matplotlib", marker = "extra == 'all'", specifier = ">=3.7.0" }, { name = "matplotlib", marker = "extra == 'viz'", specifier = ">=3.7.0" }, @@ -2013,62 +2007,47 @@ requires-dist = [ { name = "mkdocs-literate-nav", marker = "extra == 'docs'", specifier = ">=0.6.0" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5.0" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.24.0" }, - { name = "myst-parser", marker = "extra == 'all'", specifier = ">=2.0.0" }, - { name = "nbsphinx", marker = "extra == 'all'", specifier = ">=0.9.0" }, { name = "numba", specifier = ">=0.57.0" }, - { name = "numpy", specifier = ">=1.24.0,<2.0.0" }, + { name = "numpy", specifier = ">=1.24.0" }, { name = "pandas", specifier = ">=2.0.0" }, { name = "pandas-market-calendars", specifier = ">=4.0.0" }, { name = "plotly", marker = "extra == 'all'", specifier = ">=5.15.0" }, { name = "plotly", marker = "extra == 'viz'", specifier = ">=5.15.0" }, { name = "polars", specifier = ">=0.20.0" }, - { name = "pre-commit", marker = "extra == 'all'", specifier = ">=3.3.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.3.0" }, { name = "pyarrow", specifier = ">=14.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pypdf", marker = "extra == 'all'", specifier = ">=5.0.0" }, { name = "pypdf", marker = "extra == 'viz'", specifier = ">=5.0.0" }, - { name = "pytest", marker = "extra == 'all'", specifier = ">=7.4.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, - { name = "pytest-benchmark", marker = "extra == 'all'", specifier = ">=4.0.0" }, { name = "pytest-benchmark", marker = "extra == 'dev'", specifier = ">=4.0.0" }, - { name = "pytest-cov", marker = "extra == 'all'", specifier = ">=4.1.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, - { name = "pytest-timeout", marker = "extra == 'all'", specifier = ">=2.1.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.1.0" }, - { name = "pytest-xdist", marker = "extra == 'all'", specifier = ">=3.3.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.3.0" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "ruff", marker = "extra == 'all'", specifier = ">=0.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "scikit-learn", specifier = ">=1.3.0" }, - { name = "scipy", specifier = ">=1.10.0,<1.16.0" }, + { name = "scipy", specifier = ">=1.10.0" }, { name = "seaborn", marker = "extra == 'all'", specifier = ">=0.12.0" }, { name = "seaborn", marker = "extra == 'viz'", specifier = ">=0.12.0" }, { name = "shap", specifier = ">=0.41.0,<0.50.0" }, - { name = "sphinx", marker = "extra == 'all'", specifier = ">=7.0.0" }, - { name = "sphinx-autodoc-typehints", marker = "extra == 'all'", specifier = ">=1.24.0" }, - { name = "sphinx-rtd-theme", marker = "extra == 'all'", specifier = ">=1.3.0" }, { name = "statsmodels", specifier = ">=0.14.0" }, { name = "streamlit", marker = "extra == 'all'", specifier = ">=1.28.0" }, { name = "streamlit", marker = "extra == 'dashboard'", specifier = ">=1.28.0" }, - { name = "tensorflow", marker = "extra == 'all-ml'", specifier = ">=2.0.0" }, - { name = "tensorflow", marker = "extra == 'deep'", specifier = ">=2.0.0" }, { name = "tqdm", specifier = ">=4.66.0" }, - { name = "ty", marker = "extra == 'all'" }, { name = "ty", marker = "extra == 'dev'" }, { name = "wandb", marker = "extra == 'all'", specifier = ">=0.16.0" }, { name = "wandb", marker = "extra == 'tracking'", specifier = ">=0.16.0" }, { name = "xgboost", marker = "extra == 'all'", specifier = ">=2.0.0" }, - { name = "xgboost", marker = "extra == 'all-ml'", specifier = ">=2.0.0" }, { name = "xgboost", marker = "extra == 'ml'", specifier = ">=2.0.0" }, ] -provides-extras = ["advanced", "all", "all-ml", "dashboard", "deep", "dev", "docs", "gpu", "integration", "ml", "tracking", "viz"] +provides-extras = ["all", "dashboard", "dev", "docs", "gpu", "ml", "tracking", "viz"] [package.metadata.requires-dev] dev = [ { name = "hypothesis", specifier = ">=6.80.0" }, { name = "kaleido", specifier = ">=0.2.0" }, + { name = "lightgbm", specifier = ">=4.0.0" }, { name = "matplotlib", specifier = ">=3.7.0" }, { name = "plotly", specifier = ">=5.15.0" }, { name = "pre-commit", specifier = ">=3.3.0" }, @@ -2082,6 +2061,7 @@ dev = [ { name = "seaborn", specifier = ">=0.12.0" }, { name = "twine", specifier = ">=6.0.0" }, { name = "ty" }, + { name = "xgboost", specifier = ">=2.0.0" }, ] [[package]] diff --git a/validation/common/comparator.py b/validation/common/comparator.py index 1097f853..712a2d9c 100644 --- a/validation/common/comparator.py +++ b/validation/common/comparator.py @@ -78,16 +78,18 @@ def compare_results( # Extra checks if "commission" in scenario.extra_checks: - fw_comm = framework_result.extra.get("total_commission", 0) - ml4t_comm = ml4t_result.extra.get("total_commission", 0) - comm_diff = abs(fw_comm - ml4t_comm) - checks.append(CheckResult( - name="total_commission", - passed=comm_diff < tolerance.commission_abs, - message=f"{framework_result.framework}=${fw_comm:.2f}, ML4T=${ml4t_comm:.2f} (diff=${comm_diff:.2f})", - expected=fw_comm, - actual=ml4t_comm, - )) + fw_comm = framework_result.extra.get("total_commission") + ml4t_comm = ml4t_result.extra.get("total_commission") + # Only compare if the framework provides commission data + if fw_comm is not None and ml4t_comm is not None: + comm_diff = abs(fw_comm - ml4t_comm) + checks.append(CheckResult( + name="total_commission", + passed=comm_diff < tolerance.commission_abs, + message=f"{framework_result.framework}=${fw_comm:.2f}, ML4T=${ml4t_comm:.2f} (diff=${comm_diff:.2f})", + expected=fw_comm, + actual=ml4t_comm, + )) if "exit_price" in scenario.extra_checks: fw_exit = framework_result.extra.get("exit_price") diff --git a/validation/common/data_generators.py b/validation/common/data_generators.py index 8d3edfcd..0a740f70 100644 --- a/validation/common/data_generators.py +++ b/validation/common/data_generators.py @@ -38,7 +38,7 @@ def generate_random_walk( all_sessions = nyse.sessions_in_range(start, start + pd.Timedelta(days=n_bars * 2)) dates = pd.DatetimeIndex(all_sessions[:n_bars]).tz_localize("UTC") else: - dates = pd.date_range(start="2020-01-01", periods=n_bars, freq="D") + dates = pd.date_range(start="2020-01-02", periods=n_bars, freq="D") df = pd.DataFrame( { @@ -78,7 +78,7 @@ def generate_short_signals( """ np.random.seed(seed) - dates = pd.date_range(start="2020-01-01", periods=n_bars, freq="D") + dates = pd.date_range(start="2020-01-02", periods=n_bars, freq="D") base_price = 100.0 returns = np.random.randn(n_bars) * 0.02 @@ -111,7 +111,7 @@ def generate_short_signals( return df, entries, exits -def generate_stop_loss_data(seed: int = 42) -> pd.DataFrame: +def generate_stop_loss_data(seed: int = 42) -> tuple[pd.DataFrame, np.ndarray]: """Deterministic declining price path to trigger stop-loss. Used by: scenario 03. Entry at bar 0 ($100), stop triggers at bar 5 ($94.50). @@ -126,12 +126,12 @@ def generate_stop_loss_data(seed: int = 42) -> pd.DataFrame: 84.0, 83.0, 82.0, 81.0, 80.0, ]) - dates = pd.date_range(start="2020-01-01", periods=n_bars, freq="D") + dates = pd.date_range(start="2020-01-02", periods=n_bars, freq="D") opens = closes + 0.5 highs = opens + 0.5 lows = closes - 0.5 - return pd.DataFrame( + df = pd.DataFrame( { "open": opens, "high": highs, @@ -142,8 +142,13 @@ def generate_stop_loss_data(seed: int = 42) -> pd.DataFrame: index=dates, ) + entries = np.zeros(n_bars, dtype=bool) + entries[0] = True + + return df, entries + -def generate_take_profit_data(seed: int = 42) -> pd.DataFrame: +def generate_take_profit_data(seed: int = 42) -> tuple[pd.DataFrame, np.ndarray]: """Deterministic rising price path to trigger take-profit. Used by: scenario 04. Entry at bar 0 ($100), TP triggers around bar 6 ($111). @@ -158,12 +163,12 @@ def generate_take_profit_data(seed: int = 42) -> pd.DataFrame: 120.0, 121.0, 122.0, 123.0, 124.0, ]) - dates = pd.date_range(start="2020-01-01", periods=n_bars, freq="D") + dates = pd.date_range(start="2020-01-02", periods=n_bars, freq="D") opens = closes - 0.5 highs = closes + 0.5 lows = opens - 0.5 - return pd.DataFrame( + df = pd.DataFrame( { "open": opens, "high": highs, @@ -174,6 +179,11 @@ def generate_take_profit_data(seed: int = 42) -> pd.DataFrame: index=dates, ) + entries = np.zeros(n_bars, dtype=bool) + entries[0] = True + + return df, entries + def generate_trending_data( n_bars: int = 100, @@ -203,7 +213,7 @@ def generate_trending_data( prices.append(prices[-1] * (1 + change)) prices = np.array(prices) - dates = pd.date_range(start="2020-01-01", periods=n_bars, freq="D") + dates = pd.date_range(start="2020-01-02", periods=n_bars, freq="D") df = pd.DataFrame( { @@ -258,7 +268,7 @@ def generate_bracket_data( prices.append(prices[-1] * (1 + change)) prices = np.array(prices) - dates = pd.date_range(start="2020-01-01", periods=n_bars, freq="D") + dates = pd.date_range(start="2020-01-02", periods=n_bars, freq="D") df = pd.DataFrame( { @@ -311,7 +321,7 @@ def generate_short_trending_data( prices.append(prices[-1] * (1 + change)) prices = np.array(prices) - dates = pd.date_range(start="2020-01-01", periods=n_bars, freq="D") + dates = pd.date_range(start="2020-01-02", periods=n_bars, freq="D") df = pd.DataFrame( { @@ -387,7 +397,7 @@ def generate_rule_combo_data( else: raise ValueError(f"Unknown scenario: {scenario}") - dates = pd.date_range(start="2020-01-01", periods=n_bars, freq="D") + dates = pd.date_range(start="2020-01-02", periods=n_bars, freq="D") opens = closes - 0.3 highs = closes + 0.5 lows = closes - 0.5 @@ -447,7 +457,7 @@ def generate_stress_data( prices.append(max(prices[-1] * (1 + change), 1.0)) prices = np.array(prices) - dates = pd.date_range(start="2020-01-01", periods=n_bars, freq="D") + dates = pd.date_range(start="2020-01-02", periods=n_bars, freq="D") df = pd.DataFrame( { diff --git a/validation/common/ml4t_runner.py b/validation/common/ml4t_runner.py index 72cb5b88..b061d3d9 100644 --- a/validation/common/ml4t_runner.py +++ b/validation/common/ml4t_runner.py @@ -141,16 +141,26 @@ def _get_asset_name(scenario: ScenarioConfig, framework: str) -> str: def _build_strategy(scenario: ScenarioConfig, asset: str): """Build the ml4t Strategy for this scenario.""" from ml4t.backtest._validation_imports import OrderSide, Strategy + from ml4t.backtest.risk import RuleChain risk_rules = _build_risk_rules(scenario) + # Detect short direction from config and data generator name + is_short = ( + scenario.ml4t_config.get("allow_short_selling", False) + and "short" in scenario.data_generator.lower() + ) + class ValidationStrategy(Strategy): def __init__(self): self._entered = False def on_start(self, broker): if risk_rules: - broker.set_position_rules(*risk_rules) + if len(risk_rules) == 1: + broker.set_position_rules(risk_rules[0]) + else: + broker.set_position_rules(RuleChain(risk_rules)) def on_data(self, timestamp, data, context, broker): if asset not in data: @@ -187,13 +197,15 @@ def on_data(self, timestamp, data, context, broker): # Entry only on first signal, exits handled by risk rules if signals.get("entry") and current_qty == 0: if not self._entered or scenario.constants.get("allow_reentry", True): - broker.submit_order(asset, scenario.shares) + side = OrderSide.SELL if is_short else OrderSide.BUY + broker.submit_order(asset, scenario.shares, side) self._entered = True elif scenario.strategy_type == "single_entry": # One-time entry, exits handled by risk rules if not self._entered and current_qty == 0: - broker.submit_order(asset, scenario.shares) + side = OrderSide.SELL if is_short else OrderSide.BUY + broker.submit_order(asset, scenario.shares, side) self._entered = True return ValidationStrategy() @@ -224,7 +236,13 @@ def _build_config(scenario: ScenarioConfig, framework: str): StopFillMode, StopLevelBasis, ) - from ml4t.backtest.config import CommissionType, ExecutionPrice, SlippageType + from ml4t.backtest.config import ( + CommissionType, + ExecutionPrice, + SlippageType, + TrailStopTiming, + WaterMarkSource, + ) # Start with scenario base config config_kwargs: dict[str, Any] = { @@ -263,7 +281,7 @@ def _build_config(scenario: ScenarioConfig, framework: str): config_kwargs["commission_per_share"] = constants["per_share_rate"] if "slippage_fixed" in constants: config_kwargs["slippage_type"] = SlippageType.FIXED - config_kwargs["slippage_amount"] = constants["slippage_fixed"] + config_kwargs["slippage_fixed"] = constants["slippage_fixed"] if "slippage_rate" in constants: config_kwargs["slippage_type"] = SlippageType.PERCENTAGE config_kwargs["slippage_rate"] = constants["slippage_rate"] @@ -277,5 +295,9 @@ def _build_config(scenario: ScenarioConfig, framework: str): config_kwargs[key] = StopFillMode[val.upper()] elif key == "stop_level_basis" and hasattr(StopLevelBasis, val.upper()): config_kwargs[key] = StopLevelBasis[val.upper()] + elif key == "trail_hwm_source" and hasattr(WaterMarkSource, val.upper()): + config_kwargs[key] = WaterMarkSource[val.upper()] + elif key == "trail_stop_timing" and hasattr(TrailStopTiming, val.upper()): + config_kwargs[key] = TrailStopTiming[val.upper()] return BacktestConfig(**config_kwargs) diff --git a/validation/frameworks/backtrader.py b/validation/frameworks/backtrader.py index a78dec56..9f9d5c0b 100644 --- a/validation/frameworks/backtrader.py +++ b/validation/frameworks/backtrader.py @@ -143,6 +143,7 @@ def __init__(self): self.bar_count = 0 self.trade_log = [] self.total_commission = 0.0 + self.pending_trade = None def next(self): idx = self.bar_count @@ -161,17 +162,28 @@ def next(self): self.bar_count += 1 def notify_trade(self, trade): - if trade.isclosed: - self.trade_log.append({ + if trade.justopened: + self.pending_trade = { "entry_time": bt.num2date(trade.dtopen), - "exit_time": bt.num2date(trade.dtclose), "entry_price": trade.price, + "entry_size": trade.size, + } + elif trade.isclosed and self.pending_trade: + entry_size = self.pending_trade["entry_size"] + exit_price = self.pending_trade["entry_price"] + trade.pnl / abs(entry_size) + + self.trade_log.append({ + "entry_time": self.pending_trade["entry_time"], + "exit_time": bt.num2date(trade.dtclose), + "entry_price": self.pending_trade["entry_price"], + "exit_price": exit_price, "pnl": trade.pnl, "pnlcomm": trade.pnlcomm, "commission": trade.commission, - "size": abs(trade.size), + "size": abs(entry_size), "direction": "Long", }) + self.pending_trade = None def notify_order(self, order): if order.status == order.Completed: @@ -241,9 +253,12 @@ def notify_trade(self, trade): def _risk_entry_strategy(scenario: ScenarioConfig, bt: Any) -> type: - """Entry-only strategy with risk-rule exits.""" + """Entry-only strategy with risk-rule exits. + + Handles all rule combinations (TSL, SL, TP, TSL+TP, TSL+SL, SL+TP, TSL+SL+TP) + using Backtrader's OCO (One-Cancels-Other) for automatic cancellation. + """ shares = scenario.shares - constants = scenario.constants single_entry = scenario.strategy_type == "single_entry" # Determine risk rule setup @@ -255,7 +270,10 @@ def _risk_entry_strategy(scenario: ScenarioConfig, bt: Any) -> type: tp_pct = next((r["pct"] for r in scenario.risk_rules if r["type"] == "TakeProfit"), None) trail_pct = next((r["pct"] for r in scenario.risk_rules if r["type"] == "TrailingStop"), None) - is_short = scenario.ml4t_config.get("allow_short_selling", False) and "short" in scenario.data_generator.lower() + is_short = ( + scenario.ml4t_config.get("allow_short_selling", False) + and "short" in scenario.data_generator.lower() + ) class RiskEntryStrategy(bt.Strategy): params = (("entries", None), ("exits", None), ("scenario", None)) @@ -264,8 +282,61 @@ def __init__(self): self.bar_count = 0 self.trade_log = [] self.entered_once = False - self.stop_order = None + self.entry_order = None + self.exit_orders = [] self.pending_trade = None + self.needs_trail = False # Defer trailing stop to notify_order + + def _submit_fixed_exits(self, ref_price): + """Submit non-trailing exit orders at entry time, using signal close as ref. + + Returns list of submitted orders (for OCO linking with deferred trail). + """ + orders = [] + if is_short: + if has_stop_loss: + sl_price = ref_price * (1 + sl_pct) + orders.append(self.buy( + exectype=bt.Order.Stop, price=sl_price, size=shares, + )) + if has_take_profit: + tp_price = ref_price * (1 - tp_pct) + orders.append(self.buy( + exectype=bt.Order.Limit, price=tp_price, size=shares, + oco=orders[0] if orders else None, + )) + else: + if has_stop_loss: + sl_price = ref_price * (1 - sl_pct) + orders.append(self.sell( + exectype=bt.Order.Stop, price=sl_price, size=shares, + )) + if has_take_profit: + tp_price = ref_price * (1 + tp_pct) + orders.append(self.sell( + exectype=bt.Order.Limit, price=tp_price, size=shares, + oco=orders[0] if orders else None, + )) + return orders + + def _submit_trail(self): + """Submit trailing stop AFTER entry fills (deferred via notify_order). + + This ensures the trail initializes from the fill bar, not the signal bar. + Links via OCO to any existing fixed exit orders. + """ + first_existing = self.exit_orders[0] if self.exit_orders else None + if is_short: + trail = self.buy( + exectype=bt.Order.StopTrail, trailpercent=trail_pct, + size=shares, oco=first_existing, + ) + else: + trail = self.sell( + exectype=bt.Order.StopTrail, trailpercent=trail_pct, + size=shares, oco=first_existing, + ) + self.exit_orders.append(trail) def next(self): idx = self.bar_count @@ -282,74 +353,18 @@ def next(self): self.bar_count += 1 return + # Submit entry order if is_short: - # Short entry - self.sell(size=shares) - if has_trailing_stop: - self.stop_order = self.buy( - exectype=bt.Order.StopTrail, - trailpercent=trail_pct, - size=shares, - ) - elif has_stop_loss and has_take_profit: - # Bracket order - entry_price = self.data.close[0] - sl_price = entry_price * (1 + sl_pct) # SL above for short - tp_price = entry_price * (1 - tp_pct) # TP below for short - self.sell( - size=shares, - exectype=bt.Order.Stop, - price=sl_price, - ) - elif has_stop_loss: - entry_price = self.data.close[0] - sl_price = entry_price * (1 + sl_pct) - self.buy( - size=shares, - exectype=bt.Order.Stop, - price=sl_price, - ) + self.entry_order = self.sell(size=shares) else: - # Long entry - self.buy(size=shares) - if has_trailing_stop: - self.stop_order = self.sell( - exectype=bt.Order.StopTrail, - trailpercent=trail_pct, - size=shares, - ) - elif has_stop_loss and has_take_profit: - # Bracket order (OCO) - entry_price = self.data.close[0] - sl_price = entry_price * (1 - sl_pct) - tp_price = entry_price * (1 + tp_pct) - self.sell( - size=shares, - exectype=bt.Order.Stop, - price=sl_price, - ) - self.sell( - size=shares, - exectype=bt.Order.Limit, - price=tp_price, - ) - elif has_stop_loss: - entry_price = self.data.close[0] - sl_price = entry_price * (1 - sl_pct) - self.sell( - size=shares, - exectype=bt.Order.Stop, - price=sl_price, - ) - elif has_take_profit: - entry_price = self.data.close[0] - tp_price = entry_price * (1 + tp_pct) - self.sell( - size=shares, - exectype=bt.Order.Limit, - price=tp_price, - ) + self.entry_order = self.buy(size=shares) + # Submit fixed exit orders now (SL/TP from signal close) + ref_price = self.data.close[0] + self.exit_orders = self._submit_fixed_exits(ref_price) + + # Defer trailing stop to after entry fills + self.needs_trail = has_trailing_stop self.entered_once = True self.bar_count += 1 @@ -378,10 +393,14 @@ def notify_trade(self, trade): "direction": "Short" if entry_size < 0 else "Long", }) self.pending_trade = None + self.exit_orders = [] def notify_order(self, order): - if order.status == order.Completed: - if order == self.stop_order: - self.stop_order = None + if order.status == order.Completed and order == self.entry_order: + # Entry filled — now submit deferred trailing stop + self.entry_order = None + if self.needs_trail: + self._submit_trail() + self.needs_trail = False return RiskEntryStrategy diff --git a/validation/frameworks/vectorbt_oss.py b/validation/frameworks/vectorbt_oss.py index 5c9ec05f..1edd64f6 100644 --- a/validation/frameworks/vectorbt_oss.py +++ b/validation/frameworks/vectorbt_oss.py @@ -41,8 +41,11 @@ def run( constants = scenario.constants - # Build portfolio kwargs + # Build portfolio kwargs — pass OHLC so VBT uses intrabar stop checks pf_kwargs = { + "open": prices_df["open"], + "high": prices_df["high"], + "low": prices_df["low"], "close": prices_df["close"], "entries": entries, "init_cash": scenario.initial_cash, @@ -93,8 +96,12 @@ def run( pf_kwargs["sl_stop"] = trail_pct pf_kwargs["sl_trail"] = True - # Short-only adjustments - if scenario.strategy_type == "short_only": + # Short direction detection + is_short = scenario.strategy_type == "short_only" or ( + scenario.ml4t_config.get("allow_short_selling", False) + and "short" in scenario.data_generator.lower() + ) + if is_short: # VBT OSS uses short_entries/short_exits params if "entries" in pf_kwargs: pf_kwargs["short_entries"] = pf_kwargs.pop("entries") @@ -114,9 +121,9 @@ def run( normalized_trades = [] for t in trade_list: normalized = { - "entry_price": t.get("Entry Price", t.get("Avg Entry Price", 0)), - "exit_price": t.get("Exit Price", t.get("Avg Exit Price", 0)), - "pnl": t.get("PnL", t.get("P&L", 0)), + "entry_price": t.get("Avg Entry Price", t.get("Entry Price", 0)), + "exit_price": t.get("Avg Exit Price", t.get("Exit Price", 0)), + "pnl": t.get("PnL", 0), "size": t.get("Size", scenario.shares), "direction": t.get("Direction", "Long"), } @@ -124,8 +131,11 @@ def run( extra = {} if "commission" in scenario.extra_checks: - # VBT OSS includes fees in PnL, extract total fees - fees = sum(abs(t.get("Fees Paid", t.get("Fees", 0))) for t in trade_list) + # VBT OSS tracks entry/exit fees separately + fees = sum( + abs(t.get("Entry Fees", 0)) + abs(t.get("Exit Fees", 0)) + for t in trade_list + ) extra["total_commission"] = fees if "exit_price" in scenario.extra_checks and normalized_trades: extra["exit_price"] = normalized_trades[0].get("exit_price") diff --git a/validation/frameworks/zipline.py b/validation/frameworks/zipline.py index 8056e849..6d69c736 100644 --- a/validation/frameworks/zipline.py +++ b/validation/frameworks/zipline.py @@ -54,17 +54,41 @@ def run( } shares = scenario.shares risk_rules = scenario.risk_rules - is_short = scenario.strategy_type == "short_only" + is_short = scenario.strategy_type == "short_only" or ( + scenario.ml4t_config.get("allow_short_selling", False) + and "short" in scenario.data_generator.lower() + ) def initialize(context): + from zipline.api import set_commission as set_comm + from zipline.finance import commission as zipline_commission + context.asset = symbol("TEST") context.signal_data = signal_data context.bar_count = 0 context.in_position = False context.entry_price = None context.high_water_mark = None - # Use custom slippage to fill at open price - set_slippage(_create_open_price_slippage()) + + # Commission setup + if "commission_rate" in constants: + set_comm(zipline_commission.PerDollar(cost=constants["commission_rate"])) + context.commission_mode = "pct" + elif "per_share_rate" in constants: + set_comm(zipline_commission.PerShare(cost=constants["per_share_rate"])) + context.commission_mode = "per_share" + else: + set_comm(zipline_commission.PerShare(cost=0.0)) + context.commission_mode = "none" + + # Slippage: fill at open price (Zipline always fills next-bar) + # For slippage scenarios, the slippage is added on top of the open price + if "slippage_fixed" in constants: + set_slippage(_create_slippage_model(fixed=constants["slippage_fixed"])) + elif "slippage_rate" in constants: + set_slippage(_create_slippage_model(pct=constants["slippage_rate"])) + else: + set_slippage(_create_open_price_slippage()) def handle_data(context, data): idx = context.bar_count @@ -76,29 +100,32 @@ def handle_data(context, data): current_pos = context.portfolio.positions[context.asset].amount current_price = data.current(context.asset, "close") + bar_high = data.current(context.asset, "high") + bar_low = data.current(context.asset, "low") # Risk rule evaluation for manual stop/take-profit (Zipline has no built-in rules) + # Use OHLC for intrabar detection: bar_low triggers long stops, bar_high triggers short stops if current_pos != 0 and context.entry_price is not None: should_exit = False for rule in risk_rules: if rule["type"] == "StopLoss": if current_pos > 0: - loss_pct = (current_price - context.entry_price) / context.entry_price + loss_pct = (bar_low - context.entry_price) / context.entry_price if loss_pct <= -rule["pct"]: should_exit = True elif current_pos < 0: - loss_pct = (context.entry_price - current_price) / context.entry_price + loss_pct = (context.entry_price - bar_high) / context.entry_price if loss_pct <= -rule["pct"]: should_exit = True elif rule["type"] == "TakeProfit": if current_pos > 0: - gain_pct = (current_price - context.entry_price) / context.entry_price + gain_pct = (bar_high - context.entry_price) / context.entry_price if gain_pct >= rule["pct"]: should_exit = True elif current_pos < 0: - gain_pct = (context.entry_price - current_price) / context.entry_price + gain_pct = (context.entry_price - bar_low) / context.entry_price if gain_pct >= rule["pct"]: should_exit = True @@ -106,13 +133,15 @@ def handle_data(context, data): if context.high_water_mark is None: context.high_water_mark = current_price if current_pos > 0: - context.high_water_mark = max(context.high_water_mark, current_price) - drawdown = (context.high_water_mark - current_price) / context.high_water_mark + # HWM tracks from bar_high, trigger from bar_low + context.high_water_mark = max(context.high_water_mark, bar_high) + drawdown = (context.high_water_mark - bar_low) / context.high_water_mark if drawdown >= rule["pct"]: should_exit = True elif current_pos < 0: - context.high_water_mark = min(context.high_water_mark, current_price) - drawup = (current_price - context.high_water_mark) / context.high_water_mark + # LWM tracks from bar_low, trigger from bar_high + context.high_water_mark = min(context.high_water_mark, bar_low) + drawup = (bar_high - context.high_water_mark) / context.high_water_mark if drawup >= rule["pct"]: should_exit = True @@ -151,12 +180,19 @@ def analyze(context, perf): # Setup bundle bundle_name = _setup_bundle(prices_df) - # Run + # Run — snap to valid NYSE sessions (data may include holidays) + import exchange_calendars as xcals + + nyse = xcals.get_calendar("XNYS") start = prices_df.index[0] end = prices_df.index[-1] if start.tz is not None: start = start.tz_convert(None) end = end.tz_convert(None) + if not nyse.is_session(start): + start = nyse.date_to_session(start, direction="next") + if not nyse.is_session(end): + end = nyse.date_to_session(end, direction="previous") results = run_algorithm( start=start, @@ -179,16 +215,21 @@ def analyze(context, perf): num_trades += len(txn_list) num_trades = num_trades // 2 # Entry + exit = 1 round trip + extra = {} + # Note: Zipline doesn't reliably report per-transaction commission totals. + # Commission correctness is validated via final_value parity instead. + return FrameworkResult( framework="Zipline", final_value=final_value, total_pnl=final_value - scenario.initial_cash, num_trades=num_trades, + extra=extra, ) def _create_open_price_slippage(): - """Create a custom slippage model that fills at open price.""" + """Create a custom slippage model that fills at open price (zero slippage).""" from zipline.finance.slippage import SlippageModel class OpenPriceSlippage(SlippageModel): @@ -199,6 +240,27 @@ def process_order(data, order): return OpenPriceSlippage() +def _create_slippage_model(fixed: float = 0.0, pct: float = 0.0): + """Create a slippage model that fills at open price +/- slippage.""" + from zipline.finance.slippage import SlippageModel + + class OpenPriceWithSlippage(SlippageModel): + def process_order(self, data, order): + price = data.current(order.asset, "open") + if pct > 0: + slip = price * pct + else: + slip = fixed + # Buys get worse (higher) price, sells get worse (lower) price + if order.amount > 0: + price += slip + else: + price -= slip + return (price, order.amount) + + return OpenPriceWithSlippage() + + def _setup_bundle(prices_df: pd.DataFrame, bundle_name: str = "test_validation") -> str: """Register and ingest a custom bundle with test data.""" from zipline.data.bundles import ingest, register @@ -240,12 +302,22 @@ def ingest_func( return ingest_func + import exchange_calendars as xcals + + nyse = xcals.get_calendar("XNYS") + start_session = prices_df.index[0] end_session = prices_df.index[-1] if start_session.tz is not None: start_session = start_session.tz_convert(None) end_session = end_session.tz_convert(None) + # Snap to valid NYSE trading sessions (data may include weekends/holidays) + if not nyse.is_session(start_session): + start_session = nyse.date_to_session(start_session, direction="next") + if not nyse.is_session(end_session): + end_session = nyse.date_to_session(end_session, direction="previous") + register( bundle_name, make_ingest_func(prices_df), diff --git a/validation/run_scenario.py b/validation/run_scenario.py index a66bdcc5..438fc0d8 100644 --- a/validation/run_scenario.py +++ b/validation/run_scenario.py @@ -76,6 +76,24 @@ def run_single(scenario_id: str, framework: str, verbose: bool = False) -> bool: prices_df, entries = data_result exits = None + # Align to NYSE calendar for Zipline (which only operates on NYSE sessions) + if framework == "zipline": + import exchange_calendars as xcals + + nyse = xcals.get_calendar("XNYS") + start_ts = prices_df.index[0] + end_ts = prices_df.index[-1] + if start_ts.tz is not None: + start_ts = start_ts.tz_convert(None) + end_ts = end_ts.tz_convert(None) + sessions = nyse.sessions_in_range(start_ts, end_ts) + naive_idx = prices_df.index.tz_localize(None) if prices_df.index.tz else prices_df.index + valid_mask = naive_idx.isin(sessions) + prices_df = prices_df[valid_mask].copy() + entries = entries[valid_mask] + if exits is not None: + exits = exits[valid_mask] + print(f" Bars: {len(prices_df)}") print(f" Entry signals: {entries.sum()}") if exits is not None: diff --git a/validation/scenarios/definitions.py b/validation/scenarios/definitions.py index c6e8bd97..af56629f 100644 --- a/validation/scenarios/definitions.py +++ b/validation/scenarios/definitions.py @@ -100,7 +100,7 @@ def _fw_tolerances() -> dict[str, Tolerance]: "stop_level_basis": "FILL_PRICE", }, "zipline": { - "stop_fill_mode": "STOP_PRICE", + "stop_fill_mode": "NEXT_BAR_OPEN", "stop_level_basis": "FILL_PRICE", }, }, @@ -141,7 +141,7 @@ def _fw_tolerances() -> dict[str, Tolerance]: "stop_level_basis": "FILL_PRICE", }, "zipline": { - "stop_fill_mode": "STOP_PRICE", + "stop_fill_mode": "NEXT_BAR_OPEN", "stop_level_basis": "FILL_PRICE", }, }, @@ -185,6 +185,8 @@ def _fw_tolerances() -> dict[str, Tolerance]: strategy_type="long_signal", constants={"per_share_rate": 0.005}, extra_checks=["commission"], + # VBT OSS only supports percentage fees, not per-share + supported_frameworks=["vectorbt_pro", "backtrader", "zipline"], tolerances=_fw_tolerances(), ) @@ -234,11 +236,29 @@ def _fw_tolerances() -> dict[str, Tolerance]: strategy_type="risk_entry_only", risk_rules=[{"type": "TrailingStop", "pct": 0.05}], constants={"trail_pct": 0.05}, + ml4t_overrides={ + "vectorbt_oss": { + "stop_fill_mode": "STOP_PRICE", + "trail_hwm_source": "BAR_EXTREME", + }, + "vectorbt_pro": { + "stop_fill_mode": "STOP_PRICE", + }, + "backtrader": { + "stop_fill_mode": "STOP_PRICE", + "stop_level_basis": "SIGNAL_PRICE", + }, + "zipline": { + "stop_fill_mode": "NEXT_BAR_OPEN", + "trail_hwm_source": "BAR_EXTREME", + "trail_stop_timing": "INTRABAR", + }, + }, tolerances={ "vectorbt_pro": Tolerance(trade_count=0, value_pct=0.5, pnl_abs=50.0), - "vectorbt_oss": Tolerance(trade_count=0, value_pct=0.5, pnl_abs=50.0), - "backtrader": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=200.0), - "zipline": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=200.0), + "vectorbt_oss": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "backtrader": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "zipline": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=5.0), }, ) @@ -260,10 +280,22 @@ def _fw_tolerances() -> dict[str, Tolerance]: ], constants={"sl_pct": 0.05, "tp_pct": 0.10}, supported_frameworks=NO_ZIPLINE, + ml4t_overrides={ + "vectorbt_oss": { + "stop_fill_mode": "STOP_PRICE", + }, + "vectorbt_pro": { + "stop_fill_mode": "STOP_PRICE", + }, + "backtrader": { + "stop_fill_mode": "STOP_PRICE", + "stop_level_basis": "SIGNAL_PRICE", + }, + }, tolerances={ "vectorbt_pro": Tolerance(trade_count=0, value_pct=0.5, pnl_abs=50.0), - "vectorbt_oss": Tolerance(trade_count=0, value_pct=0.5, pnl_abs=50.0), - "backtrader": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=200.0), + "vectorbt_oss": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "backtrader": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), }, ) @@ -306,11 +338,29 @@ def _fw_tolerances() -> dict[str, Tolerance]: "allow_short_selling": True, "allow_leverage": True, }, + ml4t_overrides={ + "vectorbt_oss": { + "stop_fill_mode": "STOP_PRICE", + "trail_hwm_source": "BAR_EXTREME", + }, + "vectorbt_pro": { + "stop_fill_mode": "STOP_PRICE", + }, + "backtrader": { + "stop_fill_mode": "STOP_PRICE", + "stop_level_basis": "SIGNAL_PRICE", + }, + "zipline": { + "stop_fill_mode": "NEXT_BAR_OPEN", + "trail_hwm_source": "BAR_EXTREME", + "trail_stop_timing": "INTRABAR", + }, + }, tolerances={ "vectorbt_pro": Tolerance(trade_count=0, value_pct=0.5, pnl_abs=50.0), - "vectorbt_oss": Tolerance(trade_count=0, value_pct=0.5, pnl_abs=50.0), - "backtrader": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=200.0), - "zipline": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=200.0), + "vectorbt_oss": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "backtrader": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "zipline": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=5.0), }, ) @@ -331,11 +381,17 @@ def _fw_tolerances() -> dict[str, Tolerance]: {"type": "TakeProfit", "pct": 0.08}, ], constants={"trail_pct": 0.05, "tp_pct": 0.08}, + ml4t_overrides={ + "vectorbt_oss": {"stop_fill_mode": "STOP_PRICE", "trail_hwm_source": "BAR_EXTREME"}, + "vectorbt_pro": {"stop_fill_mode": "STOP_PRICE"}, + "backtrader": {"stop_fill_mode": "STOP_PRICE", "stop_level_basis": "SIGNAL_PRICE"}, + "zipline": {"stop_fill_mode": "NEXT_BAR_OPEN", "trail_hwm_source": "BAR_EXTREME", "trail_stop_timing": "INTRABAR"}, + }, tolerances={ - "vectorbt_pro": Tolerance(trade_count=0, value_pct=1.0, pnl_abs=100.0), - "vectorbt_oss": Tolerance(trade_count=0, value_pct=1.0, pnl_abs=100.0), - "backtrader": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=200.0), - "zipline": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=200.0), + "vectorbt_pro": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "vectorbt_oss": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "backtrader": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "zipline": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=5.0), }, ) @@ -356,11 +412,17 @@ def _fw_tolerances() -> dict[str, Tolerance]: {"type": "StopLoss", "pct": 0.08}, ], constants={"trail_pct": 0.05, "sl_pct": 0.08}, + ml4t_overrides={ + "vectorbt_oss": {"stop_fill_mode": "STOP_PRICE", "trail_hwm_source": "BAR_EXTREME"}, + "vectorbt_pro": {"stop_fill_mode": "STOP_PRICE"}, + "backtrader": {"stop_fill_mode": "STOP_PRICE", "stop_level_basis": "SIGNAL_PRICE"}, + "zipline": {"stop_fill_mode": "NEXT_BAR_OPEN", "trail_hwm_source": "BAR_EXTREME", "trail_stop_timing": "INTRABAR"}, + }, tolerances={ - "vectorbt_pro": Tolerance(trade_count=0, value_pct=1.0, pnl_abs=100.0), - "vectorbt_oss": Tolerance(trade_count=0, value_pct=1.0, pnl_abs=100.0), - "backtrader": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=200.0), - "zipline": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=200.0), + "vectorbt_pro": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "vectorbt_oss": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "backtrader": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "zipline": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=5.0), }, ) @@ -382,11 +444,18 @@ def _fw_tolerances() -> dict[str, Tolerance]: {"type": "StopLoss", "pct": 0.05}, ], constants={"trail_pct": 0.03, "tp_pct": 0.10, "sl_pct": 0.05}, + ml4t_overrides={ + "vectorbt_oss": {"stop_fill_mode": "STOP_PRICE", "trail_hwm_source": "BAR_EXTREME"}, + "vectorbt_pro": {"stop_fill_mode": "STOP_PRICE"}, + "backtrader": {"stop_fill_mode": "STOP_PRICE", "stop_level_basis": "SIGNAL_PRICE"}, + "zipline": {"stop_fill_mode": "NEXT_BAR_OPEN", "trail_hwm_source": "BAR_EXTREME", "trail_stop_timing": "INTRABAR"}, + }, tolerances={ - "vectorbt_pro": Tolerance(trade_count=0, value_pct=1.0, pnl_abs=100.0), - "vectorbt_oss": Tolerance(trade_count=0, value_pct=1.0, pnl_abs=100.0), - "backtrader": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=200.0), - "zipline": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=200.0), + "vectorbt_pro": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "vectorbt_oss": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "backtrader": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + # Zipline SL reference from signal close vs ml4t from fill price = ~$9.46 diff + "zipline": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=10.0), }, ) @@ -404,11 +473,17 @@ def _fw_tolerances() -> dict[str, Tolerance]: strategy_type="risk_entry_only", risk_rules=[{"type": "TrailingStop", "pct": 0.05}], constants={"trail_pct": 0.05, "allow_reentry": True}, + ml4t_overrides={ + "vectorbt_oss": {"stop_fill_mode": "STOP_PRICE", "trail_hwm_source": "BAR_EXTREME"}, + "vectorbt_pro": {"stop_fill_mode": "STOP_PRICE"}, + "backtrader": {"stop_fill_mode": "STOP_PRICE", "stop_level_basis": "SIGNAL_PRICE"}, + "zipline": {"stop_fill_mode": "NEXT_BAR_OPEN", "trail_hwm_source": "BAR_EXTREME", "trail_stop_timing": "INTRABAR"}, + }, tolerances={ "vectorbt_pro": Tolerance(trade_count=0, value_pct=1.0, pnl_abs=200.0), - "vectorbt_oss": Tolerance(trade_count=0, value_pct=1.0, pnl_abs=200.0), - "backtrader": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=500.0), - "zipline": Tolerance(trade_count=0, value_pct=2.0, pnl_abs=500.0), + "vectorbt_oss": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "backtrader": Tolerance(trade_count=0, value_pct=0.01, pnl_abs=1.0), + "zipline": Tolerance(trade_count=0, value_pct=0.1, pnl_abs=50.0), }, )