From 8b5254f820168479dcf3f936940ca1ca0a72c601 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 26 Feb 2026 17:11:11 -0500 Subject: [PATCH 01/24] fix: cast equity values to float in BacktestResult.to_equity_dataframe() When initial_cash is passed as int (e.g., 100000), the equity curve starts with int then transitions to float values. Polars strict mode rejects this mixed-type list. Ensure all values are float. Co-Authored-By: Claude Opus 4.6 --- src/ml4t/backtest/result.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ml4t/backtest/result.py b/src/ml4t/backtest/result.py index ad8a8882..e20aa972 100644 --- a/src/ml4t/backtest/result.py +++ b/src/ml4t/backtest/result.py @@ -179,7 +179,7 @@ def to_equity_dataframe(self) -> pl.DataFrame: return pl.DataFrame(schema=self._equity_schema()) timestamps = [ts for ts, _ in self.equity_curve] - values = [v for _, v in self.equity_curve] + values = [float(v) for _, v in self.equity_curve] # Build base DataFrame and sort by timestamp df = pl.DataFrame({"timestamp": timestamps, "equity": values}).sort("timestamp") From 43ae5518470d710f921c6473bf698221ecff254b Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 26 Feb 2026 18:01:21 -0500 Subject: [PATCH 02/24] fix(result): use daily returns in to_tearsheet(), add calendar param - to_tearsheet() was using to_returns_series() (bar-level), which inflates Sharpe for intraday data. Now uses to_daily_returns() for correct metrics. - Added calendar parameter for session alignment (crypto, CME futures). - Pass equity_curve to generate_backtest_tearsheet() so portfolio-level charts can render. Co-Authored-By: Claude Opus 4.6 --- src/ml4t/backtest/result.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/ml4t/backtest/result.py b/src/ml4t/backtest/result.py index e20aa972..d5c98cd5 100644 --- a/src/ml4t/backtest/result.py +++ b/src/ml4t/backtest/result.py @@ -814,6 +814,7 @@ def to_tearsheet( title: str | None = None, output_path: str | Path | None = None, include_statistical: bool = True, + calendar: str | None = None, ) -> str: """Generate an interactive HTML tearsheet for the backtest results. @@ -841,6 +842,9 @@ def to_tearsheet( include_statistical : bool Whether to include statistical validity analysis (DSR, RAS). Requires sufficient trades for meaningful statistics. + calendar : str, optional + Trading calendar for session alignment (e.g. "NYSE", "crypto"). + Passed to to_daily_returns() for correct daily aggregation. Returns ------- @@ -869,7 +873,8 @@ def to_tearsheet( # Extract data for tearsheet trades_df = self.to_trades_dataframe() - returns = self.to_returns_series().to_numpy() + # Use daily returns (not bar-level) for correct annualized metrics + returns = self.to_daily_returns(calendar=calendar).to_numpy() # Build metrics dict with all available metrics tearsheet_metrics = dict(self.metrics) @@ -887,11 +892,15 @@ def to_tearsheet( if "total_slippage" not in tearsheet_metrics and self.trades: tearsheet_metrics["total_slippage"] = sum(t.slippage for t in self.trades) + # Extract equity curve for portfolio-level charts + equity_df = self.to_equity_dataframe() if self.equity_curve else None + # Generate tearsheet html = generate_backtest_tearsheet( metrics=tearsheet_metrics, trades=trades_df if len(trades_df) > 0 else None, returns=returns if len(returns) > 0 else None, + equity_curve=equity_df, template=template, theme=theme, title=title or "Backtest Tearsheet", From e76f0bbb237ee68b3b91820292642fe81f2c411c Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 26 Feb 2026 23:57:26 -0500 Subject: [PATCH 03/24] chore: delete all deprecated code and backward compat shims - Delete analysis.py shim module (414 lines) - Remove SizingMethod enum, BacktestEngine/TrailHwmSource aliases - Remove 6 deprecated property aliases (Trade.asset/.commission/.mfe/.mae, Position.avg_entry_price) - Remove dict-like access on BacktestResult (__getitem__, __contains__, get) - Remove deprecated equity metrics (sharpe/sortino/calmar methods) - Simplify fill_executor exit reason parsing - Update all tests for new API --- src/ml4t/backtest/__init__.py | 30 +- src/ml4t/backtest/accounting/policy.py | 3 - src/ml4t/backtest/analysis.py | 414 ------------------- src/ml4t/backtest/analytics/equity.py | 59 +-- src/ml4t/backtest/broker.py | 4 - src/ml4t/backtest/config.py | 90 ++-- src/ml4t/backtest/engine.py | 11 +- src/ml4t/backtest/execution/fill_executor.py | 28 +- src/ml4t/backtest/execution/rebalancer.py | 32 +- src/ml4t/backtest/result.py | 73 ---- src/ml4t/backtest/types.py | 32 -- tests/accounting/test_account_state.py | 20 +- tests/accounting/test_position.py | 20 +- tests/regression/test_golden_strategies.py | 2 +- tests/test_analysis.py | 351 +--------------- tests/test_broker.py | 4 +- tests/test_calendar_integration.py | 20 +- tests/test_core.py | 38 +- tests/test_result.py | 24 +- uv.lock | 398 +----------------- 20 files changed, 143 insertions(+), 1510 deletions(-) delete mode 100644 src/ml4t/backtest/analysis.py diff --git a/src/ml4t/backtest/__init__.py b/src/ml4t/backtest/__init__.py index 7075331f..b9da96fd 100644 --- a/src/ml4t/backtest/__init__.py +++ b/src/ml4t/backtest/__init__.py @@ -15,10 +15,6 @@ # Import from modules # Analytics -# Deprecated: Import from analysis for backward compatibility -# These emit deprecation warnings when imported -import warnings as _warnings - from .analytics import ( # Analytics classes EquityCurve, @@ -35,11 +31,6 @@ to_trade_records, volatility, ) - -with _warnings.catch_warnings(): - _warnings.filterwarnings("ignore", category=DeprecationWarning) - from .analysis import BacktestAnalyzer, TradeStatistics - from .broker import Broker # Calendar functions (pandas_market_calendars integration) @@ -68,22 +59,15 @@ FillTiming, InitialHwmSource, Mode, + RebalanceMode, ShareType, SignalProcessing, - SizingMethod, StatsConfig, - TrailHwmSource, # Deprecated alias for WaterMarkSource TrailStopTiming, WaterMarkSource, ) -from .config import ( - CommissionModel as CommissionModelType, -) -from .config import ( - SlippageModel as SlippageModelType, -) from .datafeed import DataFeed -from .engine import BacktestEngine, Engine, run_backtest +from .engine import Engine, run_backtest # Execution model (volume limits, market impact, rebalancing) from .execution import ( @@ -191,7 +175,6 @@ "Broker", "Strategy", "Engine", - "BacktestEngine", # Backward compatibility alias "run_backtest", "BacktestResult", "BacktestExporter", @@ -213,15 +196,12 @@ "FillTiming", "ExecutionPrice", "ShareType", - "SizingMethod", "FillOrdering", "SignalProcessing", - "TrailHwmSource", # Deprecated alias for WaterMarkSource + "RebalanceMode", "TrailStopTiming", "WaterMarkSource", "InitialHwmSource", - "CommissionModelType", - "SlippageModelType", "PRESETS_DIR", # Analytics "EquityCurve", @@ -232,9 +212,7 @@ "max_drawdown", "cagr", "volatility", - # Analysis (diagnostic integration) - "BacktestAnalyzer", - "TradeStatistics", + # Bridge (diagnostic integration) "to_trade_record", "to_trade_records", "to_returns_series", diff --git a/src/ml4t/backtest/accounting/policy.py b/src/ml4t/backtest/accounting/policy.py index 210690d5..0157beec 100644 --- a/src/ml4t/backtest/accounting/policy.py +++ b/src/ml4t/backtest/accounting/policy.py @@ -272,9 +272,6 @@ def __init__( def from_config(cls, config: BacktestConfig) -> UnifiedAccountPolicy: """Create policy from BacktestConfig. - Handles both new-style (allow_short_selling, allow_leverage) and - deprecated (account_type) configuration. - Args: config: BacktestConfig instance diff --git a/src/ml4t/backtest/analysis.py b/src/ml4t/backtest/analysis.py deleted file mode 100644 index efef6065..00000000 --- a/src/ml4t/backtest/analysis.py +++ /dev/null @@ -1,414 +0,0 @@ -"""Bridge ml4t.backtest results to ml4t.diagnostic for comprehensive analysis. - -.. deprecated:: 0.3.0 - This module is deprecated. Import from ml4t.backtest.analytics instead: - - to_trade_record, to_trade_records -> from ml4t.backtest.analytics.bridge - - TradeStatistics -> use ml4t.backtest.analytics.TradeAnalyzer - - BacktestAnalyzer -> use BacktestResult directly - -This module is kept for backward compatibility and re-exports from analytics. - -Example - New recommended approach: - >>> from ml4t.backtest import BacktestResult - >>> result = engine.run() # Returns BacktestResult - >>> trades_df = result.to_trades_dataframe() - >>> metrics = result.metrics - -Example - For diagnostic integration: - >>> from ml4t.backtest.analytics import to_trade_records, TradeAnalyzer - >>> records = to_trade_records(engine.broker.trades) - >>> analyzer = TradeAnalyzer(engine.broker.trades) -""" - -from __future__ import annotations - -import warnings -from typing import TYPE_CHECKING, Any - -import numpy as np -import polars as pl - -# Re-export from analytics.bridge for backward compatibility -from ml4t.backtest.analytics.bridge import ( - to_equity_dataframe, - to_returns_series, - to_trade_record, - to_trade_records, -) -from ml4t.backtest.types import Trade - -if TYPE_CHECKING: - from ml4t.backtest.engine import Engine - -# Emit deprecation warning on import -warnings.warn( - "ml4t.backtest.analysis is deprecated. Use ml4t.backtest.analytics instead.", - DeprecationWarning, - stacklevel=2, -) - -__all__ = [ - "to_trade_record", - "to_trade_records", - "to_returns_series", - "to_equity_dataframe", - "TradeStatistics", - "BacktestAnalyzer", -] - - -class TradeStatistics: - """Compute comprehensive trade statistics from backtest results. - - This provides the same metrics as ml4t.diagnostic.evaluation.TradeStatistics - but can be computed directly from backtest trades without the diagnostic - library dependency. - - For more advanced analysis (SHAP, clustering, hypothesis generation), - use the full diagnostic library via to_trade_records(). - - Attributes: - n_trades: Total number of completed trades - n_winners: Number of profitable trades - n_losers: Number of losing trades - win_rate: Fraction of winning trades - profit_factor: Gross profit / gross loss - avg_pnl: Mean P&L per trade - avg_winner: Average P&L of winning trades - avg_loser: Average P&L of losing trades - expectancy: Expected value per trade - avg_bars_held: Average holding period in bars - - Example: - >>> stats = TradeStatistics.from_trades(engine.broker.trades) - >>> print(stats.summary()) - """ - - def __init__( - self, - n_trades: int, - n_winners: int, - n_losers: int, - win_rate: float, - profit_factor: float | None, - total_pnl: float, - avg_pnl: float, - pnl_std: float, - avg_winner: float | None, - avg_loser: float | None, - max_winner: float, - max_loser: float, - avg_bars_held: float, - avg_pnl_percent: float, - total_commission: float, - total_slippage: float, - ): - self.n_trades = n_trades - self.n_winners = n_winners - self.n_losers = n_losers - self.win_rate = win_rate - self.profit_factor = profit_factor - self.total_pnl = total_pnl - self.avg_pnl = avg_pnl - self.pnl_std = pnl_std - self.avg_winner = avg_winner - self.avg_loser = avg_loser - self.max_winner = max_winner - self.max_loser = max_loser - self.avg_bars_held = avg_bars_held - self.avg_pnl_percent = avg_pnl_percent - self.total_commission = total_commission - self.total_slippage = total_slippage - - @property - def expectancy(self) -> float: - """Expected value per trade: win_rate * avg_winner - (1 - win_rate) * |avg_loser|""" - if self.avg_winner is None or self.avg_loser is None: - return self.avg_pnl - return self.win_rate * self.avg_winner + (1 - self.win_rate) * self.avg_loser - - @property - def payoff_ratio(self) -> float | None: - """Ratio of average winner to average loser (in absolute terms).""" - if self.avg_winner is None or self.avg_loser is None or self.avg_loser == 0: - return None - return self.avg_winner / abs(self.avg_loser) - - @classmethod - def from_trades(cls, trades: list[Trade]) -> TradeStatistics: - """Compute statistics from list of Trade objects. - - Args: - trades: List of completed trades from broker.trades - - Returns: - TradeStatistics instance with all computed metrics - """ - if not trades: - return cls( - n_trades=0, - n_winners=0, - n_losers=0, - win_rate=0.0, - profit_factor=None, - total_pnl=0.0, - avg_pnl=0.0, - pnl_std=0.0, - avg_winner=None, - avg_loser=None, - max_winner=0.0, - max_loser=0.0, - avg_bars_held=0.0, - avg_pnl_percent=0.0, - total_commission=0.0, - total_slippage=0.0, - ) - - pnls = np.array([t.pnl for t in trades]) - pnl_pcts = np.array([t.pnl_percent for t in trades]) - bars = np.array([t.bars_held for t in trades]) - commissions = np.array([t.fees for t in trades]) - slippages = np.array([t.slippage for t in trades]) - - n_trades = len(trades) - winners = pnls[pnls > 0] - losers = pnls[pnls < 0] - n_winners = len(winners) - n_losers = len(losers) - - win_rate = n_winners / n_trades if n_trades > 0 else 0.0 - total_pnl = float(pnls.sum()) - avg_pnl = float(pnls.mean()) - pnl_std = float(pnls.std()) if n_trades > 1 else 0.0 - - avg_winner = float(winners.mean()) if len(winners) > 0 else None - avg_loser = float(losers.mean()) if len(losers) > 0 else None - max_winner = float(pnls.max()) if n_trades > 0 else 0.0 - max_loser = float(pnls.min()) if n_trades > 0 else 0.0 - - gross_profit = float(winners.sum()) if len(winners) > 0 else 0.0 - gross_loss = abs(float(losers.sum())) if len(losers) > 0 else 0.0 - profit_factor = gross_profit / gross_loss if gross_loss > 0 else None - - avg_bars_held = float(bars.mean()) if n_trades > 0 else 0.0 - avg_pnl_percent = float(pnl_pcts.mean()) if n_trades > 0 else 0.0 - - return cls( - n_trades=n_trades, - n_winners=n_winners, - n_losers=n_losers, - win_rate=win_rate, - profit_factor=profit_factor, - total_pnl=total_pnl, - avg_pnl=avg_pnl, - pnl_std=pnl_std, - avg_winner=avg_winner, - avg_loser=avg_loser, - max_winner=max_winner, - max_loser=max_loser, - avg_bars_held=avg_bars_held, - avg_pnl_percent=avg_pnl_percent, - total_commission=float(commissions.sum()), - total_slippage=float(slippages.sum()), - ) - - def summary(self) -> str: - """Generate human-readable summary of trade statistics.""" - lines = [ - "Trade Statistics", - "=" * 50, - f"Total Trades: {self.n_trades}", - f"Winners: {self.n_winners} | Losers: {self.n_losers}", - f"Win Rate: {self.win_rate:.2%}", - "", - "P&L Metrics", - "-" * 50, - f"Total P&L: ${self.total_pnl:,.2f}", - f"Average P&L: ${self.avg_pnl:,.2f} (±${self.pnl_std:,.2f})", - f"Avg Return: {self.avg_pnl_percent:.2%}", - ] - - if self.avg_winner is not None: - lines.append(f"Avg Winner: ${self.avg_winner:,.2f}") - if self.avg_loser is not None: - lines.append(f"Avg Loser: ${self.avg_loser:,.2f}") - if self.profit_factor is not None: - lines.append(f"Profit Factor: {self.profit_factor:.2f}") - if self.payoff_ratio is not None: - lines.append(f"Payoff Ratio: {self.payoff_ratio:.2f}") - - lines.extend( - [ - f"Expectancy: ${self.expectancy:,.2f}", - f"Max Winner: ${self.max_winner:,.2f}", - f"Max Loser: ${self.max_loser:,.2f}", - "", - "Execution Metrics", - "-" * 50, - f"Avg Holding Period: {self.avg_bars_held:.1f} bars", - f"Total Commission: ${self.total_commission:,.2f}", - f"Total Slippage: ${self.total_slippage:,.2f}", - ] - ) - - return "\n".join(lines) - - def to_dict(self) -> dict[str, Any]: - """Export statistics as dictionary.""" - return { - "n_trades": self.n_trades, - "n_winners": self.n_winners, - "n_losers": self.n_losers, - "win_rate": self.win_rate, - "profit_factor": self.profit_factor, - "total_pnl": self.total_pnl, - "avg_pnl": self.avg_pnl, - "pnl_std": self.pnl_std, - "avg_winner": self.avg_winner, - "avg_loser": self.avg_loser, - "max_winner": self.max_winner, - "max_loser": self.max_loser, - "expectancy": self.expectancy, - "payoff_ratio": self.payoff_ratio, - "avg_bars_held": self.avg_bars_held, - "avg_pnl_percent": self.avg_pnl_percent, - "total_commission": self.total_commission, - "total_slippage": self.total_slippage, - } - - -class BacktestAnalyzer: - """High-level analyzer for backtest results. - - Provides convenient access to trade statistics and prepares data - for the diagnostic library. - - Example: - >>> engine = Engine(feed, strategy, initial_cash=100_000) - >>> result = engine.run() - >>> - >>> analyzer = BacktestAnalyzer(engine) - >>> print(analyzer.trade_statistics().summary()) - >>> - >>> # For advanced analysis with diagnostic library - >>> trade_records = analyzer.get_trade_records() - """ - - def __init__(self, engine: Engine): - """Initialize analyzer with completed engine. - - Args: - engine: Engine instance after run() has been called - """ - self.engine = engine - self.broker = engine.broker - self._trade_stats: TradeStatistics | None = None - - @property - def trades(self) -> list[Trade]: - """Get list of completed trades.""" - return self.broker.trades - - @property - def equity_history(self) -> list[float]: - """Get equity curve (list of portfolio values).""" - # Engine stores equity_curve as list of (timestamp, value) tuples - if hasattr(self.engine, "equity_curve"): - return [value for _, value in self.engine.equity_curve] - # Fallback for older broker interface - if hasattr(self.broker, "equity_history"): - equity: list[float] = getattr(self.broker, "equity_history", []) - return equity - return [] - - def trade_statistics(self) -> TradeStatistics: - """Compute comprehensive trade statistics. - - Returns: - TradeStatistics with all metrics - """ - if self._trade_stats is None: - self._trade_stats = TradeStatistics.from_trades(self.trades) - return self._trade_stats - - def get_trade_records(self) -> list[dict[str, Any]]: - """Get trades in diagnostic TradeRecord format. - - Returns: - List of dicts compatible with ml4t.diagnostic TradeRecord - """ - return to_trade_records(self.trades) - - def get_returns_series(self) -> pl.Series: - """Get returns as Polars Series for diagnostic analysis. - - Returns: - Series of period returns - """ - return to_returns_series(self.equity_history) - - def get_equity_dataframe(self) -> pl.DataFrame: - """Get equity curve as DataFrame. - - Returns: - DataFrame with equity and returns columns - """ - return to_equity_dataframe(self.equity_history) - - def get_trades_dataframe(self) -> pl.DataFrame: - """Get trades as Polars DataFrame for analysis. - - Returns: - DataFrame with one row per trade - """ - if not self.trades: - return pl.DataFrame() - - records = [] - for t in self.trades: - records.append( - { - "symbol": t.symbol, - "entry_time": t.entry_time, - "exit_time": t.exit_time, - "entry_price": t.entry_price, - "exit_price": t.exit_price, - "quantity": t.quantity, - "pnl": t.pnl, - "pnl_percent": t.pnl_percent, - "bars_held": t.bars_held, - "fees": t.fees, - "slippage": t.slippage, - "direction": t.direction, - "mfe": t.mfe, - "mae": t.mae, - } - ) - - return pl.DataFrame(records) - - def summary(self) -> str: - """Generate comprehensive backtest summary. - - Returns: - Formatted summary string - """ - stats = self.trade_statistics() - - # Get backtest-level metrics - equity = self.equity_history - initial = equity[0] if equity else 0 - final = equity[-1] if equity else 0 - total_return = (final - initial) / initial if initial > 0 else 0 - - lines = [ - "Backtest Summary", - "=" * 60, - f"Initial Capital: ${initial:,.2f}", - f"Final Value: ${final:,.2f}", - f"Total Return: {total_return:.2%}", - "", - stats.summary(), - ] - - return "\n".join(lines) diff --git a/src/ml4t/backtest/analytics/equity.py b/src/ml4t/backtest/analytics/equity.py index 88ded5e2..0b4f7f01 100644 --- a/src/ml4t/backtest/analytics/equity.py +++ b/src/ml4t/backtest/analytics/equity.py @@ -1,6 +1,5 @@ """Equity curve tracking and analysis.""" -import warnings from dataclasses import dataclass, field from datetime import datetime @@ -75,40 +74,6 @@ def years(self) -> float: """Duration in years based on trading days.""" return len(self.values) / TRADING_DAYS_PER_YEAR if self.values else 0.0 - def sharpe(self, risk_free_rate: float = 0.0) -> float: - """Annualized Sharpe ratio. - - .. deprecated:: - This method uses bar-level returns which gives incorrect results - for intraday data. Use ``result.compute_metrics()`` instead for - properly computed Sharpe ratio with daily returns. - """ - warnings.warn( - "EquityCurve.sharpe() uses bar-level returns which is incorrect for " - "intraday data. Use result.compute_metrics() instead for proper " - "Sharpe ratio with daily returns.", - DeprecationWarning, - stacklevel=2, - ) - return sharpe_ratio(self.returns, risk_free_rate) - - def sortino(self, risk_free_rate: float = 0.0) -> float: - """Annualized Sortino ratio. - - .. deprecated:: - This method uses bar-level returns which gives incorrect results - for intraday data. Use ``result.compute_metrics()`` instead for - properly computed Sortino ratio with daily returns. - """ - warnings.warn( - "EquityCurve.sortino() uses bar-level returns which is incorrect for " - "intraday data. Use result.compute_metrics() instead for proper " - "Sortino ratio with daily returns.", - DeprecationWarning, - stacklevel=2, - ) - return sortino_ratio(self.returns, risk_free_rate) - def max_drawdown_info(self) -> tuple[float, int, int]: """Maximum drawdown with peak/trough indices.""" return max_drawdown(self.values) @@ -124,24 +89,6 @@ def cagr(self) -> float: """Compound Annual Growth Rate.""" return cagr(self.initial_value, self.final_value, self.years) - @property - def calmar(self) -> float: - """Calmar ratio (CAGR / Max Drawdown). - - .. deprecated:: - This property assumes bars=days for CAGR calculation, which is - incorrect for intraday data. Use ``result.compute_metrics()`` - instead for properly computed Calmar ratio. - """ - warnings.warn( - "EquityCurve.calmar assumes bars=days for CAGR, which is incorrect for " - "intraday data. Use result.compute_metrics() instead for proper " - "Calmar ratio.", - DeprecationWarning, - stacklevel=2, - ) - return calmar_ratio(self.cagr, self.max_dd) - @property def volatility(self) -> float: """Annualized volatility.""" @@ -162,10 +109,10 @@ def to_dict(self) -> dict: "final_value": self.final_value, "total_return": self.total_return, "cagr": self.cagr, - "sharpe": self.sharpe(), - "sortino": self.sortino(), + "sharpe": sharpe_ratio(self.returns), + "sortino": sortino_ratio(self.returns), "max_drawdown": self.max_dd, - "calmar": self.calmar, + "calmar": calmar_ratio(self.cagr, self.max_dd), "volatility": self.volatility, "trading_days": len(self.values), "years": self.years, diff --git a/src/ml4t/backtest/broker.py b/src/ml4t/backtest/broker.py index 4231eb3c..1c7e5a88 100644 --- a/src/ml4t/backtest/broker.py +++ b/src/ml4t/backtest/broker.py @@ -42,9 +42,6 @@ Trade, ) -# Backward compatibility -TrailHwmSource = WaterMarkSource - if TYPE_CHECKING: from .accounting.policy import AccountPolicy from .config import BacktestConfig @@ -1660,7 +1657,6 @@ def _update_water_marks(self): # VBT Pro only updates water marks from bar extremes on the bar AFTER entry is_new_position = asset in self._positions_created_this_bar # BAR_EXTREME: use HIGH for HWM (longs), LOW for LWM (shorts) - # Note: HIGH is a deprecated alias for BAR_EXTREME use_extremes = self.trail_hwm_source.value == "bar_extreme" and not is_new_position pos.update_water_marks( current_price=self._current_prices[asset], diff --git a/src/ml4t/backtest/config.py b/src/ml4t/backtest/config.py index 85e88f5c..0831c87e 100644 --- a/src/ml4t/backtest/config.py +++ b/src/ml4t/backtest/config.py @@ -53,21 +53,6 @@ class ShareType(str, Enum): INTEGER = "integer" # Round down to whole shares (like most real brokers) -class SizingMethod(str, Enum): - """How position size is calculated. - - .. deprecated:: - This enum is not consumed by any runtime code. Position sizing is - always determined by strategy code. Retained for serialization - backward compatibility only. - """ - - PERCENT_OF_PORTFOLIO = "percent_of_portfolio" # % of total portfolio value - PERCENT_OF_CASH = "percent_of_cash" # % of available cash only - FIXED_VALUE = "fixed_value" # Fixed dollar amount per position - FIXED_SHARES = "fixed_shares" # Fixed number of shares - - class FillOrdering(str, Enum): """Order processing sequence within a single bar. @@ -88,6 +73,38 @@ class FillOrdering(str, Enum): FIFO = "fifo" +class RebalanceMode(str, Enum): + """How portfolio value is computed during multi-asset rebalancing. + + When rebalancing across multiple assets, the engine must decide whether + to recompute portfolio value after each fill or freeze it. Real brokers + differ: some snapshot account value at order placement, others update + incrementally as each fill settles. + + SNAPSHOT: + Freeze portfolio value at the start of the rebalance. All targets + computed from the same base, orders batch and fill at once. Matches + Backtrader's ``order_target_percent`` in ``next()`` where + ``broker.getvalue()`` is constant across all submissions. + + INCREMENTAL: + Recompute portfolio value after each asset's order fills. Most + accurate cash tracking — each target uses the latest portfolio + state. May produce more trades than SNAPSHOT because cascading + value changes create small corrections. + + HYBRID: + Freeze portfolio value for target computation, but fill + sequentially (cash constraints checked against live state). + Matches VectorBT's default behavior with ``auto_call_seq=False`` + and ``update_value=False``. + """ + + SNAPSHOT = "snapshot" + INCREMENTAL = "incremental" + HYBRID = "hybrid" + + class SignalProcessing(str, Enum): """How signals are processed relative to existing positions.""" @@ -144,13 +161,6 @@ class WaterMarkSource(str, Enum): CLOSE = "close" # Use close prices for water mark updates (default) BAR_EXTREME = "bar_extreme" # Use HIGH for HWM, LOW for LWM (VBT Pro with OHLC) - # Deprecated alias for backward compatibility - HIGH = "bar_extreme" # Deprecated: use BAR_EXTREME instead - - -# Backward compatibility alias -TrailHwmSource = WaterMarkSource - class InitialHwmSource(str, Enum): """Source for initial high-water mark on position entry. @@ -447,6 +457,7 @@ def get_effective_account_type(self) -> str: reject_on_insufficient_cash: bool = True partial_fills_allowed: bool = False fill_ordering: FillOrdering = FillOrdering.EXIT_FIRST + rebalance_mode: RebalanceMode = RebalanceMode.SNAPSHOT # === Calendar & Timezone === calendar: str | None = None # Exchange calendar (e.g., "NYSE", "CME_Equity", "LSE") @@ -509,6 +520,7 @@ def to_dict(self) -> dict: "reject_on_insufficient_cash": self.reject_on_insufficient_cash, "partial_fills_allowed": self.partial_fills_allowed, "fill_ordering": self.fill_ordering.value, + "rebalance_mode": self.rebalance_mode.value, }, } @@ -525,35 +537,14 @@ def from_dict(cls, data: dict, preset_name: str | None = None) -> BacktestConfig cash_cfg = data.get("cash", {}) order_cfg = data.get("orders", {}) - # Handle legacy account type for migration - legacy_type = acct_cfg.get("type") - legacy_margin_req = acct_cfg.get("margin_requirement") - - # Determine account settings from new or legacy fields - if "allow_short_selling" in acct_cfg: - # New format - allow_short_selling = acct_cfg.get("allow_short_selling", False) - allow_leverage = acct_cfg.get("allow_leverage", False) - elif legacy_type is not None: - # Convert legacy format to new flags - if legacy_type == "cash": - allow_short_selling, allow_leverage = False, False - elif legacy_type == "crypto": - allow_short_selling, allow_leverage = True, False - elif legacy_type == "margin": - allow_short_selling, allow_leverage = True, True - else: - raise ValueError(f"Unknown account type: '{legacy_type}'") - else: - # Default - allow_short_selling = False - allow_leverage = False + allow_short_selling = acct_cfg.get("allow_short_selling", False) + allow_leverage = acct_cfg.get("allow_leverage", False) return cls( # Account allow_short_selling=allow_short_selling, allow_leverage=allow_leverage, - initial_margin=acct_cfg.get("initial_margin", legacy_margin_req or 0.5), + initial_margin=acct_cfg.get("initial_margin", 0.5), long_maintenance_margin=acct_cfg.get("long_maintenance_margin", 0.25), short_maintenance_margin=acct_cfg.get("short_maintenance_margin", 0.30), fixed_margin_schedule=acct_cfg.get("fixed_margin_schedule"), @@ -593,6 +584,7 @@ def from_dict(cls, data: dict, preset_name: str | None = None) -> BacktestConfig reject_on_insufficient_cash=order_cfg.get("reject_on_insufficient_cash", True), partial_fills_allowed=order_cfg.get("partial_fills_allowed", False), fill_ordering=FillOrdering(order_cfg.get("fill_ordering", "exit_first")), + rebalance_mode=RebalanceMode(order_cfg.get("rebalance_mode", "snapshot")), # Metadata preset_name=preset_name, ) @@ -671,6 +663,7 @@ def _default_preset(cls) -> BacktestConfig: reject_on_insufficient_cash=True, partial_fills_allowed=False, fill_ordering=FillOrdering.EXIT_FIRST, + rebalance_mode=RebalanceMode.INCREMENTAL, ) @classmethod @@ -718,6 +711,7 @@ def _backtrader_preset(cls) -> BacktestConfig: reject_on_insufficient_cash=True, partial_fills_allowed=False, fill_ordering=FillOrdering.FIFO, # Backtrader processes in submission order + rebalance_mode=RebalanceMode.SNAPSHOT, # BT batches all orders before filling ) @classmethod @@ -764,6 +758,7 @@ def _vectorbt_preset(cls) -> BacktestConfig: reject_on_insufficient_cash=False, # VectorBT is more permissive partial_fills_allowed=True, fill_ordering=FillOrdering.EXIT_FIRST, # VBT call_seq='auto' + rebalance_mode=RebalanceMode.HYBRID, # Frozen targets, sequential fills ) @classmethod @@ -809,6 +804,7 @@ def _zipline_preset(cls) -> BacktestConfig: reject_on_insufficient_cash=True, partial_fills_allowed=True, # Volume-based = partial fills fill_ordering=FillOrdering.EXIT_FIRST, + rebalance_mode=RebalanceMode.SNAPSHOT, # Zipline batches orders in handle_data ) @classmethod @@ -854,6 +850,7 @@ def _realistic_preset(cls) -> BacktestConfig: reject_on_insufficient_cash=True, partial_fills_allowed=False, fill_ordering=FillOrdering.EXIT_FIRST, + rebalance_mode=RebalanceMode.INCREMENTAL, ) def describe(self) -> str: @@ -916,6 +913,7 @@ def describe(self) -> str: "", "Orders:", f" Fill ordering: {self.fill_ordering.value}", + f" Rebalance mode: {self.rebalance_mode.value}", f" Reject insufficient: {self.reject_on_insufficient_cash}", f" Partial fills: {self.partial_fills_allowed}", "", diff --git a/src/ml4t/backtest/engine.py b/src/ml4t/backtest/engine.py index 4e5342eb..44038af9 100644 --- a/src/ml4t/backtest/engine.py +++ b/src/ml4t/backtest/engine.py @@ -8,6 +8,7 @@ import polars as pl from .analytics import EquityCurve, TradeAnalyzer +from .analytics.metrics import calmar_ratio, sharpe_ratio, sortino_ratio from .broker import Broker from .config import InitialHwmSource, Mode, TrailStopTiming, WaterMarkSource from .datafeed import DataFeed @@ -287,9 +288,9 @@ def _generate_results(self) -> BacktestResult: "total_commission": sum(f.commission for f in self.broker.fills), "total_slippage": sum(f.slippage for f in self.broker.fills), # Additional metrics - "sharpe": equity.sharpe(), - "sortino": equity.sortino(), - "calmar": equity.calmar, + "sharpe": sharpe_ratio(equity.returns), + "sortino": sortino_ratio(equity.returns), + "calmar": calmar_ratio(equity.cagr, equity.max_dd), "cagr": equity.cagr, "volatility": equity.volatility, "profit_factor": trade_analyzer.profit_factor, @@ -484,7 +485,3 @@ def run_backtest( execution_mode=execution_mode, ) return engine.run() - - -# Backward compatibility: BacktestEngine was renamed to Engine in v0.2.0 -BacktestEngine = Engine diff --git a/src/ml4t/backtest/execution/fill_executor.py b/src/ml4t/backtest/execution/fill_executor.py index 91526a27..7eaf592a 100644 --- a/src/ml4t/backtest/execution/fill_executor.py +++ b/src/ml4t/backtest/execution/fill_executor.py @@ -27,12 +27,7 @@ def _get_exit_reason(order: Order) -> str: - """Get exit reason from order, preferring typed enum over string parsing. - - Priority: - 1. order._exit_reason (ExitReason enum) - preferred, set by broker - 2. order._risk_exit_reason (str) - legacy, parsed for backward compatibility - 3. ExitReason.SIGNAL - default for strategy-initiated exits + """Get exit reason from order. Args: order: Order with exit reason metadata @@ -40,28 +35,9 @@ def _get_exit_reason(order: Order) -> str: Returns: ExitReason enum value as string """ - # Prefer typed enum if available if order._exit_reason is not None: return order._exit_reason.value - - # Fall back to string parsing for backward compatibility - reason = order._risk_exit_reason - if reason is None: - return ExitReason.SIGNAL.value - - reason_lower = reason.lower() - if "stop_loss" in reason_lower: - return ExitReason.STOP_LOSS.value - elif "take_profit" in reason_lower: - return ExitReason.TAKE_PROFIT.value - elif "trailing" in reason_lower: - return ExitReason.TRAILING_STOP.value - elif "time" in reason_lower: - return ExitReason.TIME_STOP.value - elif "end_of_data" in reason_lower: - return ExitReason.END_OF_DATA.value - else: - return ExitReason.SIGNAL.value + return ExitReason.SIGNAL.value @dataclass diff --git a/src/ml4t/backtest/execution/rebalancer.py b/src/ml4t/backtest/execution/rebalancer.py index deac260f..daa13caf 100644 --- a/src/ml4t/backtest/execution/rebalancer.py +++ b/src/ml4t/backtest/execution/rebalancer.py @@ -24,7 +24,7 @@ from ..broker import Broker from ..types import Order -from ..config import ShareType +from ..config import RebalanceMode, ShareType from ..types import OrderSide @@ -50,6 +50,10 @@ class RebalanceConfig: max_single_weight: Maximum weight allowed for any single asset. 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. + SNAPSHOT (default): Freeze value, batch fills (backward compatible). + INCREMENTAL: Recompute value after each fill (most accurate). + HYBRID: Frozen targets, sequential fills (VBT-style). """ # Trade thresholds @@ -68,6 +72,7 @@ class RebalanceConfig: # Order handling cancel_before_rebalance: bool = True account_for_pending: bool = True + rebalance_mode: RebalanceMode = RebalanceMode.SNAPSHOT class TargetWeightExecutor: @@ -107,6 +112,15 @@ def execute( ) -> list["Order"]: """Execute rebalancing to target weights. + Behavior depends on ``self.config.rebalance_mode``: + + - **SNAPSHOT** (default): Compute portfolio value once, submit all orders, + fill at once. Backward compatible — matches the pre-v0.18 behavior. + - **INCREMENTAL**: Recompute portfolio value after each fill. Most accurate + cash tracking. Each target uses the latest portfolio state. + - **HYBRID**: Freeze portfolio value for target computation, but fill + sequentially (cash constraints checked against live state). + Args: target_weights: Dict of asset -> target weight (0.0 to 1.0). Sum can be < 1.0 to hold cash. @@ -126,6 +140,7 @@ def execute( return [] orders: list[Order] = [] + mode = self.config.rebalance_mode # 2. Get current weights (effective or actual based on config) if self.config.account_for_pending and not self.config.cancel_before_rebalance: @@ -148,6 +163,15 @@ def execute( if order is not None: orders.append(order) + # INCREMENTAL / HYBRID: fill after each asset + if mode in (RebalanceMode.INCREMENTAL, RebalanceMode.HYBRID) and order is not None: + broker._process_orders() + + # INCREMENTAL: recompute equity and weights from updated state + if mode == RebalanceMode.INCREMENTAL: + equity = broker.get_account_value() + current_weights = self._get_current_weights(broker, data) + # 5. Close positions not in target for asset in current_weights: if asset not in target_weights: @@ -157,6 +181,12 @@ def execute( if close_order: orders.append(close_order) + # Process close orders immediately for INCREMENTAL/HYBRID + if mode in (RebalanceMode.INCREMENTAL, RebalanceMode.HYBRID): + broker._process_orders() + if mode == RebalanceMode.INCREMENTAL: + equity = broker.get_account_value() + return orders def _process_asset( diff --git a/src/ml4t/backtest/result.py b/src/ml4t/backtest/result.py index d5c98cd5..3c632ac2 100644 --- a/src/ml4t/backtest/result.py +++ b/src/ml4t/backtest/result.py @@ -22,7 +22,6 @@ from __future__ import annotations import json -import warnings from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -735,78 +734,6 @@ def _equity_schema() -> dict[str, pl.DataType]: "high_water_mark": pl.Float64(), } - # --- Backward compatibility: dict-like access --- - - def __getitem__(self, key: str) -> Any: - """Allow dictionary-style access for backward compatibility. - - .. deprecated:: 0.3.0 - Dict-style access (result["key"]) is deprecated and will be removed - in a future version. Use direct attribute access instead: - - result.trades instead of result["trades"] - - result.metrics["sharpe"] instead of result["sharpe"] - - Example: - result["sharpe"] # Same as result.metrics["sharpe"] - result["trades"] # Same as result.trades - """ - warnings.warn( - "Dict-style access (result['key']) is deprecated. " - "Use result.trades, result.metrics['sharpe'], etc. instead.", - DeprecationWarning, - stacklevel=2, - ) - # Special keys that map to attributes - attr_map = { - "trades": self.trades, - "equity_curve": self.equity_curve, - "fills": self.fills, - "equity": self.equity, - "trade_analyzer": self.trade_analyzer, - } - if key in attr_map: - return attr_map[key] - # Everything else from metrics - return self.metrics[key] - - def __contains__(self, key: str) -> bool: - """Support 'key in result' checks. - - .. deprecated:: 0.3.0 - Use hasattr() or check result.metrics directly. - """ - warnings.warn( - "'key in result' is deprecated. Use hasattr() or check result.metrics.", - DeprecationWarning, - stacklevel=2, - ) - if key in ("trades", "equity_curve", "fills", "equity", "trade_analyzer"): - return True - return key in self.metrics - - def get(self, key: str, default: Any = None) -> Any: - """Dict-like get() for backward compatibility. - - .. deprecated:: 0.3.0 - Use direct attribute access or result.metrics.get(). - """ - warnings.warn( - "result.get() is deprecated. Use result.metrics.get() instead.", - DeprecationWarning, - stacklevel=2, - ) - # Direct lookup to avoid double deprecation warning from __getitem__ - attr_map = { - "trades": self.trades, - "equity_curve": self.equity_curve, - "fills": self.fills, - "equity": self.equity, - "trade_analyzer": self.trade_analyzer, - } - if key in attr_map: - return attr_map[key] - return self.metrics.get(key, default) - def to_tearsheet( self, template: Literal["quant_trader", "hedge_fund", "risk_manager", "full"] = "full", diff --git a/src/ml4t/backtest/types.py b/src/ml4t/backtest/types.py index 7229bdb5..a0374ff9 100644 --- a/src/ml4t/backtest/types.py +++ b/src/ml4t/backtest/types.py @@ -214,11 +214,6 @@ def __post_init__(self): if self.current_price is None: self.current_price = self.entry_price - @property - def avg_entry_price(self) -> float: - """Alias for entry_price (accounting compatibility).""" - return self.entry_price - @property def market_value(self) -> float: """Current market value of the position. @@ -390,38 +385,11 @@ def direction(self) -> str: """Return 'long' or 'short' based on quantity sign.""" return "long" if self.quantity > 0 else "short" - @property - def side(self) -> str: - """Alias for direction (backward compatibility).""" - return self.direction - @property def is_open(self) -> bool: """Return True if this is an open (mark-to-market) trade.""" return self.status == "open" - # === Backward compatibility aliases (deprecated) === - - @property - def asset(self) -> str: - """Deprecated: Use 'symbol' instead.""" - return self.symbol - - @property - def commission(self) -> float: - """Deprecated: Use 'fees' instead.""" - return self.fees - - @property - def max_favorable_excursion(self) -> float: - """Deprecated: Use 'mfe' instead.""" - return self.mfe - - @property - def max_adverse_excursion(self) -> float: - """Deprecated: Use 'mae' instead.""" - return self.mae - @dataclass class PartialExit: diff --git a/tests/accounting/test_account_state.py b/tests/accounting/test_account_state.py index 1d966ceb..39e514d1 100644 --- a/tests/accounting/test_account_state.py +++ b/tests/accounting/test_account_state.py @@ -31,7 +31,7 @@ def test_open_long_position(self): assert "AAPL" in account.positions pos = account.positions["AAPL"] assert pos.quantity == 100.0 - assert pos.avg_entry_price == 150.0 + assert pos.entry_price == 150.0 def test_add_to_long_position(self): """Test adding to existing long position updates cost basis.""" @@ -48,7 +48,7 @@ def test_add_to_long_position(self): pos = account.positions["AAPL"] assert pos.quantity == 150.0 # Weighted average: (100×150 + 50×160) / 150 = 23,000 / 150 = 153.33 - assert abs(pos.avg_entry_price - 153.333) < 0.01 + assert abs(pos.entry_price - 153.333) < 0.01 def test_close_long_position(self): """Test closing long position increases cash.""" @@ -78,7 +78,7 @@ def test_partial_close_long(self): assert account.cash == 94_600.0 # 100k - 15k + 9.6k pos = account.positions["AAPL"] assert pos.quantity == 40.0 # 100 - 60 - assert pos.avg_entry_price == 150.0 # Unchanged for partial close + assert pos.entry_price == 150.0 # Unchanged for partial close class TestAccountStateApplyFillShortPositions: @@ -108,7 +108,7 @@ def test_open_short_position_increases_cash(self): assert "AAPL" in account.positions pos = account.positions["AAPL"] assert pos.quantity == -100.0 # Negative quantity - assert pos.avg_entry_price == 150.0 + assert pos.entry_price == 150.0 assert pos.market_value == -15_000.0 # Negative market value (liability) def test_add_to_short_position_updates_cost_basis(self): @@ -130,7 +130,7 @@ def test_add_to_short_position_updates_cost_basis(self): pos = account.positions["AAPL"] assert pos.quantity == -150.0 # Total short position # Weighted average: (100×150 + 50×160) / 150 = 23,000 / 150 = 153.33 - assert abs(pos.avg_entry_price - 153.333) < 0.01 + assert abs(pos.entry_price - 153.333) < 0.01 def test_close_short_position_decreases_cash(self): """Test covering short position decreases cash. @@ -178,7 +178,7 @@ def test_partial_close_short(self): assert account.cash == 109_200.0 # 100k + 15k - 5.8k pos = account.positions["AAPL"] assert pos.quantity == -60.0 # Still short 60 - assert pos.avg_entry_price == 150.0 # Unchanged for partial close + assert pos.entry_price == 150.0 # Unchanged for partial close def test_short_position_market_value_negative(self): """Test that short position market value is negative (liability). @@ -227,7 +227,7 @@ def test_reversal_long_to_short(self): assert account.cash == 117_000.0 # 85k + 32k pos = account.positions["AAPL"] assert pos.quantity == -100.0 # Now short 100 - assert pos.avg_entry_price == 160.0 # New entry price for short + assert pos.entry_price == 160.0 # New entry price for short def test_reversal_short_to_long(self): """Test reversing from short to long position.""" @@ -245,7 +245,7 @@ def test_reversal_short_to_long(self): assert account.cash == 86_000.0 # 115k - 29k pos = account.positions["AAPL"] assert pos.quantity == 100.0 # Now long 100 - assert pos.avg_entry_price == 145.0 # New entry price for long + assert pos.entry_price == 145.0 # New entry price for long class TestAccountStateApplyFillEquityCalculation: @@ -342,7 +342,7 @@ def test_fractional_shares(self): assert account.cash == 75_000.0 # 100k - 25k pos = account.positions["BTC"] assert pos.quantity == 0.5 - assert pos.avg_entry_price == 50_000.0 + assert pos.entry_price == 50_000.0 def test_very_small_price(self): """Test with very small prices (penny stocks, crypto).""" @@ -355,7 +355,7 @@ def test_very_small_price(self): assert account.cash == 99_900.0 # 100k - 100 pos = account.positions["PENNY"] assert pos.quantity == 10_000.0 - assert pos.avg_entry_price == 0.01 + assert pos.entry_price == 0.01 if __name__ == "__main__": diff --git a/tests/accounting/test_position.py b/tests/accounting/test_position.py index fb716b06..5bb68738 100644 --- a/tests/accounting/test_position.py +++ b/tests/accounting/test_position.py @@ -23,7 +23,7 @@ def test_long_position_creation(self): assert pos.asset == "AAPL" assert pos.quantity == 100.0 - assert pos.avg_entry_price == 150.0 + assert pos.entry_price == 150.0 assert pos.current_price == 150.0 assert pos.bars_held == 0 @@ -103,7 +103,7 @@ def test_short_position_creation(self): ) assert pos.quantity == -100.0 - assert pos.avg_entry_price == 150.0 + assert pos.entry_price == 150.0 def test_short_position_market_value_is_negative(self): """Test that short positions have negative market value (liability).""" @@ -316,11 +316,10 @@ def test_mark_to_market_updates_unrealized_pnl(self): class TestPositionCostBasisTracking: """Tests for weighted average cost basis tracking.""" - def test_avg_entry_price_represents_cost_basis(self): - """Test that avg_entry_price is the cost basis.""" - # This test documents that entry_price (via avg_entry_price property) - # is used for cost basis. Updates are done externally - # (e.g., by AccountState when adding to a position) + def test_entry_price_represents_cost_basis(self): + """Test that entry_price is the cost basis.""" + # This test documents that entry_price is used for cost basis. + # Updates are done externally (e.g., by AccountState when adding to a position) pos = Position( asset="AAPL", @@ -330,8 +329,7 @@ def test_avg_entry_price_represents_cost_basis(self): entry_time=datetime.now(), ) - # Verify cost basis is tracked (avg_entry_price is alias for entry_price) - assert pos.avg_entry_price == 150.0 + # Verify cost basis is tracked assert pos.entry_price == 150.0 # Simulating adding 100 shares at $160 @@ -340,8 +338,8 @@ def test_avg_entry_price_represents_cost_basis(self): pos.entry_price = 155.0 pos.quantity = 200.0 - # Verify avg_entry_price property reflects updated entry_price - assert pos.avg_entry_price == 155.0 + # Verify entry_price reflects updated cost basis + assert pos.entry_price == 155.0 # Now verify P&L is based on weighted average pos.current_price = 160.0 diff --git a/tests/regression/test_golden_strategies.py b/tests/regression/test_golden_strategies.py index feda44b9..dd06f32b 100644 --- a/tests/regression/test_golden_strategies.py +++ b/tests/regression/test_golden_strategies.py @@ -389,7 +389,7 @@ def test_execution_fingerprint(self): # Add trade details if available if result.trades: fingerprint_data["first_trade_entry"] = round(result.trades[0].entry_price, 2) - fingerprint_data["first_trade_commission"] = round(result.trades[0].commission, 4) + fingerprint_data["first_trade_commission"] = round(result.trades[0].fees, 4) # Create hash of fingerprint fingerprint_str = json.dumps(fingerprint_data, sort_keys=True) diff --git a/tests/test_analysis.py b/tests/test_analysis.py index 31e27030..829a610d 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -1,4 +1,4 @@ -"""Tests for the analysis module - trade statistics and backtest analysis.""" +"""Tests for the analytics bridge module - trade record conversion and utility functions.""" from datetime import datetime, timedelta @@ -6,10 +6,7 @@ import polars as pl import pytest -from ml4t.backtest import DataFeed, Engine, Strategy -from ml4t.backtest.analysis import ( - BacktestAnalyzer, - TradeStatistics, +from ml4t.backtest.analytics.bridge import ( to_equity_dataframe, to_returns_series, to_trade_record, @@ -271,347 +268,3 @@ def test_returns_calculation(self, sample_equity_curve: list[float]): # Second return: (101000 - 100000) / 100000 = 0.01 assert pytest.approx(df["returns"][1], rel=1e-4) == 0.01 - - -# === Tests for TradeStatistics === - - -class TestTradeStatistics: - """Tests for trade statistics computation.""" - - def test_from_empty_trades(self): - """Test statistics from empty trade list.""" - stats = TradeStatistics.from_trades([]) - - assert stats.n_trades == 0 - assert stats.n_winners == 0 - assert stats.n_losers == 0 - assert stats.win_rate == 0.0 - assert stats.profit_factor is None - assert stats.avg_winner is None - assert stats.avg_loser is None - assert stats.total_pnl == 0.0 - - def test_single_winning_trade(self, sample_winning_trade: Trade): - """Test statistics from single winning trade.""" - stats = TradeStatistics.from_trades([sample_winning_trade]) - - assert stats.n_trades == 1 - assert stats.n_winners == 1 - assert stats.n_losers == 0 - assert stats.win_rate == 1.0 - assert stats.avg_winner == 1000.0 - assert stats.avg_loser is None # No losers - assert stats.total_pnl == 1000.0 - assert stats.pnl_std == 0.0 # Single trade, no std - - def test_single_losing_trade(self, sample_losing_trade: Trade): - """Test statistics from single losing trade.""" - stats = TradeStatistics.from_trades([sample_losing_trade]) - - assert stats.n_trades == 1 - assert stats.n_winners == 0 - assert stats.n_losers == 1 - assert stats.win_rate == 0.0 - assert stats.avg_winner is None # No winners - assert stats.avg_loser == -1000.0 - assert stats.total_pnl == -1000.0 - - def test_mixed_trades(self, mixed_trades: list[Trade]): - """Test statistics from mixed trades.""" - stats = TradeStatistics.from_trades(mixed_trades) - - assert stats.n_trades == 3 - assert stats.n_winners == 2 # winning_trade + short_trade - assert stats.n_losers == 1 - assert pytest.approx(stats.win_rate, rel=1e-4) == 2 / 3 - assert stats.total_pnl == 1000.0 # 1000 + (-1000) + 1000 - - def test_expectancy_calculation(self, mixed_trades: list[Trade]): - """Test expectancy property calculation.""" - stats = TradeStatistics.from_trades(mixed_trades) - - # expectancy = win_rate * avg_winner + (1 - win_rate) * avg_loser - expected = stats.win_rate * stats.avg_winner + (1 - stats.win_rate) * stats.avg_loser - assert pytest.approx(stats.expectancy, rel=1e-4) == expected - - def test_expectancy_no_losers(self, sample_winning_trade: Trade): - """Test expectancy when no losers (falls back to avg_pnl).""" - stats = TradeStatistics.from_trades([sample_winning_trade]) - - # With no losers, expectancy falls back to avg_pnl - assert stats.expectancy == stats.avg_pnl - - def test_payoff_ratio_calculation(self, mixed_trades: list[Trade]): - """Test payoff ratio (avg_winner / |avg_loser|).""" - stats = TradeStatistics.from_trades(mixed_trades) - - expected = stats.avg_winner / abs(stats.avg_loser) - assert pytest.approx(stats.payoff_ratio, rel=1e-4) == expected - - def test_payoff_ratio_no_losers(self, sample_winning_trade: Trade): - """Test payoff ratio when no losers.""" - stats = TradeStatistics.from_trades([sample_winning_trade]) - - assert stats.payoff_ratio is None - - def test_profit_factor_calculation(self, mixed_trades: list[Trade]): - """Test profit factor (gross_profit / gross_loss).""" - stats = TradeStatistics.from_trades(mixed_trades) - - # Gross profit: 1000 + 1000 = 2000 - # Gross loss: |-1000| = 1000 - assert pytest.approx(stats.profit_factor, rel=1e-4) == 2.0 - - def test_profit_factor_no_losers(self, sample_winning_trade: Trade): - """Test profit factor when no losers.""" - stats = TradeStatistics.from_trades([sample_winning_trade]) - - assert stats.profit_factor is None - - def test_summary_format(self, mixed_trades: list[Trade]): - """Test summary string generation.""" - stats = TradeStatistics.from_trades(mixed_trades) - summary = stats.summary() - - assert "Trade Statistics" in summary - assert "Total Trades: 3" in summary - assert "Win Rate:" in summary - assert "Profit Factor:" in summary - assert "Expectancy:" in summary - - def test_summary_no_trades(self): - """Test summary with no trades.""" - stats = TradeStatistics.from_trades([]) - summary = stats.summary() - - assert "Total Trades: 0" in summary - assert "Win Rate: 0.00%" in summary - - def test_to_dict(self, mixed_trades: list[Trade]): - """Test dictionary export.""" - stats = TradeStatistics.from_trades(mixed_trades) - d = stats.to_dict() - - assert d["n_trades"] == 3 - assert d["win_rate"] == stats.win_rate - assert d["expectancy"] == stats.expectancy - assert d["payoff_ratio"] == stats.payoff_ratio - assert "total_commission" in d - assert "total_slippage" in d - - def test_commission_slippage_totals(self, mixed_trades: list[Trade]): - """Test commission and slippage totals.""" - stats = TradeStatistics.from_trades(mixed_trades) - - # Sum of commissions: 10 + 8 + 12 = 30 - assert stats.total_commission == 30.0 - # Sum of slippage: 5 + 4 + 6 = 15 - assert stats.total_slippage == 15.0 - - def test_max_winner_loser(self, mixed_trades: list[Trade]): - """Test max winner and max loser.""" - stats = TradeStatistics.from_trades(mixed_trades) - - assert stats.max_winner == 1000.0 # Both winners have pnl=1000 - assert stats.max_loser == -1000.0 - - -# === Tests for BacktestAnalyzer === - - -def generate_simple_prices(start: datetime, periods: int) -> pl.DataFrame: - """Generate simple uptrending price data.""" - rows = [] - for i in range(periods): - ts = start + timedelta(days=i) - price = 100 * (1 + 0.01 * i) # 1% daily return - rows.append( - { - "timestamp": ts, - "asset": "SPY", - "open": price * 0.999, - "high": price * 1.01, - "low": price * 0.99, - "close": price, - "volume": 1000000.0, - } - ) - return pl.DataFrame(rows) - - -class SimpleBuyAndHoldStrategy(Strategy): - """Simple buy and hold for testing.""" - - def __init__(self, asset: str = "SPY"): - self.asset = asset - self.bought = False - - def on_data(self, timestamp, data, context, broker): - if not self.bought and self.asset in data: - price = data[self.asset]["close"] - qty = int(broker.account.cash * 0.95 / price) - if qty > 0: - broker.submit_order(self.asset, qty) - self.bought = True - - -class SimpleLongShortStrategy(Strategy): - """Strategy that creates trades for testing.""" - - def __init__(self): - self.bar_count = 0 - self.position = 0 - - def on_data(self, timestamp, data, context, broker): - self.bar_count += 1 - if "SPY" not in data: - return - - # Buy on bar 2 - if self.bar_count == 2 and self.position == 0: - broker.submit_order("SPY", 100) - self.position = 100 - - # Sell on bar 6 - if self.bar_count == 6 and self.position > 0: - broker.submit_order("SPY", -100) - self.position = 0 - - -class TestBacktestAnalyzer: - """Tests for BacktestAnalyzer class.""" - - @pytest.fixture - def completed_engine(self) -> Engine: - """Engine that has completed a backtest with trades.""" - prices = generate_simple_prices(datetime(2024, 1, 1), 10) - feed = DataFeed(prices_df=prices) - strategy = SimpleLongShortStrategy() - engine = Engine(feed, strategy, initial_cash=100000) - engine.run() - return engine - - @pytest.fixture - def buy_hold_engine(self) -> Engine: - """Engine with buy and hold (no closed trades).""" - prices = generate_simple_prices(datetime(2024, 1, 1), 10) - feed = DataFeed(prices_df=prices) - strategy = SimpleBuyAndHoldStrategy() - engine = Engine(feed, strategy, initial_cash=100000) - engine.run() - return engine - - def test_initialization(self, completed_engine: Engine): - """Test analyzer initialization.""" - analyzer = BacktestAnalyzer(completed_engine) - - assert analyzer.engine is completed_engine - assert analyzer.broker is completed_engine.broker - assert analyzer._trade_stats is None # Not computed yet - - def test_trades_property(self, completed_engine: Engine): - """Test trades property returns broker trades.""" - analyzer = BacktestAnalyzer(completed_engine) - - trades = analyzer.trades - assert isinstance(trades, list) - assert len(trades) == 1 # SimpleLongShortStrategy creates 1 trade - - def test_equity_history_property(self, completed_engine: Engine): - """Test equity history property.""" - analyzer = BacktestAnalyzer(completed_engine) - - equity = analyzer.equity_history - assert isinstance(equity, list) - assert len(equity) > 0 - assert all(isinstance(v, int | float) for v in equity) - - def test_trade_statistics_caching(self, completed_engine: Engine): - """Test that trade statistics are cached.""" - analyzer = BacktestAnalyzer(completed_engine) - - stats1 = analyzer.trade_statistics() - stats2 = analyzer.trade_statistics() - - assert stats1 is stats2 # Same object (cached) - - def test_get_trade_records(self, completed_engine: Engine): - """Test get_trade_records returns diagnostic format.""" - analyzer = BacktestAnalyzer(completed_engine) - - records = analyzer.get_trade_records() - assert isinstance(records, list) - assert len(records) == 1 - - # Verify record structure - record = records[0] - assert "symbol" in record - assert "pnl" in record - assert "metadata" in record - - def test_get_returns_series(self, completed_engine: Engine): - """Test get_returns_series.""" - analyzer = BacktestAnalyzer(completed_engine) - - returns = analyzer.get_returns_series() - assert isinstance(returns, pl.Series) - assert len(returns) > 0 - - def test_get_equity_dataframe(self, completed_engine: Engine): - """Test get_equity_dataframe.""" - analyzer = BacktestAnalyzer(completed_engine) - - df = analyzer.get_equity_dataframe() - assert isinstance(df, pl.DataFrame) - assert "equity" in df.columns - assert "returns" in df.columns - - def test_get_trades_dataframe(self, completed_engine: Engine): - """Test get_trades_dataframe with trades.""" - analyzer = BacktestAnalyzer(completed_engine) - - df = analyzer.get_trades_dataframe() - assert isinstance(df, pl.DataFrame) - assert len(df) == 1 - - # Check expected columns - expected_cols = [ - "symbol", - "entry_time", - "exit_time", - "entry_price", - "exit_price", - "quantity", - "pnl", - "direction", - ] - for col in expected_cols: - assert col in df.columns - - def test_get_trades_dataframe_empty(self, buy_hold_engine: Engine): - """Test get_trades_dataframe with no closed trades.""" - analyzer = BacktestAnalyzer(buy_hold_engine) - - df = analyzer.get_trades_dataframe() - assert isinstance(df, pl.DataFrame) - assert len(df) == 0 - - def test_summary(self, completed_engine: Engine): - """Test summary generation.""" - analyzer = BacktestAnalyzer(completed_engine) - - summary = analyzer.summary() - assert "Backtest Summary" in summary - assert "Initial Capital:" in summary - assert "Final Value:" in summary - assert "Total Return:" in summary - assert "Trade Statistics" in summary - - def test_summary_with_no_trades(self, buy_hold_engine: Engine): - """Test summary with no completed trades.""" - analyzer = BacktestAnalyzer(buy_hold_engine) - - summary = analyzer.summary() - assert "Backtest Summary" in summary - assert "Total Trades: 0" in summary diff --git a/tests/test_broker.py b/tests/test_broker.py index 834bf5e0..6d1e8130 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -466,8 +466,8 @@ def test_commission_split_on_flip(self): assert len(broker.trades) == 1 closing_trade = broker.trades[0] assert closing_trade.quantity == 100.0 # Long 100 closed - assert closing_trade.commission == 1.0, ( - f"Expected close commission $1.00, got ${closing_trade.commission}" + assert closing_trade.fees == 1.0, ( + f"Expected close commission $1.00, got ${closing_trade.fees}" ) # Check PnL calculation includes only close commission diff --git a/tests/test_calendar_integration.py b/tests/test_calendar_integration.py index 3ee724e3..5b59d402 100644 --- a/tests/test_calendar_integration.py +++ b/tests/test_calendar_integration.py @@ -135,7 +135,7 @@ def test_no_calendar_processes_all_bars(self): # All 14 bars should be processed assert strategy.bars_processed == 14 - assert results["skipped_bars"] == 0 + assert results.metrics["skipped_bars"] == 0 def test_enforce_false_processes_all_bars(self): """With enforce_sessions=False, all bars are processed.""" @@ -155,7 +155,7 @@ def test_enforce_false_processes_all_bars(self): # All 14 bars should be processed assert strategy.bars_processed == 14 - assert results["skipped_bars"] == 0 + assert results.metrics["skipped_bars"] == 0 def test_skip_weekend_data(self): """With enforce_sessions=True, weekend bars are skipped.""" @@ -181,7 +181,7 @@ def test_skip_weekend_data(self): # Should skip 4 weekend days + 1 MLK Day = 5 non-trading days # 14 days - 5 = 9 trading days - assert results["skipped_bars"] == 5 + assert results.metrics["skipped_bars"] == 5 assert strategy.bars_processed == 9 # Verify no weekend timestamps in processed bars @@ -225,7 +225,7 @@ def test_skip_holiday_data(self): results = engine.run() # July 4th should be skipped - assert results["skipped_bars"] == 1 + assert results.metrics["skipped_bars"] == 1 assert strategy.bars_processed == 4 # Verify July 4th not in processed timestamps @@ -279,7 +279,7 @@ def test_intraday_weekend_skipped(self): # All bars should be skipped (Saturday) assert strategy.bars_processed == 0 - assert results["skipped_bars"] == 3 + assert results.metrics["skipped_bars"] == 3 def test_intraday_holiday_skipped(self): """Holiday intraday bars are skipped (via trading day fallback).""" @@ -317,7 +317,7 @@ def test_intraday_holiday_skipped(self): # All bars should be skipped (holiday) assert strategy.bars_processed == 0 - assert results["skipped_bars"] == 2 + assert results.metrics["skipped_bars"] == 2 def test_intraday_trading_day_processed(self): """Intraday bars on trading days are processed.""" @@ -356,7 +356,7 @@ def test_intraday_trading_day_processed(self): # All bars should be processed (regular trading day) assert strategy.bars_processed == 3 - assert results["skipped_bars"] == 0 + assert results.metrics["skipped_bars"] == 0 class TestCalendarEdgeCases: @@ -397,7 +397,7 @@ def test_empty_data_with_calendar(self): results = engine.run() assert strategy.bars_processed == 0 - assert results["skipped_bars"] == 0 + assert results.metrics["skipped_bars"] == 0 def test_mixed_valid_invalid_days(self): """Mix of trading days and non-trading days.""" @@ -437,7 +437,7 @@ def test_mixed_valid_invalid_days(self): # Should process 3 trading days, skip 3 (Sat, Sun, MLK Day) assert strategy.bars_processed == 3 - assert results["skipped_bars"] == 3 + assert results.metrics["skipped_bars"] == 3 def test_different_calendar_cme(self): """CME calendar has different trading hours.""" @@ -459,4 +459,4 @@ def test_different_calendar_cme(self): # All 5 trading days should be processed (no weekends in data) assert strategy.bars_processed == 5 - assert results["skipped_bars"] == 0 + assert results.metrics["skipped_bars"] == 0 diff --git a/tests/test_core.py b/tests/test_core.py index 78a53cbd..367aa498 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -422,7 +422,7 @@ def test_buy_and_hold(self): engine = Engine(feed, strategy, initial_cash=100000) results = engine.run() - assert results["num_trades"] == 0 # Still holding + assert results.metrics["num_trades"] == 0 # Still holding assert len(engine.broker.positions) == 1 assert engine.broker.get_position("AAPL").quantity == 100 @@ -436,8 +436,8 @@ def test_signal_based_strategy(self): results = engine.run() # Should have some trades - assert len(results["equity_curve"]) == 30 - assert results["final_value"] > 0 + assert len(results.equity_curve) == 30 + assert results.metrics["final_value"] > 0 def test_vix_filter_strategy(self): prices = generate_prices(["AAPL"], datetime(2024, 1, 1), 20) @@ -449,7 +449,7 @@ def test_vix_filter_strategy(self): engine = Engine(feed, strategy, initial_cash=100000) results = engine.run() - assert len(results["equity_curve"]) == 20 + assert len(results.equity_curve) == 20 def test_with_commission(self): prices = generate_prices(["AAPL"], datetime(2024, 1, 1), 10, {"AAPL": 100}) @@ -460,7 +460,7 @@ def test_with_commission(self): engine = Engine(feed, strategy, initial_cash=100000, commission_model=commission) results = engine.run() - assert results["total_commission"] >= 1.0 + assert results.metrics["total_commission"] >= 1.0 def test_convenience_function(self): prices = generate_prices(["AAPL"], datetime(2024, 1, 1), 10) @@ -472,8 +472,8 @@ def test_convenience_function(self): initial_cash=50000, ) - assert results["initial_cash"] == 50000 - assert len(results["equity_curve"]) == 10 + assert results.metrics["initial_cash"] == 50000 + assert len(results.equity_curve) == 10 class TestTradeRecording: @@ -499,8 +499,8 @@ def on_data(self, timestamp, data, context, broker): engine = Engine(feed, QuickTrade(), initial_cash=100000) results = engine.run() - assert len(results["trades"]) == 1 - trade = results["trades"][0] + assert len(results.trades) == 1 + trade = results.trades[0] assert trade.bars_held >= 1 # Note: entry_signals/exit_signals are not Trade fields # Signals are available in context during on_data, not stored on Trade @@ -548,7 +548,7 @@ def test_from_config_percentage_commission(self): results = engine.run() # Verify commission was applied - assert results["total_commission"] > 0 + assert results.metrics["total_commission"] > 0 def test_from_config_per_share_commission(self): """Test from_config with per-share commission.""" @@ -565,7 +565,7 @@ def test_from_config_per_share_commission(self): results = engine.run() # Should have minimum commission applied - assert results["total_commission"] >= 1.0 + assert results.metrics["total_commission"] >= 1.0 def test_from_config_no_commission(self): """Test from_config with no commission.""" @@ -577,7 +577,7 @@ def test_from_config_no_commission(self): engine = Engine.from_config(feed, strategy, config) results = engine.run() - assert results["total_commission"] == 0.0 + assert results.metrics["total_commission"] == 0.0 def test_from_config_percentage_slippage(self): """Test from_config with percentage slippage.""" @@ -592,7 +592,7 @@ def test_from_config_percentage_slippage(self): engine = Engine.from_config(feed, strategy, config) results = engine.run() - assert results["total_slippage"] > 0 + assert results.metrics["total_slippage"] > 0 def test_from_config_fixed_slippage(self): """Test from_config with fixed slippage.""" @@ -607,7 +607,7 @@ def test_from_config_fixed_slippage(self): engine = Engine.from_config(feed, strategy, config) results = engine.run() - assert results["total_slippage"] > 0 + assert results.metrics["total_slippage"] > 0 def test_from_config_fill_timing_same_bar(self): """Test from_config with SAME_BAR fill timing.""" @@ -645,7 +645,7 @@ def test_from_config_margin_account(self): engine = Engine.from_config(feed, strategy, config) results = engine.run() - assert results["final_value"] > 0 + assert results.metrics["final_value"] > 0 class TestNextBarExecutionMode: @@ -698,7 +698,7 @@ def test_run_backtest_with_config_object(self): ) results = run_backtest(prices=prices, strategy=strategy, config=config) - assert results["initial_cash"] == 50000 + assert results.metrics["initial_cash"] == 50000 def test_run_backtest_with_string_preset(self): """Test run_backtest with string preset name.""" @@ -707,8 +707,8 @@ def test_run_backtest_with_string_preset(self): results = run_backtest(prices=prices, strategy=strategy, config="default") - assert "equity_curve" in results - assert len(results["equity_curve"]) == 10 + assert results.equity_curve is not None + assert len(results.equity_curve) == 10 class TestEmptyDataFeed: @@ -735,7 +735,7 @@ def test_empty_data_returns_empty_results(self): results = engine.run() # Should return empty or minimal results without error - assert results.get("num_trades", 0) == 0 + assert results.metrics.get("num_trades", 0) == 0 if __name__ == "__main__": diff --git a/tests/test_result.py b/tests/test_result.py index f230cfb1..2ede9824 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -327,7 +327,7 @@ def test_trade_records_basic(self, backtest_result: BacktestResult): class TestBacktestResultDict: - """Tests for to_dict() and dict-like access.""" + """Tests for to_dict().""" def test_to_dict_basic(self, backtest_result: BacktestResult): """Test dictionary conversion.""" @@ -339,28 +339,6 @@ def test_to_dict_basic(self, backtest_result: BacktestResult): assert "fills" in d assert "sharpe" in d - def test_getitem_metrics(self, backtest_result: BacktestResult): - """Test dictionary-style access for metrics.""" - assert backtest_result["sharpe"] == 1.5 - assert backtest_result["final_value"] == 100750.0 - - def test_getitem_attributes(self, backtest_result: BacktestResult): - """Test dictionary-style access for attributes.""" - assert backtest_result["trades"] == backtest_result.trades - assert backtest_result["equity_curve"] == backtest_result.equity_curve - assert backtest_result["fills"] == backtest_result.fills - - def test_contains(self, backtest_result: BacktestResult): - """Test 'in' operator.""" - assert "trades" in backtest_result - assert "sharpe" in backtest_result - assert "nonexistent" not in backtest_result - - def test_get_method(self, backtest_result: BacktestResult): - """Test get() method with default.""" - assert backtest_result.get("sharpe") == 1.5 - assert backtest_result.get("nonexistent", 42) == 42 - def test_repr(self, backtest_result: BacktestResult): """Test string representation.""" s = repr(backtest_result) diff --git a/uv.lock b/uv.lock index fc28a2e0..fde31ce4 100644 --- a/uv.lock +++ b/uv.lock @@ -39,91 +39,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "anywidget" -version = "0.9.21" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ipywidgets" }, - { name = "psygnal" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/be/5e/cbea445bf062b81e4d366ca29dae4f0aedc7a64f384afc24670e07bec560/anywidget-0.9.21.tar.gz", hash = "sha256:b8d0172029ac426573053c416c6a587838661612208bb390fa0607862e594b27", size = 390517, upload-time = "2025-11-12T17:06:03.035Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/03/c17464bbf682ea87e7e3de2ddc63395e359a78ae9c01f55fc78759ecbd79/anywidget-0.9.21-py3-none-any.whl", hash = "sha256:78c268e0fbdb1dfd15da37fb578f9cf0a0df58a430e68d9156942b7a9391a761", size = 231797, upload-time = "2025-11-12T17:06:01.564Z" }, -] - -[[package]] -name = "arch" -version = "8.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "scipy" }, - { name = "statsmodels" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/50/f8be4b21db5eb0490aef82b592d105baac957f601805ee7fe5b9182405b2/arch-8.0.0.tar.gz", hash = "sha256:5e9895c2354b9475aff50797ff2191dc64dc5f79602baf0c9321310fb864b637", size = 872623, upload-time = "2025-10-21T08:55:52.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b5/8f04a871c2e0f94430c15d313f88fe7808d80c4752b0ebdeadfec21dec8e/arch-8.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94262bef94dda3f72182a8dfc21cab1a8a79750cf168f3cf2aec02d7217bee55", size = 940443, upload-time = "2025-10-21T08:46:46.648Z" }, - { url = "https://files.pythonhosted.org/packages/b5/42/7f1b880857839ea0841304586715c7d2a477552d04bcde32d1d55d8ccaa0/arch-8.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1d9cb343f15e71e9cee2415bffa1e3458aeb674a538118de71f1124b6c5b755a", size = 929795, upload-time = "2025-10-21T08:39:58.066Z" }, - { url = "https://files.pythonhosted.org/packages/94/d8/44724b06cff6f51b977e8b947403c846be4645c9333d2cad350101b917ca/arch-8.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b72818d66e3ba1f5fcf2a7af4d81a1da7c70e72edf9437144a013173e11b901d", size = 974063, upload-time = "2025-10-21T09:11:39.331Z" }, - { url = "https://files.pythonhosted.org/packages/bc/00/7cc035e2a08b9186cfbd0b5d3dd3967481f64722c3af69416edc8a182fd7/arch-8.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975ec3bdf7926335742ac362251fafe32b448b8f194dead062f22a00beef772d", size = 990702, upload-time = "2025-10-21T09:11:41.53Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4e2dad5b4b88d872a9afd29916ced89116cb31a7c81ed4cbfb2de972cae5/arch-8.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c9d0c8b26f49e3f8b7ae4ade15fac74555c95701a3e22463d991ce4ae7cea966", size = 993284, upload-time = "2025-10-21T09:11:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/d2/4b/abfe066b00a5f1f0ab80dc5b7424f9fc1008116546fefcc1d17def0be9b6/arch-8.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:2aa5e631f91283733592b44e0c0640da5f690f5895738ad0d26a007325e3d0fc", size = 937932, upload-time = "2025-10-21T08:43:16.89Z" }, - { url = "https://files.pythonhosted.org/packages/84/6e/b4379d1dee984f4a51afad9bfb49a3079ae196faf0bb834b7b5ad8e5ec6a/arch-8.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:268dfe386f8c64a1973374bc0425bdf0c7c2250c2bfd7238d98bae701827ec2b", size = 942557, upload-time = "2025-10-21T08:45:19.825Z" }, - { url = "https://files.pythonhosted.org/packages/8d/54/ab79d924327497fddb462ce51216d193e374ad2295b1003542802ed9a021/arch-8.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f4341b22279d82d0300ebd54d1d5f80324f31fc017c8138f47e810bdb81d753", size = 932106, upload-time = "2025-10-21T08:42:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d8/1d/82a772cbc8d64a804438a618f766574d3c87c888342240465761fdba9dec/arch-8.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e551820a0640736c9e9b8fa10ce50e7ae4f31e570ec229c308a3b46aaf8242a7", size = 964602, upload-time = "2025-10-21T09:13:26.715Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d3/da7d55f51bb31a10d1b4a01a22ec0180265a5afeed0d99bd4d0c7b3a61e1/arch-8.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13cbf04d45ecbee7578704a232f897cd02794d845f877158fb2838e6fb637887", size = 981331, upload-time = "2025-10-21T09:13:28.013Z" }, - { url = "https://files.pythonhosted.org/packages/db/be/b44592be8f7926e04f2646206ef83cd68f40e948465fff651b739412146a/arch-8.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fab6e25763e1ef516d8b6c932ef1d0aac3ec812d6b501fc57d8269333d02ce86", size = 983205, upload-time = "2025-10-21T09:13:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/ef/86/612d45473d0865d41934b0580fa05e6aa48167b502d0136e8bd9dd5aa581/arch-8.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8b13d261e0a681b3a8a2f9c588ab37a35500bca9f3bbcc6ca1ce2d999322651d", size = 930370, upload-time = "2025-10-21T08:42:14.667Z" }, - { url = "https://files.pythonhosted.org/packages/d6/51/78f84f9e486e173356931b2bfaf0c2a6d6923f1e8975045e3416ac388215/arch-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4320a9b3707e819a97f0b0a10847e529e2f765158c617a455987a34305018617", size = 940530, upload-time = "2025-10-21T08:46:04.53Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b8/73910773efffc2d35d2739be1bdc70dfcc58a83cff35c4d62e14acceca2b/arch-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b75bc7f4af4da5aca6cbcc52284564fcce8c974cf7d89d8b9777d8c16a228b0", size = 930359, upload-time = "2025-10-21T08:40:11.285Z" }, - { url = "https://files.pythonhosted.org/packages/1c/04/bdd65c773f6ce60cae50cb4f85bcf15dcbe687df75998966e5a236125182/arch-8.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aaefeb2b23276fe286fe554e7e69fea80daff4185ecdf9fc891ba1b2c1e49ad4", size = 964843, upload-time = "2025-10-21T09:13:48.538Z" }, - { url = "https://files.pythonhosted.org/packages/d6/40/7b7ac152c35c32da2a00ba3523ea84c358478b12ee7b3b2b6892e5b9d81b/arch-8.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c1f1d8abefab2f69f7fdbef08cc18c8377667d3b8d197a1f301d97f0e686cd2", size = 982864, upload-time = "2025-10-21T09:13:50.494Z" }, - { url = "https://files.pythonhosted.org/packages/80/40/d99c7d3e0a471d5e0f3e6b3ff1145db789e5a1c4e8fed25e8c22629e87fc/arch-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a985abc5367d225a6b346782dee9e7d84381c2af5ab795a6234aa1491c96f0bb", size = 985288, upload-time = "2025-10-21T09:13:52.243Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e7/2d15374129c03b6f97321f837190cb19863204dbcff289e23cc37f035c96/arch-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:bd73bd2d811bcf0551443b6e0a10bc25af002e9eb146aff164897c70aac35e85", size = 929688, upload-time = "2025-10-21T08:42:06.529Z" }, - { url = "https://files.pythonhosted.org/packages/96/37/8d9ec002ec3e750f3ea2af42b67a1e3cf3a82523b556fd8d10d1f34a085a/arch-8.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8015f7bdcc14800dc2dc2acb01dffebddebf587df4aa27f62671e809ccfdefb1", size = 940799, upload-time = "2025-10-21T08:49:28.848Z" }, - { url = "https://files.pythonhosted.org/packages/70/c8/533ad2ef4277d2f6c95e8038088de2d80c6a41137c7d23e00b08425e2c39/arch-8.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a5a2ee20c5a0d88eda12894d8fbb8320b7bfe3436c2e40fa16da918db54eb5b4", size = 931745, upload-time = "2025-10-21T08:50:02.936Z" }, - { url = "https://files.pythonhosted.org/packages/af/8e/27bf8ef574c507fd984283acd8b33ff066c2ee4beea4b8af9eada23a20f4/arch-8.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82113ee290afac972f73ad6f61b4b4ebd57bf9a288c2df163f9b2bc1874f89f3", size = 967633, upload-time = "2025-10-21T09:25:15.477Z" }, - { url = "https://files.pythonhosted.org/packages/22/11/8a3b956a532b26fe4f325d9829b09eba725eb25a3e89d568673e2015beca/arch-8.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:563bea8e594f712a38ec2186f6dcd0d55a73f1c203bd228958cad495d9d471f1", size = 983273, upload-time = "2025-10-21T09:25:17.695Z" }, - { url = "https://files.pythonhosted.org/packages/95/99/40ca7262d2cc5d74a7b8be10e8a254e0063969edb50959c489bad8d2adb4/arch-8.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:119766cdb3ac9ebdad4077dcf8238fb2a4554ba3c6503bd4431161f43923357e", size = 985658, upload-time = "2025-10-21T09:25:19.375Z" }, - { url = "https://files.pythonhosted.org/packages/0a/d1/14d3dab7283ea68a4e2d17be62d854af6df7e3d6f1b998a87d5be3ed8aed/arch-8.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:4849380bb831a1dc09891cd424f6623f163bde2403c66e376f9d0b5f8c1791c5", size = 934414, upload-time = "2025-10-21T08:44:28.636Z" }, -] - -[[package]] -name = "astropy" -version = "7.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "astropy-iers-data" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyerfa" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/92/2dce2d48347efc3346d08ca7995b152d242ebd170c571f7c9346468d8427/astropy-7.2.0.tar.gz", hash = "sha256:ae48bc26b1feaeb603cd94bd1fa1aa39137a115fe931b7f13787ab420e8c3070", size = 7057774, upload-time = "2025-11-25T22:36:41.916Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/6d/6330a844bad8dfc4875e0f2fa1db1fee87837ba9805aa8a8d048c071363a/astropy-7.2.0-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:efac04df4cc488efe630c2fff1992d6516dfb16a06e197fb68bc9e8e3b85def1", size = 6442332, upload-time = "2025-11-25T22:36:23.6Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ba/3418133ba144dfcd1530bca5a6b695f4cdd21a8abaaa2ac4e5450d11b028/astropy-7.2.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:52e9a7d9c86b21f1af911a2930cd0c4a275fb302d455c89e11eedaffef6f2ad0", size = 6413656, upload-time = "2025-11-25T22:36:26.548Z" }, - { url = "https://files.pythonhosted.org/packages/be/ba/05e43b5a7d738316a097fa78524d3eaaff5986294b4a052d4adb3c45e7c0/astropy-7.2.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97c370421b9bb13d4c762c7af06d172bad7c01bd5bcf88314f6913c3c235b770", size = 9758867, upload-time = "2025-11-25T22:36:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1c/f06ad85180e7dd9855aa5ede901bfc2be858d7bee17d4e978a14c0ecec14/astropy-7.2.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f39ce2c80211fbceb005d377a5478cd0d66c42aa1498d252f2239fe5a025c24", size = 9789007, upload-time = "2025-11-25T22:36:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/f8/fb/e4d35194a5009d7a73333079481a4ef1380a255d67b9c1db578151a5fb50/astropy-7.2.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ad4d71db994d45f046a1a5449000cf0f88ab6367cb67658500654a0586d6ab19", size = 9748547, upload-time = "2025-11-25T22:36:33.154Z" }, - { url = "https://files.pythonhosted.org/packages/36/ea/f990730978ae0a7a34705f885d2f3806928c5f0bc22eefd6a1a23539cc32/astropy-7.2.0-cp311-abi3-win32.whl", hash = "sha256:95161f26602433176483e8bde8ab1a8ca09148f5b4bf5190569a26d381091598", size = 6237228, upload-time = "2025-11-25T22:36:35.236Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bc/f4378f586dd63902c37d16f68f35f7d555b3b32e08ac6b1d633eb0a48805/astropy-7.2.0-cp311-abi3-win_amd64.whl", hash = "sha256:dc7c340ba1713e55c93071b32033f3153470a0f663a4d539c03a7c9b44020790", size = 6362868, upload-time = "2025-11-25T22:36:37.784Z" }, - { url = "https://files.pythonhosted.org/packages/77/79/b6d4bf01913cfd4ce0cd4c1be5916beccdb92b2970bab8c827984231eae6/astropy-7.2.0-cp311-abi3-win_arm64.whl", hash = "sha256:0c428735a3f15b05c2095bc6ccb5f98a64bc99fb7015866af19ff8492420ddaf", size = 6221756, upload-time = "2025-11-25T22:36:39.852Z" }, -] - -[[package]] -name = "astropy-iers-data" -version = "0.2026.1.19.0.42.31" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/37/17585f424e7672e849d1a860ea7196832bb686fe82f45f29a67dc27a5954/astropy_iers_data-0.2026.1.19.0.42.31.tar.gz", hash = "sha256:e389150579ce84da5f73ca47261ca5fed8e42652f28d6b034b6e9752a0b46e60", size = 1920366, upload-time = "2026-01-19T00:43:17.415Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/bc/9fc60c786e9ecb706bf0fbfb1aed2e3a46d9d4695a7a3d8b6f6831aaf73f/astropy_iers_data-0.2026.1.19.0.42.31-py3-none-any.whl", hash = "sha256:7ce1574a098cb3d2e1b4f48f5ed25570b37bb591ff931edd48e3393138109186", size = 1976745, upload-time = "2026-01-19T00:43:16.12Z" }, -] - [[package]] name = "asttokens" version = "3.0.1" @@ -1616,23 +1531,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/0e/b756c7708143a63fca65a51ca07990fa647db2cc8fcd65177b9e96680255/librt-0.7.7-cp314-cp314t-win_arm64.whl", hash = "sha256:142c2cd91794b79fd0ce113bd658993b7ede0fe93057668c2f98a45ca00b7e91", size = 39724, upload-time = "2026-01-01T23:52:09.745Z" }, ] -[[package]] -name = "lightgbm" -version = "4.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/0b/a2e9f5c5da7ef047cc60cef37f86185088845e8433e54d2e7ed439cce8a3/lightgbm-4.6.0.tar.gz", hash = "sha256:cb1c59720eb569389c0ba74d14f52351b573af489f230032a1c9f314f8bab7fe", size = 1703705, upload-time = "2025-02-15T04:03:03.111Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/75/cffc9962cca296bc5536896b7e65b4a7cdeb8db208e71b9c0133c08f8f7e/lightgbm-4.6.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:b7a393de8a334d5c8e490df91270f0763f83f959574d504c7ccb9eee4aef70ed", size = 2010151, upload-time = "2025-02-15T04:02:50.961Z" }, - { url = "https://files.pythonhosted.org/packages/21/1b/550ee378512b78847930f5d74228ca1fdba2a7fbdeaac9aeccc085b0e257/lightgbm-4.6.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:2dafd98d4e02b844ceb0b61450a660681076b1ea6c7adb8c566dfd66832aafad", size = 1592172, upload-time = "2025-02-15T04:02:53.937Z" }, - { url = "https://files.pythonhosted.org/packages/64/41/4fbde2c3d29e25ee7c41d87df2f2e5eda65b431ee154d4d462c31041846c/lightgbm-4.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4d68712bbd2b57a0b14390cbf9376c1d5ed773fa2e71e099cac588703b590336", size = 3454567, upload-time = "2025-02-15T04:02:56.443Z" }, - { url = "https://files.pythonhosted.org/packages/42/86/dabda8fbcb1b00bcfb0003c3776e8ade1aa7b413dff0a2c08f457dace22f/lightgbm-4.6.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cb19b5afea55b5b61cbb2131095f50538bd608a00655f23ad5d25ae3e3bf1c8d", size = 3569831, upload-time = "2025-02-15T04:02:58.925Z" }, - { url = "https://files.pythonhosted.org/packages/5e/23/f8b28ca248bb629b9e08f877dd2965d1994e1674a03d67cd10c5246da248/lightgbm-4.6.0-py3-none-win_amd64.whl", hash = "sha256:37089ee95664b6550a7189d887dbf098e3eadab03537e411f52c63c121e3ba4b", size = 1451509, upload-time = "2025-02-15T04:03:01.515Z" }, -] - [[package]] name = "llvmlite" version = "0.46.0" @@ -1733,108 +1631,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/1e/0eee8bcc16bf01b265ac83e4b870596e2f3bcc40d88aa7ec25407180fe44/lru_dict-1.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3be24e24c8998302ea1c28f997505fa6843f507aad3c7d5c3a82cc01c5c11be4", size = 14062, upload-time = "2025-11-02T10:02:12.878Z" }, ] -[[package]] -name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, - { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, - { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, - { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, - { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, - { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, - { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, - { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, - { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, - { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, - { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, - { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, - { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, - { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, - { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, - { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, - { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, - { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, - { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, - { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, - { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, - { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, - { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, - { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, -] - [[package]] name = "mako" version = "1.3.10" @@ -2218,36 +2014,25 @@ dev = [ name = "ml4t-diagnostic" source = { editable = "../ml4t-diagnostic" } dependencies = [ - { name = "anywidget" }, - { name = "arch" }, { name = "jinja2" }, - { name = "lightgbm" }, { name = "numba" }, { name = "numpy" }, { name = "pandas" }, - { name = "pandas-datareader" }, { name = "pandas-market-calendars" }, { name = "plotly" }, { name = "polars" }, { name = "pyarrow" }, { name = "pydantic" }, { name = "pyyaml" }, - { name = "riskfolio-lib" }, { name = "scikit-learn" }, { name = "scipy" }, - { name = "setuptools" }, { name = "statsmodels" }, { name = "structlog" }, - { name = "sympy" }, { name = "tabulate" }, - { name = "vectorbt" }, - { name = "xgboost" }, ] [package.metadata] requires-dist = [ - { name = "anywidget", specifier = ">=0.9.21" }, - { name = "arch", specifier = ">=7.2.0" }, { name = "arch", marker = "extra == 'advanced'", specifier = ">=6.0.0" }, { name = "arch", marker = "extra == 'all'", specifier = ">=6.0.0" }, { name = "cupy-cuda11x", marker = "extra == 'all-ml'", specifier = ">=11.0.0" }, @@ -2261,7 +2046,6 @@ requires-dist = [ { name = "jinja2", specifier = ">=3.1.0" }, { name = "kaleido", marker = "extra == 'all'", specifier = ">=0.2.0" }, { name = "kaleido", marker = "extra == 'viz'", specifier = ">=0.2.0" }, - { name = "lightgbm", specifier = ">=4.6.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" }, @@ -2277,7 +2061,6 @@ requires-dist = [ { name = "numba", specifier = ">=0.57.0" }, { name = "numpy", specifier = ">=1.24.0" }, { name = "pandas", specifier = ">=2.0.0" }, - { name = "pandas-datareader", specifier = ">=0.10.0" }, { name = "pandas-market-calendars", specifier = ">=4.0.0" }, { name = "plotly", specifier = ">=5.15.0" }, { name = "plotly", marker = "extra == 'all'", specifier = ">=5.15.0" }, @@ -2300,14 +2083,12 @@ requires-dist = [ { 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 = "riskfolio-lib", specifier = ">=7.1.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" }, { name = "seaborn", marker = "extra == 'all'", specifier = ">=0.12.0" }, { name = "seaborn", marker = "extra == 'viz'", specifier = ">=0.12.0" }, - { name = "setuptools", specifier = ">=80.9.0" }, { name = "shap", marker = "extra == 'all'", specifier = ">=0.41.0,<0.50.0" }, { name = "shap", marker = "extra == 'all-ml'", specifier = ">=0.41.0,<0.50.0" }, { name = "shap", marker = "extra == 'deep'", specifier = ">=0.41.0,<0.50.0" }, @@ -2316,22 +2097,17 @@ requires-dist = [ { 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 = "structlog", specifier = ">=23.0.0" }, - { name = "sympy", specifier = ">=1.14.0" }, { name = "tabulate", specifier = ">=0.9.0" }, { name = "tensorflow", marker = "extra == 'all-ml'", specifier = ">=2.0.0" }, { name = "tensorflow", marker = "extra == 'deep'", specifier = ">=2.0.0" }, { name = "ty", marker = "extra == 'all'" }, { name = "ty", marker = "extra == 'dev'" }, - { name = "vectorbt", specifier = ">=0.28.2" }, - { name = "xgboost", specifier = ">=2.1.4" }, { 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", "viz"] +provides-extras = ["advanced", "all", "all-ml", "deep", "dev", "docs", "gpu", "integration", "ml", "viz"] [package.metadata.requires-dev] dev = [ @@ -2356,15 +2132,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] -[[package]] -name = "mpmath" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, -] - [[package]] name = "msgpack" version = "1.1.2" @@ -2858,15 +2625,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/ee/346fa473e666fe14c52fcdd19ec2424157290a032d4c41f98127bfb31ac7/numpy-2.3.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425", size = 12967213, upload-time = "2025-11-16T22:52:39.38Z" }, ] -[[package]] -name = "nvidia-nccl-cu12" -version = "2.29.2" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/b2/e4dc7b33020645746710040cb2a6ac0de8332687d3ce902156dd3d7c351a/nvidia_nccl_cu12-2.29.2-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:0712e55c067965c6093cc793a9bbcc5f37b5b47248e9ebf8ae3af06867757587", size = 289707761, upload-time = "2026-01-07T00:21:30.514Z" }, - { url = "https://files.pythonhosted.org/packages/23/2d/609d0392d992259c6dc39881688a7fc13b1397a668bc360fbd68d1396f85/nvidia_nccl_cu12-2.29.2-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:3a9a0bf4142126e0d0ed99ec202579bef8d007601f9fab75af60b10324666b12", size = 289762233, upload-time = "2026-01-07T00:21:56.124Z" }, -] - [[package]] name = "osqp" version = "1.0.5" @@ -2960,20 +2718,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] -[[package]] -name = "pandas-datareader" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lxml" }, - { name = "pandas" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/94/b0363da3981da77d3ec7990e89006e4d4f71fd71a82290ce5c85540a7019/pandas-datareader-0.10.0.tar.gz", hash = "sha256:9fc3c63d39bc0c10c2683f1c6d503ff625020383e38f6cbe14134826b454d5a6", size = 95477, upload-time = "2021-07-13T12:38:59.942Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/16/56c9d648b503619ebe96f726b5f642b68e299b34162ed2d6faa9d7966b7d/pandas_datareader-0.10.0-py3-none-any.whl", hash = "sha256:0b95ff3635bc3ee1a6073521b557ab0e3c39d219f4a3b720b6b0bc6e8cdb4bb7", size = 109460, upload-time = "2021-07-13T12:38:57.795Z" }, -] - [[package]] name = "pandas-market-calendars" version = "5.2.4" @@ -3216,35 +2960,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] -[[package]] -name = "psygnal" -version = "0.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/79/20c3e23e75272e9ddf018097cf872ab088bccba978888472656629efa4a3/psygnal-0.15.1.tar.gz", hash = "sha256:f64f62dee2306fc1c22050a59b6c6cdad126e04b0cf50e393ff858a1da719096", size = 123147, upload-time = "2026-01-04T16:38:41.959Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/a7/69495410025cc4298765545ce3b8c635cd4c8d3a362b7fbbc15b80e9fc8f/psygnal-0.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1adc41515f648696990964433f1e25d8dfd306813a3645366c85e01986ba57a0", size = 581002, upload-time = "2026-01-04T16:38:12.753Z" }, - { url = "https://files.pythonhosted.org/packages/75/1f/19a8126ccf3cd3974ba5d08a435a049b666961d90f5848ba83599d7a29de/psygnal-0.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:38ff18455b2ac73d4e8eea82ef298ce904b52e4dfdc603a24380c9c440e37519", size = 567775, upload-time = "2026-01-04T16:38:14.04Z" }, - { url = "https://files.pythonhosted.org/packages/54/c5/b1348880d603edb82128a721397a1ddcf3dfcf5384fe5689db6e471118ae/psygnal-0.15.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c923c322eeefb1140886927cfe7bda7c32341087e290e812b9c69a624ab72d54", size = 855961, upload-time = "2026-01-04T16:38:15.612Z" }, - { url = "https://files.pythonhosted.org/packages/e6/42/3da2d6f3583bd1a849f7faa2fd3492b14bfda05012519ceaea5992658af0/psygnal-0.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2714ddaa41ea3134c0ee91cebd5fb11a88f254ea1d5948806ab0ad5f8be603d5", size = 862721, upload-time = "2026-01-04T16:38:17.059Z" }, - { url = "https://files.pythonhosted.org/packages/4d/14/6fc7e97fdecf7e8c5c105684bab784920312a3259800d8b53e3cf8783f42/psygnal-0.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:877516056a5a383427a647fff2fad5179eaa3e12de2c083c273e748435414aef", size = 415696, upload-time = "2026-01-04T16:38:18.355Z" }, - { url = "https://files.pythonhosted.org/packages/76/65/b7bbca96bc477aa9ac2264e5907b2f4ccfcd1319f776dd1f35eec06cc2f4/psygnal-0.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d56f0f35eaf4a21f660de76885222faf9e8c7112454528d3394d464f3d4d1a3", size = 598340, upload-time = "2026-01-04T16:38:19.752Z" }, - { url = "https://files.pythonhosted.org/packages/40/f2/56577465a1b42a5e6780bb5fab53fb68f8bfd72f0131ed397576529af724/psygnal-0.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0febcf757a1323d9b8bd75735ee3569213d8110012a7bf0f478e85c5ab459fc6", size = 575311, upload-time = "2026-01-04T16:38:21.137Z" }, - { url = "https://files.pythonhosted.org/packages/79/81/f642ac08104049383076f83480ed412c9626e068769a1c34873c595bec0e/psygnal-0.15.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5e4837dfbfa4974dabe0795e32be9aadcd87603adf734738ce1114f72238a05", size = 889770, upload-time = "2026-01-04T16:38:22.629Z" }, - { url = "https://files.pythonhosted.org/packages/de/43/e571fa40b72780abed080ef829e5ad98017b6fe48d28c15a2404e006b676/psygnal-0.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07b4c4e03bbf4e8cad7e25f4fbc1ba9575fb9c3d14991bc7edfeb8b09c8d6d54", size = 881105, upload-time = "2026-01-04T16:38:23.896Z" }, - { url = "https://files.pythonhosted.org/packages/e3/26/ef3ab825eb08eaecbbceeeb56383694fe64ce399dbfd1d0767bb85688785/psygnal-0.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:4f0ce91b9c18e92281bf2c3fc4bb4e808d90f0b023d0a37b302d354188520338", size = 418969, upload-time = "2026-01-04T16:38:25.731Z" }, - { url = "https://files.pythonhosted.org/packages/46/21/5a142165d27063abf5921807d3c3d973f5d44ab414a13b210839a43ead4d/psygnal-0.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2087aadc9404f007f79c2899e329932869e362c50de58b90631c5f49b4768cc5", size = 596768, upload-time = "2026-01-04T16:38:27.053Z" }, - { url = "https://files.pythonhosted.org/packages/e1/25/c1712931d61c118691e73daf29ef708c679ea9ba187c797dd5deee360411/psygnal-0.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f3bf68ca42569dfdce20c6cf915d34b78b9e3ddddacb9f78728224fda6946b4", size = 574808, upload-time = "2026-01-04T16:38:28.779Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4f/3593e5adb88a188c798604aed95fbc1479f30230e7f51e8f2c770e6a3832/psygnal-0.15.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9fca977f5335deea39aed22e31d9795983e4f243e59a7d3c4105793adb7693d", size = 885616, upload-time = "2026-01-04T16:38:30.081Z" }, - { url = "https://files.pythonhosted.org/packages/58/4c/14779ed4c3a1d71fa1a9a87ecfb184ad3335dd64681067f77c1c47b14ae9/psygnal-0.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c85b7d05b92ccbec47c75ab8a5545eda462e81a492c82424aba5ab81a3ad89d", size = 876516, upload-time = "2026-01-04T16:38:31.422Z" }, - { url = "https://files.pythonhosted.org/packages/3e/bc/4f771e3cdcde4db4023dbf36d6f0aab44e02b9de719353c22954b655e2ff/psygnal-0.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac0e693b29e0a429e97315a52313321855bef6140e9975b7ae78b4d93c8fbb42", size = 419172, upload-time = "2026-01-04T16:38:32.82Z" }, - { url = "https://files.pythonhosted.org/packages/f4/2e/975bd61727578d88df62797f78390965ca7905780cf01eb59cb095a13638/psygnal-0.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:803fc33c4280c822c6f4b22e6c3ea7c4483e190f3cc69e69350098b3799476f3", size = 595706, upload-time = "2026-01-04T16:38:34.139Z" }, - { url = "https://files.pythonhosted.org/packages/b8/55/e487f1d91497eb75e86c3fdfef69a21b1cab24d023383dd7648b08797d6a/psygnal-0.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4f53b4b83355b0a785b745987fd04e59bbf169a9028ed81a68ca7e05fb76d458", size = 575133, upload-time = "2026-01-04T16:38:35.448Z" }, - { url = "https://files.pythonhosted.org/packages/bf/2f/f286355accd0e68d3eef52e63c8b9ab6ba33ec3107177719a036b3319657/psygnal-0.15.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcbca12190f5aa65c1f8fb04a81fa6f4463c5f5dde25cd74c3a56ceff6f37b02", size = 889565, upload-time = "2026-01-04T16:38:37.003Z" }, - { url = "https://files.pythonhosted.org/packages/fc/dc/40c6026c88d7f9220ecc913afe0501045a512c9b82f9b7e036bb089dc287/psygnal-0.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1ac399566852fe4354ce26a1acbe12319232e8c2b615fe5ad1e114c547095cf6", size = 880863, upload-time = "2026-01-04T16:38:38.381Z" }, - { url = "https://files.pythonhosted.org/packages/b7/85/b4f45ec3057c473b5622fc002b3a636a698c34d3a0917a064ff5247f1984/psygnal-0.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:d3a03055f331ce91d44581c71edb79938ccc133a94af2ce7ad3a18fa57ac7be5", size = 423654, upload-time = "2026-01-04T16:38:39.7Z" }, - { url = "https://files.pythonhosted.org/packages/46/49/7742544684bee728ec123515d2694cee859aa2a705951a461230b00f18cc/psygnal-0.15.1-py3-none-any.whl", hash = "sha256:4221140e633e45b076953c64bcb9b41a744833527f9a037c1ca98bc270798cbf", size = 90638, upload-time = "2026-01-04T16:38:40.841Z" }, -] - [[package]] name = "ptyprocess" version = "0.7.0" @@ -3322,15 +3037,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/03/f335d6c52b4a4761bcc83499789a1e2e16d9d201a58c327a9b5cc9a41bd9/pyarrow-22.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0c34fe18094686194f204a3b1787a27456897d8a2d62caf84b61e8dfbc0252ae", size = 29185594, upload-time = "2025-10-24T10:09:53.111Z" }, ] -[[package]] -name = "pybind11" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2f/7b/a6d8dcb83c457e24a9df1e4d8fd5fb8034d4bbc62f3c324681e8a9ba57c2/pybind11-3.0.1.tar.gz", hash = "sha256:9c0f40056a016da59bab516efb523089139fcc6f2ba7e4930854c61efb932051", size = 546914, upload-time = "2025-08-22T20:09:27.265Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/8a/37362fc2b949d5f733a8b0f2ff51ba423914cabefe69f1d1b6aab710f5fe/pybind11-3.0.1-py3-none-any.whl", hash = "sha256:aa8f0aa6e0a94d3b64adfc38f560f33f15e589be2175e103c0a33c6bce55ee89", size = 293611, upload-time = "2025-08-22T20:09:25.235Z" }, -] - [[package]] name = "pycparser" version = "2.23" @@ -3452,24 +3158,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pyerfa" -version = "2.0.1.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/39/63cc8291b0cf324ae710df41527faf7d331bce573899199d926b3e492260/pyerfa-2.0.1.5.tar.gz", hash = "sha256:17d6b24fe4846c65d5e7d8c362dcb08199dc63b30a236aedd73875cc83e1f6c0", size = 818430, upload-time = "2024-11-11T15:22:30.852Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/d9/3448a57cb5bd19950de6d6ab08bd8fbb3df60baa71726de91d73d76c481b/pyerfa-2.0.1.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b282d7c60c4c47cf629c484c17ac504fcb04abd7b3f4dfcf53ee042afc3a5944", size = 341818, upload-time = "2024-11-11T15:22:16.467Z" }, - { url = "https://files.pythonhosted.org/packages/11/4a/31a363370478b63c6289a34743f2ba2d3ae1bd8223e004d18ab28fb92385/pyerfa-2.0.1.5-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:be1aeb70390dd03a34faf96749d5cabc58437410b4aab7213c512323932427df", size = 329370, upload-time = "2024-11-11T15:22:17.829Z" }, - { url = "https://files.pythonhosted.org/packages/cb/96/b6210fc624123c8ae13e1eecb68fb75e3f3adff216d95eee1c7b05843e3e/pyerfa-2.0.1.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0603e8e1b839327d586c8a627cdc634b795e18b007d84f0cda5500a0908254e", size = 692794, upload-time = "2024-11-11T15:22:19.429Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e0/050018d855d26d3c0b4a7d1b2ed692be758ce276d8289e2a2b44ba1014a5/pyerfa-2.0.1.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e43c7194e3242083f2350b46c09fd4bf8ba1bcc0ebd1460b98fc47fe2389906", size = 738711, upload-time = "2024-11-11T15:22:20.661Z" }, - { url = "https://files.pythonhosted.org/packages/b9/f5/ff91ee77308793ae32fa1e1de95e9edd4551456dd888b4e87c5938657ca5/pyerfa-2.0.1.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:07b80cd70701f5d066b1ac8cce406682cfcd667a1186ec7d7ade597239a6021d", size = 722966, upload-time = "2024-11-11T15:22:21.905Z" }, - { url = "https://files.pythonhosted.org/packages/2c/56/b22b35c8551d2228ff8d445e63787112927ca13f6dc9e2c04f69d742c95b/pyerfa-2.0.1.5-cp39-abi3-win32.whl", hash = "sha256:d30b9b0df588ed5467e529d851ea324a67239096dd44703125072fd11b351ea2", size = 339955, upload-time = "2024-11-11T15:22:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/b4/11/97233cf23ad5411ac6f13b1d6ee3888f90ace4f974d9bf9db887aa428912/pyerfa-2.0.1.5-cp39-abi3-win_amd64.whl", hash = "sha256:66292d437dcf75925b694977aa06eb697126e7b86553e620371ed3e48b5e0ad0", size = 349410, upload-time = "2024-11-11T15:22:24.817Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -3899,51 +3587,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] -[[package]] -name = "riskfolio-lib" -version = "7.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "arch" }, - { name = "astropy" }, - { name = "clarabel" }, - { name = "cvxpy" }, - { name = "matplotlib" }, - { name = "networkx" }, - { name = "numpy" }, - { name = "pandas" }, - { name = "pybind11" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "scs" }, - { name = "statsmodels" }, - { name = "vectorbt" }, - { name = "xlsxwriter" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1b/33/5dd0dbdacad1ef24ac6e0dc4e8900617f373c338bcb4ec452ffc6d4a89e4/riskfolio_lib-7.2.0.tar.gz", hash = "sha256:b467c9c3c56f57453596715ecd4fe490f2caa5fbce8cc72841e3558c345fcfa8", size = 45102607, upload-time = "2026-01-03T02:33:02.797Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/fd/eef22ef4a8adc24033a6145f210d2bc7973e10026d66eb1a33eab1b00e32/riskfolio_lib-7.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b91261b3e72efdad69c6a8281d872bd19b948a06d9fa277d757c2818a0f2d668", size = 468179, upload-time = "2026-01-03T02:32:31.744Z" }, - { url = "https://files.pythonhosted.org/packages/05/b4/f1b65e6994a12eceb1ebb639e6aff951e0043276b3e1eafb330426be4cee/riskfolio_lib-7.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ce826b98f6ad481a908abf3f3b6fb8f6b473831d4fec90cb352e051de58eaced", size = 310701, upload-time = "2026-01-03T02:32:33.326Z" }, - { url = "https://files.pythonhosted.org/packages/36/c0/e555a5e7f2cc37f18cfe3778bc4429488f12d216aa81db11148092e43a41/riskfolio_lib-7.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e74b620eadf58f270d1af791883db3d8304cf7e66f00692e282fa29e01d15f", size = 302475, upload-time = "2026-01-03T02:32:34.502Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9f/88a201421e85356566ca13d637414efebfaa979e5aec6e13333f0e8298af/riskfolio_lib-7.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bf400d1cccc26b55bb9ce2d5402894784443ceba6efacdd412afbe59c62af87", size = 319888, upload-time = "2026-01-03T02:32:35.425Z" }, - { url = "https://files.pythonhosted.org/packages/01/78/cbd3bfcf0e265ca78975b59e2174e5c2b966413ed00997dc452d08b1dc03/riskfolio_lib-7.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:29ff68b7a613ea55bc010d43ed0bfee6b699f62531e95099b731f39f550ce8cb", size = 281412, upload-time = "2026-01-03T02:32:36.644Z" }, - { url = "https://files.pythonhosted.org/packages/70/6a/4b7c3bcf47d501bb0878534e543c57eefc233579157d7f9d663878ea43b2/riskfolio_lib-7.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3aa15f407327d5318151b0a34262e170ec6c23650c552c6a45f00441a4d0019a", size = 466196, upload-time = "2026-01-03T02:32:37.883Z" }, - { url = "https://files.pythonhosted.org/packages/ec/58/c08bf8a0a8e56c2ccd84fc211e74f1873a9a1b2a221b30b05333b5ed10b8/riskfolio_lib-7.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:65cbd2c8cd73894d598611c60b7739d046bd5e35e1e26543cc89a181e138defd", size = 309582, upload-time = "2026-01-03T02:32:38.978Z" }, - { url = "https://files.pythonhosted.org/packages/fc/26/282e471a83100310d3a5e9f5f69ed4222f60b2551d7675b9d4f1be46a3ad/riskfolio_lib-7.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a9be7907dd7241a82b55006b3e97921c73ab17c317f3a809cf56bdbd1433e00", size = 302406, upload-time = "2026-01-03T02:32:40.409Z" }, - { url = "https://files.pythonhosted.org/packages/63/8b/c4568cc8c85152d9acc984594998e0fc481f801a9b9625c3cf9892419b1b/riskfolio_lib-7.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b51afeed322f2c53c4aedd7d06646cefb3550cc306dab51066f3ca2af26b249f", size = 319900, upload-time = "2026-01-03T02:32:42.082Z" }, - { url = "https://files.pythonhosted.org/packages/28/a8/6f3a7b96a9021734d76023bcc08584a12c062fd72246ceff3fc28082dcf3/riskfolio_lib-7.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ed0ba913bb80eb62a678555d87d0098b61dacb6b36f1f551843bcb22febe91d8", size = 281613, upload-time = "2026-01-03T02:32:43.407Z" }, - { url = "https://files.pythonhosted.org/packages/97/ad/b356ca6da743fb0271f6fc5e3cd7007ff3a69be18dbd3fc7a99d6c6549ae/riskfolio_lib-7.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9cf78837ec9708f7536f0c60e841c5d8308b6cb126780eea89520ed28621773b", size = 466210, upload-time = "2026-01-03T02:32:44.448Z" }, - { url = "https://files.pythonhosted.org/packages/87/69/046c90db3072ba36c5d39ed80e391c0528a77fe272beb3d77605c20c51b5/riskfolio_lib-7.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a308fe752dff06cf6b72d398b2daa92b0c59310cb6605420a1138c2715664be5", size = 309601, upload-time = "2026-01-03T02:32:45.866Z" }, - { url = "https://files.pythonhosted.org/packages/a3/e5/0ebf723113f7cedb0070a6e84dc13c87a5002f0911bd9166ee9d27607803/riskfolio_lib-7.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2da596f53243cdf9f742d9654b4820116dc7fcb99317abf02595cdd81a19626a", size = 302375, upload-time = "2026-01-03T02:32:46.855Z" }, - { url = "https://files.pythonhosted.org/packages/74/1e/aea550ba99c25cab978d1ca450e1cb794b6f30d8683853ee2d95ac24e363/riskfolio_lib-7.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13155de3635e22123fd9ba69bc2b7de787647e06e6d69738e5f1be2ff6414544", size = 319669, upload-time = "2026-01-03T02:32:48.039Z" }, - { url = "https://files.pythonhosted.org/packages/63/38/fd56fbbb83466d0ef216c80d2ecb99ac4681c353be7786e5f42418423e6f/riskfolio_lib-7.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a771636a0a01ef2e99cb3183ed28245e3835802cb586455c4fb78ea0f36018a0", size = 281597, upload-time = "2026-01-03T02:32:48.972Z" }, - { url = "https://files.pythonhosted.org/packages/ea/4b/b954c57faca2bfb24a4740151ea553e4208c5e8a619be3050ab6fa988b99/riskfolio_lib-7.2.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2b46d296bafc4f165fcd1cb4cb79654f1a30f70cc6a6ecad1e1175537d757a76", size = 466839, upload-time = "2026-01-03T02:32:49.978Z" }, - { url = "https://files.pythonhosted.org/packages/15/fb/7ac716a7de6f4819304299b68d5bde6c675dad37e48c2628e6676351620f/riskfolio_lib-7.2.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7ccf0c7a8039ea228e8afef4667629b5efe2fe36c749d7d14531d2755d10a267", size = 309847, upload-time = "2026-01-03T02:32:51.061Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5c/f92f928a43384a54528a7de64bd059041b39171fb25e9b0cf7eed35b10e7/riskfolio_lib-7.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd24eebb7bf4aadb019dbe9d5f84b54d55d913cfa313dfe11eb9eca1c6decf9", size = 302569, upload-time = "2026-01-03T02:32:52.935Z" }, - { url = "https://files.pythonhosted.org/packages/a9/bc/7ffd4432a45ea0d047308b777751399173fc3a4fdcaa85969d098941fbee/riskfolio_lib-7.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b3983d6cbde78bfd5b7b240226ec3797ca1a2cc73e168820df62a292b45f4ee", size = 319815, upload-time = "2026-01-03T02:32:53.856Z" }, - { url = "https://files.pythonhosted.org/packages/52/5d/f5ff6462d033522825b59d59bbd61b64eda65d11e209752390d7105fae4a/riskfolio_lib-7.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:514c7ee32e3b10ba19e076f0ce00092a9f8c579104d18e69f1e39a395ee64456", size = 285895, upload-time = "2026-01-03T02:32:54.985Z" }, -] - [[package]] name = "roman-numerals" version = "4.1.0" @@ -4548,18 +4191,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, ] -[[package]] -name = "sympy" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mpmath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, -] - [[package]] name = "tables" version = "3.10.2" @@ -4894,33 +4525,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, ] -[[package]] -name = "xgboost" -version = "3.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "nvidia-nccl-cu12", marker = "sys_platform == 'linux'" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/db/ff3eb8ff8cdf87a57cbb0f484234b4353178587236c4c84c1d307165c1f8/xgboost-3.1.3.tar.gz", hash = "sha256:0aeaa59d7ba09221a6fa75f70406751cfafdf3f149d0a91b197a1360404a28f3", size = 1237662, upload-time = "2026-01-10T00:20:13.458Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/a9/8668a5662c497c32ab127b7ca57d91153f499b31c725969a1e4147782e64/xgboost-3.1.3-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:e16a6c352ee1a4c19372a7b2bb75129e10e63adeeabd3d11f21b7787378e5a50", size = 2378032, upload-time = "2026-01-10T00:18:14.103Z" }, - { url = "https://files.pythonhosted.org/packages/52/39/ec5c53228b091387e934d3d419e8e3a5ce98c1650d458987d6e254a15304/xgboost-3.1.3-py3-none-macosx_12_0_arm64.whl", hash = "sha256:a7a1d59f3529de0ad9089c59b6cc595cd7b4424feabcc06463c4bde41f202f74", size = 2211477, upload-time = "2026-01-10T00:18:34.409Z" }, - { url = "https://files.pythonhosted.org/packages/99/f7/ceb06e6b959e5a8b303883482ecad346495641947679e3f735ae8ac1caa7/xgboost-3.1.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2e31482633883b2e95fda6055db654bbfac82e10d91ad3d9929086ebd28eb1c4", size = 115346575, upload-time = "2026-01-10T00:19:11.44Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9c/9d4ad7f586698bad52a570d2bf81138e500a5d9f32723c2b4ed1dd9252d8/xgboost-3.1.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:687504d1d76dc797df08b0dbe8b83d58629cdc06df52378f617164d16142bf2c", size = 115926894, upload-time = "2026-01-10T00:19:49.123Z" }, - { url = "https://files.pythonhosted.org/packages/3a/d8/4d4ae25452577f2dfabc66b60e712e7c01f9fe6c389fa88c546c2f427c4d/xgboost-3.1.3-py3-none-win_amd64.whl", hash = "sha256:3fe349b4c6030f0d66e166a3a6b7d470e776d530ea240d77335e36144cbe132a", size = 72011993, upload-time = "2026-01-10T00:17:42.98Z" }, -] - -[[package]] -name = "xlsxwriter" -version = "3.2.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, -] - [[package]] name = "zipline-reloaded" version = "3.1.1" From 742322e71e6c843dc99d3e8c0e94fad62ef2345b Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 08:50:59 -0500 Subject: [PATCH 04/24] chore: apply pre-commit normalization updates --- api.yaml | 6 +++--- uv.lock | 15 --------------- validation/lean/workspace/lean.json | 2 +- 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/api.yaml b/api.yaml index f1ee6e88..2e57709f 100644 --- a/api.yaml +++ b/api.yaml @@ -22,7 +22,7 @@ modules: methods: - name: run returns: BacktestResult - + - name: Strategy description: Base class for trading strategies methods: @@ -40,7 +40,7 @@ modules: type: int - name: equity type: float - + - name: BacktestConfig params: - name: initial_cash @@ -55,7 +55,7 @@ modules: - name: margin_ratio type: float default: 1.0 - + - name: BacktestResult properties: - name: equity_curve diff --git a/uv.lock b/uv.lock index fde31ce4..5d2cd97f 100644 --- a/uv.lock +++ b/uv.lock @@ -2014,7 +2014,6 @@ dev = [ name = "ml4t-diagnostic" source = { editable = "../ml4t-diagnostic" } dependencies = [ - { name = "jinja2" }, { name = "numba" }, { name = "numpy" }, { name = "pandas" }, @@ -2027,8 +2026,6 @@ dependencies = [ { name = "scikit-learn" }, { name = "scipy" }, { name = "statsmodels" }, - { name = "structlog" }, - { name = "tabulate" }, ] [package.metadata] @@ -2043,7 +2040,6 @@ requires-dist = [ { 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 = "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" }, @@ -2097,8 +2093,6 @@ requires-dist = [ { 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 = "structlog", specifier = ">=23.0.0" }, - { name = "tabulate", specifier = ">=0.9.0" }, { name = "tensorflow", marker = "extra == 'all-ml'", specifier = ">=2.0.0" }, { name = "tensorflow", marker = "extra == 'deep'", specifier = ">=2.0.0" }, { name = "ty", marker = "extra == 'all'" }, @@ -4222,15 +4216,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/ca/eaa029a43d269bdda6985931d6cfd479e876cd8cf7c887d818bef05ef03b/tables-3.10.2-cp313-cp313-win_amd64.whl", hash = "sha256:5637fdcded5ba5426aa24e0e42d6f990926a4da7f193830df131dfcb7e842900", size = 6385562, upload-time = "2025-01-04T20:44:08.196Z" }, ] -[[package]] -name = "tabulate" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, -] - [[package]] name = "threadpoolctl" version = "3.6.0" diff --git a/validation/lean/workspace/lean.json b/validation/lean/workspace/lean.json index 38e8aec8..7dcf7517 100644 --- a/validation/lean/workspace/lean.json +++ b/validation/lean/workspace/lean.json @@ -645,4 +645,4 @@ "organization-id": "afc064e272f61db45f040d556c9a9375", "file-database-last-update": "01/01/2026 10:40:20", "id": "Local" -} \ No newline at end of file +} From b1503f4e13758c9d6d0435ce8427ee5c90513434 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 08:51:03 -0500 Subject: [PATCH 05/24] feat: simplify config/types and remove stale presets --- src/ml4t/backtest/__init__.py | 220 ++-------------------- src/ml4t/backtest/calendar.py | 2 +- src/ml4t/backtest/config.py | 104 ++++++---- src/ml4t/backtest/presets/backtrader.yaml | 46 ----- src/ml4t/backtest/presets/default.yaml | 45 ----- src/ml4t/backtest/presets/realistic.yaml | 48 ----- src/ml4t/backtest/presets/vectorbt.yaml | 46 ----- src/ml4t/backtest/presets/zipline.yaml | 46 ----- src/ml4t/backtest/result.py | 52 ++++- src/ml4t/backtest/types.py | 6 +- 10 files changed, 132 insertions(+), 483 deletions(-) delete mode 100644 src/ml4t/backtest/presets/backtrader.yaml delete mode 100644 src/ml4t/backtest/presets/default.yaml delete mode 100644 src/ml4t/backtest/presets/realistic.yaml delete mode 100644 src/ml4t/backtest/presets/vectorbt.yaml delete mode 100644 src/ml4t/backtest/presets/zipline.yaml diff --git a/src/ml4t/backtest/__init__.py b/src/ml4t/backtest/__init__.py index b9da96fd..27ab7f61 100644 --- a/src/ml4t/backtest/__init__.py +++ b/src/ml4t/backtest/__init__.py @@ -13,122 +13,18 @@ except ImportError: __version__ = "0.0.0.dev0" -# Import from modules -# Analytics -from .analytics import ( - # Analytics classes - EquityCurve, - TradeAnalyzer, - cagr, - calmar_ratio, - max_drawdown, - sharpe_ratio, - sortino_ratio, - # Bridge functions (diagnostic integration) - to_equity_dataframe, - to_returns_series, - to_trade_record, - to_trade_records, - volatility, -) from .broker import Broker - -# Calendar functions (pandas_market_calendars integration) -from .calendar import ( - CALENDAR_ALIASES, - filter_to_trading_days, - filter_to_trading_sessions, - generate_trading_minutes, - get_calendar, - get_early_closes, - get_holidays, - get_schedule, - get_trading_days, - is_market_open, - is_trading_day, - list_calendars, - next_trading_day, - previous_trading_day, -) -from .config import ( - PRESETS_DIR, - BacktestConfig, - DataFrequency, - ExecutionPrice, - FillOrdering, - FillTiming, - InitialHwmSource, - Mode, - RebalanceMode, - ShareType, - SignalProcessing, - StatsConfig, - TrailStopTiming, - WaterMarkSource, -) +from .config import BacktestConfig, Mode from .datafeed import DataFeed from .engine import Engine, run_backtest - -# Execution model (volume limits, market impact, rebalancing) -from .execution import ( - ExecutionLimits, - ExecutionResult, - LinearImpact, - MarketImpactModel, - NoImpact, - NoLimits, - RebalanceConfig, - SquareRootImpact, - TargetWeightExecutor, - VolumeParticipationLimit, -) - -# Export utilities -from .export import BacktestExporter -from .models import ( - CombinedCommission, - CommissionModel, - FixedSlippage, - NoCommission, - NoSlippage, - PercentageCommission, - PercentageSlippage, - PerShareCommission, - SlippageModel, - TieredCommission, - VolumeShareSlippage, -) - -# Structured result -from .result import BacktestResult, enrich_trades_with_signals +from .result import BacktestResult # Risk management rules (position-level) from .risk.position.composite import RuleChain -from .risk.position.dynamic import ( - ScaledExit, - TighteningTrailingStop, - TrailingStop, - VolatilityStop, - VolatilityTrailingStop, -) -from .risk.position.signal import SignalExit -from .risk.position.static import StopLoss, TimeExit - -# Session alignment -from .sessions import SessionConfig, align_to_sessions, compute_session_pnl - -# Strategy templates -from .strategies import ( - LongShortStrategy, - MeanReversionStrategy, - MomentumStrategy, - SignalFollowingStrategy, -) +from .risk.position.dynamic import TrailingStop +from .risk.position.static import StopLoss from .strategy import Strategy from .types import ( - AssetClass, - AssetTradingStats, - ContractSpec, ExecutionMode, ExitReason, Fill, @@ -143,10 +39,16 @@ ) __all__ = [ - # Types - "AssetClass", - "AssetTradingStats", - "ContractSpec", + # Core API + "DataFeed", + "Broker", + "Strategy", + "Engine", + "run_backtest", + "BacktestConfig", + "Mode", + "BacktestResult", + # Canonical domain types "OrderType", "OrderSide", "OrderStatus", @@ -158,100 +60,8 @@ "Position", "Fill", "Trade", - # Models - "CommissionModel", - "SlippageModel", - "NoCommission", - "PercentageCommission", - "PerShareCommission", - "TieredCommission", - "CombinedCommission", - "NoSlippage", - "FixedSlippage", - "PercentageSlippage", - "VolumeShareSlippage", - # Core - "DataFeed", - "Broker", - "Strategy", - "Engine", - "run_backtest", - "BacktestResult", - "BacktestExporter", - "enrich_trades_with_signals", - # Strategy templates - "SignalFollowingStrategy", - "MomentumStrategy", - "MeanReversionStrategy", - "LongShortStrategy", - # Session alignment - "SessionConfig", - "compute_session_pnl", - "align_to_sessions", - # Configuration - "BacktestConfig", - "Mode", - "StatsConfig", - "DataFrequency", - "FillTiming", - "ExecutionPrice", - "ShareType", - "FillOrdering", - "SignalProcessing", - "RebalanceMode", - "TrailStopTiming", - "WaterMarkSource", - "InitialHwmSource", - "PRESETS_DIR", - # Analytics - "EquityCurve", - "TradeAnalyzer", - "sharpe_ratio", - "sortino_ratio", - "calmar_ratio", - "max_drawdown", - "cagr", - "volatility", - # Bridge (diagnostic integration) - "to_trade_record", - "to_trade_records", - "to_returns_series", - "to_equity_dataframe", - # Calendar functions - "CALENDAR_ALIASES", - "get_calendar", - "get_schedule", - "get_trading_days", - "is_trading_day", - "is_market_open", - "next_trading_day", - "previous_trading_day", - "list_calendars", - "get_holidays", - "get_early_closes", - "filter_to_trading_days", - "filter_to_trading_sessions", - "generate_trading_minutes", - # Execution model - "ExecutionLimits", - "NoLimits", - "VolumeParticipationLimit", - "MarketImpactModel", - "NoImpact", - "LinearImpact", - "SquareRootImpact", - "ExecutionResult", - # Rebalancing - "RebalanceConfig", - "TargetWeightExecutor", - # Risk management rules (position-level) + # Risk rules "StopLoss", - "TimeExit", "TrailingStop", - "TighteningTrailingStop", - "VolatilityStop", - "VolatilityTrailingStop", - "ScaledExit", - "SignalExit", "RuleChain", ] diff --git a/src/ml4t/backtest/calendar.py b/src/ml4t/backtest/calendar.py index 9c3f3749..b0dd8a60 100644 --- a/src/ml4t/backtest/calendar.py +++ b/src/ml4t/backtest/calendar.py @@ -268,7 +268,7 @@ def is_trading_day(calendar_id: str, check_date: date | datetime | str) -> bool: calendar = get_calendar(calendar_id) # Convert to pandas Timestamp for comparison - if isinstance(check_date, (str, date, datetime)): + if isinstance(check_date, str | date | datetime): check_date = pd.Timestamp(check_date) valid_days = calendar.valid_days(start_date=check_date, end_date=check_date) diff --git a/src/ml4t/backtest/config.py b/src/ml4t/backtest/config.py index 0831c87e..a6b52075 100644 --- a/src/ml4t/backtest/config.py +++ b/src/ml4t/backtest/config.py @@ -28,6 +28,8 @@ import yaml +from .types import ExecutionMode, StopFillMode, StopLevelBasis + class FillTiming(str, Enum): """When orders are filled relative to signal generation.""" @@ -218,16 +220,6 @@ class TrailStopTiming(str, Enum): VBT_PRO = "vbt_pro" # Two-pass: LAGGED check, then INTRABAR check using CLOSE only -class ExecutionMode(str, Enum): - """Order execution timing mode. - - Controls when orders are eligible for execution relative to signal generation. - """ - - SAME_BAR = "same_bar" # Fill on same bar as order submission - NEXT_BAR = "next_bar" # Fill on next bar after order submission - - @dataclass class StatsConfig: """Configuration for per-asset trading statistics tracking. @@ -257,27 +249,6 @@ class StatsConfig: enabled: bool = True -class StopFillMode(str, Enum): - """Price used for stop order fills. - - Controls what price is used when a stop order triggers. - """ - - STOP_PRICE = "stop_price" # Fill at stop price (if not gapped) - CLOSE_PRICE = "close_price" # Fill at bar close (conservative) - NEXT_BAR_OPEN = "next_bar_open" # Fill at next bar's open - - -class StopLevelBasis(str, Enum): - """Reference price for calculating stop levels. - - Controls what price the stop percentage/amount is applied to. - """ - - FILL_PRICE = "fill_price" # Use actual fill price (most accurate) - SIGNAL_PRICE = "signal_price" # Use price at signal time (Backtrader style) - - @dataclass class BacktestConfig: """ @@ -525,8 +496,73 @@ def to_dict(self) -> dict: } @classmethod - def from_dict(cls, data: dict, preset_name: str | None = None) -> BacktestConfig: - """Create config from dictionary.""" + def from_dict( + cls, data: dict, preset_name: str | None = None, strict: bool = True + ) -> BacktestConfig: + """Create config from dictionary. + + Args: + data: Nested config dictionary + preset_name: Optional metadata label + strict: If True, reject unknown sections/keys + """ + if not isinstance(data, dict): + raise TypeError(f"Config data must be a dict, got {type(data).__name__}") + + if strict: + allowed_sections = { + "account", + "execution", + "stops", + "position_sizing", + "signals", + "commission", + "slippage", + "cash", + "orders", + } + unknown_sections = set(data) - allowed_sections + if unknown_sections: + raise ValueError(f"Unknown config section(s): {sorted(unknown_sections)}") + + allowed_keys_by_section = { + "account": { + "allow_short_selling", + "allow_leverage", + "initial_margin", + "long_maintenance_margin", + "short_maintenance_margin", + "fixed_margin_schedule", + }, + "execution": {"fill_timing", "execution_price", "execution_mode"}, + "stops": { + "stop_fill_mode", + "stop_level_basis", + "trail_hwm_source", + "initial_hwm_source", + "trail_stop_timing", + }, + "position_sizing": {"share_type", "default_position_pct"}, + "signals": {"signal_processing", "accumulate_positions"}, + "commission": {"model", "rate", "per_share", "per_trade", "minimum"}, + "slippage": {"model", "rate", "fixed", "stop_rate"}, + "cash": {"initial", "buffer_pct"}, + "orders": { + "reject_on_insufficient_cash", + "partial_fills_allowed", + "fill_ordering", + "rebalance_mode", + }, + } + for section, cfg in data.items(): + if not isinstance(cfg, dict): + raise TypeError(f"Section '{section}' must be a dict, got {type(cfg).__name__}") + unknown_keys = set(cfg) - allowed_keys_by_section[section] + if unknown_keys: + raise ValueError( + f"Unknown key(s) in section '{section}': {sorted(unknown_keys)}" + ) + acct_cfg = data.get("account", {}) exec_cfg = data.get("execution", {}) stops_cfg = data.get("stops", {}) @@ -601,7 +637,7 @@ def from_yaml(cls, path: str | Path) -> BacktestConfig: path = Path(path) with open(path) as f: data = yaml.safe_load(f) - return cls.from_dict(data, preset_name=path.stem) + return cls.from_dict(data, preset_name=path.stem, strict=True) @classmethod def from_preset(cls, preset: str) -> BacktestConfig: diff --git a/src/ml4t/backtest/presets/backtrader.yaml b/src/ml4t/backtest/presets/backtrader.yaml deleted file mode 100644 index 62b4feb5..00000000 --- a/src/ml4t/backtest/presets/backtrader.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# Backtrader-Compatible Configuration -# -# Use this preset to replicate Backtrader's default behavior. -# Key characteristics: -# - INTEGER shares (rounds down to whole shares) -# - Next-bar execution (no look-ahead bias) -# - Check position state before trading -# - Percentage-based commission - -execution: - fill_timing: next_bar_open - execution_price: open - -position_sizing: - share_type: integer # Backtrader uses whole shares - sizing_method: percent_of_portfolio - default_position_pct: 0.10 - -signals: - signal_processing: check_position - accumulate_positions: false - -commission: - model: percentage - rate: 0.001 # 0.1% - per_share: 0.0 - per_trade: 0.0 - minimum: 0.0 - -slippage: - model: percentage - rate: 0.001 # 0.1% - fixed: 0.0 - -cash: - initial: 100000.0 - allow_negative: false - buffer_pct: 0.0 - -orders: - reject_on_insufficient_cash: true - partial_fills_allowed: false - -account: - type: cash - margin_requirement: 0.5 diff --git a/src/ml4t/backtest/presets/default.yaml b/src/ml4t/backtest/presets/default.yaml deleted file mode 100644 index ea7271a5..00000000 --- a/src/ml4t/backtest/presets/default.yaml +++ /dev/null @@ -1,45 +0,0 @@ -# Default Configuration -# -# Balanced settings for general use. -# - Next-bar execution (realistic, no look-ahead) -# - Fractional shares (flexible) -# - Moderate costs -# - Check position state (event-driven behavior) - -execution: - fill_timing: next_bar_open - execution_price: open - -position_sizing: - share_type: fractional - sizing_method: percent_of_portfolio - default_position_pct: 0.10 - -signals: - signal_processing: check_position - accumulate_positions: false - -commission: - model: percentage - rate: 0.001 # 0.1% - per_share: 0.0 - per_trade: 0.0 - minimum: 0.0 - -slippage: - model: percentage - rate: 0.001 # 0.1% - fixed: 0.0 - -cash: - initial: 100000.0 - allow_negative: false - buffer_pct: 0.0 - -orders: - reject_on_insufficient_cash: true - partial_fills_allowed: false - -account: - type: cash - margin_requirement: 0.5 diff --git a/src/ml4t/backtest/presets/realistic.yaml b/src/ml4t/backtest/presets/realistic.yaml deleted file mode 100644 index 55760c6b..00000000 --- a/src/ml4t/backtest/presets/realistic.yaml +++ /dev/null @@ -1,48 +0,0 @@ -# Realistic Configuration -# -# Conservative settings for production-like simulation. -# - INTEGER shares (like real brokers) -# - Next-bar execution (no look-ahead) -# - Higher costs (pessimistic estimates) -# - Extra stop slippage (gaps hurt in fast markets) -# - Cash buffer (margin of safety) -# - Smaller positions (risk management) - -execution: - fill_timing: next_bar_open - execution_price: open - -position_sizing: - share_type: integer # Real brokers use whole shares - sizing_method: percent_of_portfolio - default_position_pct: 0.05 # Smaller positions for risk management - -signals: - signal_processing: check_position - accumulate_positions: false - -commission: - model: percentage - rate: 0.002 # 0.2% - higher to be conservative - per_share: 0.0 - per_trade: 0.0 - minimum: 1.0 # $1 minimum per trade - -slippage: - model: percentage - rate: 0.002 # 0.2% - higher to be conservative - fixed: 0.0 - stop_rate: 0.001 # Extra 0.1% for stop fills (gaps hurt in fast markets) - -cash: - initial: 100000.0 - allow_negative: false - buffer_pct: 0.02 # Keep 2% cash reserve - -orders: - reject_on_insufficient_cash: true - partial_fills_allowed: false - -account: - type: cash - margin_requirement: 0.5 diff --git a/src/ml4t/backtest/presets/vectorbt.yaml b/src/ml4t/backtest/presets/vectorbt.yaml deleted file mode 100644 index 87dea1ab..00000000 --- a/src/ml4t/backtest/presets/vectorbt.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# VectorBT-Compatible Configuration -# -# Use this preset to replicate VectorBT's default behavior. -# Key characteristics: -# - FRACTIONAL shares (allows 0.5, 1.234, etc.) -# - Same-bar execution (vectorized processing) -# - Process ALL signals (no position state check) -# - Percentage-based fees - -execution: - fill_timing: same_bar # VectorBT is vectorized - execution_price: close - -position_sizing: - share_type: fractional # VectorBT allows fractional shares - sizing_method: percent_of_portfolio - default_position_pct: 0.10 - -signals: - signal_processing: process_all # Process ALL signals - accumulate_positions: false # accumulate=False in VectorBT - -commission: - model: percentage - rate: 0.001 # fees parameter - per_share: 0.0 - per_trade: 0.0 - minimum: 0.0 - -slippage: - model: percentage - rate: 0.001 # slippage parameter - fixed: 0.0 - -cash: - initial: 100000.0 - allow_negative: false - buffer_pct: 0.0 - -orders: - reject_on_insufficient_cash: false # VectorBT is more permissive - partial_fills_allowed: true - -account: - type: cash - margin_requirement: 0.5 diff --git a/src/ml4t/backtest/presets/zipline.yaml b/src/ml4t/backtest/presets/zipline.yaml deleted file mode 100644 index 63857ba2..00000000 --- a/src/ml4t/backtest/presets/zipline.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# Zipline-Compatible Configuration -# -# Use this preset to replicate Zipline's default behavior. -# Key characteristics: -# - INTEGER shares (rounds down to whole shares) -# - Next-bar execution (order on bar N, fill on bar N+1) -# - Per-share commission (IB-style) -# - Volume-based slippage - -execution: - fill_timing: next_bar_open - execution_price: open - -position_sizing: - share_type: integer # Zipline uses whole shares - sizing_method: percent_of_portfolio - default_position_pct: 0.10 - -signals: - signal_processing: check_position - accumulate_positions: false - -commission: - model: per_share # Zipline uses per-share commission (IB-style) - rate: 0.0 - per_share: 0.005 # $0.005 per share (IB-style) - per_trade: 0.0 - minimum: 1.0 # $1 minimum per trade - -slippage: - model: volume_based # Zipline's signature feature - rate: 0.1 # 10% of bar volume - fixed: 0.0 - -cash: - initial: 100000.0 - allow_negative: false - buffer_pct: 0.0 - -orders: - reject_on_insufficient_cash: true - partial_fills_allowed: true # Volume-based = partial fills - -account: - type: cash - margin_requirement: 0.5 diff --git a/src/ml4t/backtest/result.py b/src/ml4t/backtest/result.py index 3c632ac2..f56e79bb 100644 --- a/src/ml4t/backtest/result.py +++ b/src/ml4t/backtest/result.py @@ -584,7 +584,7 @@ def to_parquet( # Filter to JSON-serializable metrics serializable = {} for k, v in self.metrics.items(): - if isinstance(v, (int, float, str, bool, type(None))): + if isinstance(v, int | float | str | bool | type(None)): serializable[k] = v elif isinstance(v, datetime): serializable[k] = v.isoformat() @@ -859,6 +859,7 @@ def enrich_trades_with_signals( signal_columns: list[str] | None = None, timestamp_col: str = "timestamp", asset_col: str | None = None, + trades_asset_col: str | None = None, ) -> pl.DataFrame: """Enrich trades DataFrame with signal values at entry/exit times via as-of join. @@ -880,6 +881,8 @@ def enrich_trades_with_signals( timestamp_col: Name of timestamp column in signals_df. asset_col: Name of asset column in signals_df for multi-asset signals. If None, assumes single-asset or already filtered. + trades_asset_col: Name of asset column in trades_df. + If None, auto-detects: "symbol" first, then "asset". Returns: Trades DataFrame with added columns: @@ -921,8 +924,32 @@ def enrich_trades_with_signals( # Preserve original trade order (join_asof requires sorting which disrupts order) trades_df = trades_df.with_row_index("_original_order") - # Ensure signals are sorted by timestamp for join_asof - signals_sorted = signals_df.sort(timestamp_col) + # Detect trade-side asset column when doing multi-asset enrichment + if asset_col and asset_col in signals_df.columns: + if trades_asset_col is None: + if "symbol" in trades_df.columns: + trades_asset_col = "symbol" + elif "asset" in trades_df.columns: + trades_asset_col = "asset" + else: + raise ValueError( + "Multi-asset enrichment requires trades_df to include 'symbol' or 'asset', " + "or set trades_asset_col explicitly." + ) + if trades_asset_col not in trades_df.columns: + raise ValueError(f"trades_asset_col '{trades_asset_col}' not found in trades_df") + + # Ensure sortedness for join_asof + signals_sorted = ( + signals_df.sort([asset_col, timestamp_col]) + if asset_col and asset_col in signals_df.columns + else signals_df.sort(timestamp_col) + ) + trades_sorted = ( + trades_df.sort([trades_asset_col, "entry_time"]) + if trades_asset_col + else trades_df.sort("entry_time") + ) # Join for entry signals entry_cols = [timestamp_col] + signal_columns @@ -935,17 +962,18 @@ def enrich_trades_with_signals( if asset_col and asset_col in signals_df.columns: # Multi-asset: join on both timestamp and asset - result = trades_df.join_asof( + result = trades_sorted.join_asof( entry_signals, left_on="entry_time", right_on=timestamp_col, - by_left="asset", + by_left=trades_asset_col, by_right=asset_col, strategy="backward", + check_sortedness=False, ) else: # Single-asset: join on timestamp only - result = trades_df.join_asof( + result = trades_sorted.join_asof( entry_signals, left_on="entry_time", right_on=timestamp_col, @@ -956,18 +984,24 @@ def enrich_trades_with_signals( exit_signals = signals_sorted.select(entry_cols) exit_rename = {c: f"exit_{c}" for c in signal_columns} exit_signals = exit_signals.rename(exit_rename) + result_for_exit = ( + result.sort([trades_asset_col, "exit_time"]) + if trades_asset_col + else result.sort("exit_time") + ) if asset_col and asset_col in signals_df.columns: - result = result.join_asof( + result = result_for_exit.join_asof( exit_signals, left_on="exit_time", right_on=timestamp_col, - by_left="asset", + by_left=trades_asset_col, by_right=asset_col, strategy="backward", + check_sortedness=False, ) else: - result = result.join_asof( + result = result_for_exit.join_asof( exit_signals, left_on="exit_time", right_on=timestamp_col, diff --git a/src/ml4t/backtest/types.py b/src/ml4t/backtest/types.py index a0374ff9..6de38975 100644 --- a/src/ml4t/backtest/types.py +++ b/src/ml4t/backtest/types.py @@ -29,14 +29,14 @@ class OrderStatus(Enum): REJECTED = "rejected" -class ExecutionMode(Enum): +class ExecutionMode(str, Enum): """Order execution timing mode.""" SAME_BAR = "same_bar" # Orders fill at current bar's close (default) NEXT_BAR = "next_bar" # Orders fill at next bar's open (like Backtrader) -class StopFillMode(Enum): +class StopFillMode(str, Enum): """Stop/take-profit fill price mode. Different frameworks handle stop order fills differently: @@ -115,7 +115,7 @@ class ExitReason(str, Enum): END_OF_DATA = "end_of_data" # Backtest ended with open position -class StopLevelBasis(Enum): +class StopLevelBasis(str, Enum): """Basis for calculating stop/take-profit levels. Different frameworks calculate stop levels from different reference prices: From 9d90e02d0fdd7e18d8006ed89771d625b9532395 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 08:51:11 -0500 Subject: [PATCH 06/24] refactor: extract broker orchestration into core components --- src/ml4t/backtest/broker.py | 322 ++------------------- src/ml4t/backtest/core/__init__.py | 16 + src/ml4t/backtest/core/execution_engine.py | 136 +++++++++ src/ml4t/backtest/core/order_book.py | 102 +++++++ src/ml4t/backtest/core/portfolio_ledger.py | 31 ++ src/ml4t/backtest/core/risk_engine.py | 119 ++++++++ src/ml4t/backtest/core/shared.py | 31 ++ 7 files changed, 464 insertions(+), 293 deletions(-) create mode 100644 src/ml4t/backtest/core/__init__.py create mode 100644 src/ml4t/backtest/core/execution_engine.py create mode 100644 src/ml4t/backtest/core/order_book.py create mode 100644 src/ml4t/backtest/core/portfolio_ledger.py create mode 100644 src/ml4t/backtest/core/risk_engine.py create mode 100644 src/ml4t/backtest/core/shared.py diff --git a/src/ml4t/backtest/broker.py b/src/ml4t/backtest/broker.py index 1c7e5a88..4532c11e 100644 --- a/src/ml4t/backtest/broker.py +++ b/src/ml4t/backtest/broker.py @@ -3,13 +3,9 @@ from __future__ import annotations from collections import deque -from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any -from .config import ( - ExecutionMode as ConfigExecutionMode, -) from .config import ( FillOrdering, InitialHwmSource, @@ -18,11 +14,12 @@ TrailStopTiming, WaterMarkSource, ) -from .config import ( - StopFillMode as ConfigStopFillMode, -) -from .config import ( - StopLevelBasis as ConfigStopLevelBasis, +from .core import ( + ExecutionEngine, + OrderBook, + PortfolioLedger, + RiskEngine, + SubmitOrderOptions, ) from .execution.fill_executor import FillExecutor from .models import CommissionModel, NoCommission, NoSlippage, SlippageModel @@ -30,7 +27,6 @@ AssetTradingStats, ContractSpec, ExecutionMode, - ExitReason, Fill, Order, OrderSide, @@ -48,48 +44,6 @@ from .execution import ExecutionLimits, MarketImpactModel -@dataclass -class _SubmitOrderOptions: - """Internal options for submit_order behavior. - - Used to control special cases like deferred exits that need - to bypass the normal NEXT_BAR mode order deferral. - """ - - eligible_in_next_bar_mode: bool = False - """If True, order is eligible for immediate execution even in NEXT_BAR mode. - - This is used for deferred exits that should execute at the next bar's open, - not be deferred to the bar after that. - """ - - -def _reason_to_exit_reason(reason: str) -> ExitReason: - """Map reason string to ExitReason enum. - - Used when setting order._exit_reason from risk rule action.reason. - - Args: - reason: Human-readable reason string (e.g., "stop_loss_5.0%") - - Returns: - Corresponding ExitReason enum value - """ - reason_lower = reason.lower() - if "stop_loss" in reason_lower: - return ExitReason.STOP_LOSS - elif "take_profit" in reason_lower: - return ExitReason.TAKE_PROFIT - elif "trailing" in reason_lower: - return ExitReason.TRAILING_STOP - elif "time" in reason_lower: - return ExitReason.TIME_STOP - elif "end_of_data" in reason_lower: - return ExitReason.END_OF_DATA - else: - return ExitReason.SIGNAL - - class Broker: """Broker interface - same for backtest and live trading.""" @@ -224,6 +178,12 @@ def __init__( self._session_config = None # Optional SessionConfig for session boundary detection self._last_session_id: int | None = None # Track current session for boundary detection + # Extracted orchestration components (Phase B1 alpha-reset) + self._order_book = OrderBook(self) + self._risk_engine = RiskEngine(self) + self._execution_engine = ExecutionEngine(self) + self._portfolio_ledger = PortfolioLedger(self) + @classmethod def from_config( cls, @@ -282,38 +242,14 @@ def from_config( elif config.slippage_model == SlipModelEnum.NONE: slippage_model = NoSlippage() - # Map config execution mode to types.ExecutionMode - execution_mode = ( - ExecutionMode.SAME_BAR - if config.execution_mode == ConfigExecutionMode.SAME_BAR - else ExecutionMode.NEXT_BAR - ) - - # Map config stop fill mode to types.StopFillMode - stop_fill_mode_map = { - ConfigStopFillMode.STOP_PRICE: StopFillMode.STOP_PRICE, - ConfigStopFillMode.CLOSE_PRICE: StopFillMode.CLOSE_PRICE, - ConfigStopFillMode.NEXT_BAR_OPEN: StopFillMode.NEXT_BAR_OPEN, - } - stop_fill_mode = stop_fill_mode_map.get(config.stop_fill_mode, StopFillMode.STOP_PRICE) - - # Map config stop level basis to types.StopLevelBasis - stop_level_basis_map = { - ConfigStopLevelBasis.FILL_PRICE: StopLevelBasis.FILL_PRICE, - ConfigStopLevelBasis.SIGNAL_PRICE: StopLevelBasis.SIGNAL_PRICE, - } - stop_level_basis = stop_level_basis_map.get( - config.stop_level_basis, StopLevelBasis.FILL_PRICE - ) - return cls( initial_cash=config.initial_cash, commission_model=commission_model, slippage_model=slippage_model, stop_slippage_rate=config.stop_slippage_rate, - execution_mode=execution_mode, - stop_fill_mode=stop_fill_mode, - stop_level_basis=stop_level_basis, + execution_mode=config.execution_mode, + stop_fill_mode=config.stop_fill_mode, + stop_level_basis=config.stop_level_basis, trail_hwm_source=config.trail_hwm_source, initial_hwm_source=config.initial_hwm_source, trail_stop_timing=config.trail_stop_timing, @@ -554,12 +490,7 @@ def get_cash(self) -> float: def get_account_value(self) -> float: """Calculate total account value (cash + position values).""" - value = self.cash - for asset, pos in self.positions.items(): - price = self._current_prices.get(asset, pos.entry_price) - multiplier = self.get_multiplier(asset) - value += pos.quantity * price * multiplier - return value + return self._portfolio_ledger.get_account_value() def get_rejected_orders(self, asset: str | None = None) -> list[Order]: """Get all rejected orders, optionally filtered by asset. @@ -570,10 +501,7 @@ def get_rejected_orders(self, asset: str | None = None) -> list[Order]: Returns: List of rejected Order objects with rejection_reason populated """ - rejected = [o for o in self.orders if o.status == OrderStatus.REJECTED] - if asset is not None: - rejected = [o for o in rejected if o.asset == asset] - return rejected + return self._portfolio_ledger.get_rejected_orders(asset=asset) @property def last_rejection_reason(self) -> str | None: @@ -582,8 +510,7 @@ def last_rejection_reason(self) -> str | None: Returns: Rejection reason string, or None if no orders have been rejected """ - rejected = [o for o in self.orders if o.status == OrderStatus.REJECTED] - return rejected[-1].rejection_reason if rejected else None + return self._portfolio_ledger.last_rejection_reason # === Risk Management === @@ -665,82 +592,7 @@ def evaluate_position_rules(self) -> list[Order]: Called by Engine before processing orders. Returns list of exit orders. Handles defer_fill=True by storing pending exits for next bar. """ - from .risk.types import ActionType - - exit_orders = [] - - for asset, pos in list(self.positions.items()): - rules = self._get_position_rules(asset) - if rules is None: - continue - - price = self._current_prices.get(asset) - if price is None: - continue - - # Build state and evaluate - state = self._build_position_state(pos, price) - action = rules.evaluate(state) - - if action.action == ActionType.EXIT_FULL: - if action.defer_fill: - # NEXT_BAR_OPEN mode: defer exit to next bar - # Store pending exit info (will be processed at next bar's open) - self._pending_exits[asset] = { - "reason": action.reason, - "pct": 1.0, - "quantity": pos.quantity, - "fill_price": action.fill_price, # Preserve for STOP_PRICE mode - } - else: - # Generate full exit order immediately - # For STOP_PRICE mode, risk exits should fill on trigger bar even in NEXT_BAR mode - order = self.submit_order( - asset, - -pos.quantity, - order_type=OrderType.MARKET, - _options=_SubmitOrderOptions(eligible_in_next_bar_mode=True), - ) - if order: - order._risk_exit_reason = action.reason - order._exit_reason = _reason_to_exit_reason(action.reason) - # Store fill price for stop/limit triggered exits - # This is the price at which the stop/limit was triggered - order._risk_fill_price = action.fill_price - exit_orders.append(order) - # VBT Pro compatibility: prevent same-bar re-entry - self._stop_exits_this_bar.add(asset) - - elif action.action == ActionType.EXIT_PARTIAL: - if action.defer_fill: - # NEXT_BAR_OPEN mode: defer partial exit to next bar - exit_qty = abs(pos.quantity) * action.pct - if exit_qty > 0: - self._pending_exits[asset] = { - "reason": action.reason, - "pct": action.pct, - "quantity": exit_qty if pos.quantity > 0 else -exit_qty, - "fill_price": action.fill_price, # Preserve for STOP_PRICE mode - } - else: - # Generate partial exit order immediately - # For STOP_PRICE mode, risk exits should fill on trigger bar even in NEXT_BAR mode - exit_qty = abs(pos.quantity) * action.pct - if exit_qty > 0: - actual_qty = -exit_qty if pos.quantity > 0 else exit_qty - order = self.submit_order( - asset, - actual_qty, - order_type=OrderType.MARKET, - _options=_SubmitOrderOptions(eligible_in_next_bar_mode=True), - ) - if order: - order._risk_exit_reason = action.reason - order._exit_reason = _reason_to_exit_reason(action.reason) - order._risk_fill_price = action.fill_price - exit_orders.append(order) - - return exit_orders + return self._risk_engine.evaluate_position_rules() def submit_order( self, @@ -751,7 +603,7 @@ def submit_order( limit_price: float | None = None, stop_price: float | None = None, trail_amount: float | None = None, - _options: _SubmitOrderOptions | None = None, + _options: SubmitOrderOptions | None = None, ) -> Order | None: """Submit a new order to the broker. @@ -787,54 +639,17 @@ def submit_order( order = broker.submit_order("AAPL", -100, order_type=OrderType.STOP, stop_price=145.0) """ - if side is None: - if quantity == 0: - return None - side = OrderSide.BUY if quantity > 0 else OrderSide.SELL - # Always normalize quantity to positive (Bug #3 fix) - quantity = abs(quantity) - if quantity == 0: - return None - - # VBT Pro compatibility: prevent same-bar re-entry after stop exit - # When a stop/trail exit occurs, don't allow new entry until next bar - # This applies to BOTH BUY (long entry) AND SELL (short entry) orders - # when there's no existing position (i.e., this is a new entry, not a close) - if asset in self._stop_exits_this_bar: - # Check if this is a new entry (no existing position) - existing_pos = self.positions.get(asset) - if existing_pos is None: - return None # Silently reject entry on same bar as stop exit - - self._order_counter += 1 - order = Order( + return self._order_book.submit_order( asset=asset, - side=side, quantity=quantity, + side=side, order_type=order_type, limit_price=limit_price, stop_price=stop_price, trail_amount=trail_amount, - order_id=f"ORD-{self._order_counter}", - created_at=self._current_time, + options=_options, ) - # Capture signal price (close at order time) for stop level calculation - # This is used when stop_level_basis is SIGNAL_PRICE (Backtrader behavior) - order._signal_price = self._current_prices.get(asset) - - self.orders.append(order) - self.pending_orders.append(order) - - # Track orders placed this bar for next-bar execution mode - # Bug #1 fix: Allow eligible orders (e.g., deferred exits) to skip this tracking - if self.execution_mode == ExecutionMode.NEXT_BAR and ( - _options is None or not _options.eligible_in_next_bar_mode - ): - self._orders_this_bar.append(order) - - return order - def submit_bracket( self, asset: str, @@ -939,16 +754,6 @@ def submit_bracket( return entry, tp, sl - # Phase 4.2: Whitelist updatable order fields to prevent mutation of immutable fields - _UPDATABLE_ORDER_FIELDS: frozenset[str] = frozenset( - { - "quantity", - "limit_price", - "stop_price", - "trail_amount", - } - ) - def update_order(self, order_id: str, **kwargs) -> bool: """Update pending order parameters. @@ -968,28 +773,10 @@ def update_order(self, order_id: str, **kwargs) -> bool: Raises: ValueError: If attempting to update non-updatable fields """ - # Validate all fields are updatable - invalid_fields = set(kwargs.keys()) - self._UPDATABLE_ORDER_FIELDS - if invalid_fields: - raise ValueError( - f"Cannot update order fields: {invalid_fields}. " - f"Updatable fields: {sorted(self._UPDATABLE_ORDER_FIELDS)}" - ) - - for order in self.pending_orders: - if order.order_id == order_id: - for key, value in kwargs.items(): - setattr(order, key, value) - return True - return False + return self._order_book.update_order(order_id, **kwargs) def cancel_order(self, order_id: str) -> bool: - for order in self.pending_orders: - if order.order_id == order_id: - order.status = OrderStatus.CANCELLED - self.pending_orders.remove(order) - return True - return False + return self._order_book.cancel_order(order_id) def close_position(self, asset: str) -> Order | None: """Close an open position for the given asset. @@ -1496,16 +1283,11 @@ def rebalance_to_weights( def get_order(self, order_id: str) -> Order | None: """Get order by ID.""" - for order in self.orders: - if order.order_id == order_id: - return order - return None + return self._order_book.get_order(order_id) def get_pending_orders(self, asset: str | None = None) -> list[Order]: """Get pending orders, optionally filtered by asset.""" - if asset is None: - return list(self.pending_orders) - return [o for o in self.pending_orders if o.asset == asset] + return self._order_book.get_pending_orders(asset=asset) def _is_exit_order(self, order: Order) -> bool: """Check if order is an exit (reducing existing position). @@ -1552,50 +1334,7 @@ def _process_pending_exits(self) -> list[Order]: Returns list of exit orders that were created and will be filled. """ - exit_orders = [] - - for asset, pending in list(self._pending_exits.items()): - pos = self.positions.get(asset) - if pos is None: - # Position no longer exists (shouldn't happen normally) - del self._pending_exits[asset] - continue - - open_price = self._current_opens.get(asset) - if open_price is None: - # No open price available, skip this bar - continue - - # Determine fill price based on stop_fill_mode - stored_fill_price = pending.get("fill_price") - if self.stop_fill_mode == StopFillMode.STOP_PRICE and stored_fill_price is not None: - # Use the original stop price, but check for gap-through - exit_side = OrderSide.SELL if pending["quantity"] > 0 else OrderSide.BUY - gap_price = self._check_gap_through(exit_side, stored_fill_price, open_price) - fill_price = gap_price if gap_price is not None else stored_fill_price - else: - # Default: fill at open price - fill_price = open_price - - # Create exit order - # Bug #1 fix: Pass eligible_in_next_bar_mode=True so exit executes this bar - exit_qty = pending["quantity"] - order = self.submit_order( - asset, - -exit_qty, - order_type=OrderType.MARKET, - _options=_SubmitOrderOptions(eligible_in_next_bar_mode=True), - ) - if order: - order._risk_exit_reason = pending["reason"] - order._exit_reason = _reason_to_exit_reason(pending["reason"]) - order._risk_fill_price = fill_price - exit_orders.append(order) - - # Remove from pending - del self._pending_exits[asset] - - return exit_orders + return self._risk_engine.process_pending_exits() def _update_time( self, @@ -1729,10 +1468,7 @@ def _process_orders(self, use_open: bool = False): Args: use_open: If True, use open prices (for next-bar mode at bar start). """ - if self.fill_ordering == FillOrdering.EXIT_FIRST: - self._process_orders_exit_first(use_open) - else: - self._process_orders_fifo(use_open) + self._execution_engine.process_orders(use_open=use_open) def _get_fill_price_for_order(self, order: Order, use_open: bool) -> float | None: """Get the fill price for an order based on execution mode.""" diff --git a/src/ml4t/backtest/core/__init__.py b/src/ml4t/backtest/core/__init__.py new file mode 100644 index 00000000..906b5224 --- /dev/null +++ b/src/ml4t/backtest/core/__init__.py @@ -0,0 +1,16 @@ +"""Core orchestration components for alpha-reset architecture.""" + +from .execution_engine import ExecutionEngine +from .order_book import OrderBook +from .portfolio_ledger import PortfolioLedger +from .risk_engine import RiskEngine +from .shared import SubmitOrderOptions, reason_to_exit_reason + +__all__ = [ + "ExecutionEngine", + "OrderBook", + "PortfolioLedger", + "RiskEngine", + "SubmitOrderOptions", + "reason_to_exit_reason", +] diff --git a/src/ml4t/backtest/core/execution_engine.py b/src/ml4t/backtest/core/execution_engine.py new file mode 100644 index 00000000..0f7dc957 --- /dev/null +++ b/src/ml4t/backtest/core/execution_engine.py @@ -0,0 +1,136 @@ +"""Order execution sequencing extracted from Broker.""" + +from __future__ import annotations + +from ..types import OrderStatus + + +class ExecutionEngine: + """Executes pending orders using configured fill ordering.""" + + def __init__(self, broker): + self.broker = broker + + def process_orders(self, use_open: bool = False): + if self.broker.fill_ordering.value == "exit_first": + self._process_orders_exit_first(use_open) + else: + self._process_orders_fifo(use_open) + + def _process_orders_exit_first(self, use_open: bool = False): + broker = self.broker + exit_orders = [] + entry_orders = [] + + for order in broker.pending_orders[:]: + if broker.execution_mode.value == "next_bar" and order in broker._orders_this_bar: + continue + if broker._is_exit_order(order): + exit_orders.append(order) + else: + entry_orders.append(order) + + filled_orders: list = [] + + for order in exit_orders: + price = broker._get_fill_price_for_order(order, use_open) + if price is None: + continue + fill_price = broker._check_fill(order, price) + if fill_price is not None: + fully_filled = broker._execute_fill(order, fill_price) + if fully_filled: + filled_orders.append(order) + broker._partial_orders.pop(order.order_id, None) + else: + broker._update_partial_order(order) + + broker.account.mark_to_market(broker._current_prices) + + for order in entry_orders: + self._process_single_order(order, use_open, filled_orders) + + self._cleanup_filled_orders(filled_orders) + + def _process_orders_fifo(self, use_open: bool = False): + broker = self.broker + eligible_orders = [] + for order in broker.pending_orders[:]: + if broker.execution_mode.value == "next_bar" and order in broker._orders_this_bar: + continue + eligible_orders.append(order) + + filled_orders: list = [] + + for order in eligible_orders: + self._process_single_order(order, use_open, filled_orders) + if filled_orders and filled_orders[-1] is order: + broker.account.mark_to_market(broker._current_prices) + + self._cleanup_filled_orders(filled_orders) + + def _process_single_order(self, order, use_open: bool, filled_orders: list) -> None: + broker = self.broker + price = broker._get_fill_price_for_order(order, use_open) + if price is None: + return + + is_exit = broker._is_exit_order(order) + + if is_exit: + fill_price = broker._check_fill(order, price) + if fill_price is not None: + fully_filled = broker._execute_fill(order, fill_price) + if fully_filled: + filled_orders.append(order) + broker._partial_orders.pop(order.order_id, None) + else: + broker._update_partial_order(order) + else: + broker._apply_share_rounding(order) + if order.quantity <= 0: + order.status = OrderStatus.REJECTED + order.rejection_reason = "Quantity rounds to zero (share_type=INTEGER)" + return + + fill_price = broker._check_fill(order, price) + if fill_price is None: + return + + valid, rejection_reason = broker.gatekeeper.validate_order(order, fill_price) + + if valid: + fully_filled = broker._execute_fill(order, fill_price) + if fully_filled: + filled_orders.append(order) + broker._partial_orders.pop(order.order_id, None) + else: + broker._update_partial_order(order) + elif ( + not broker.reject_on_insufficient_cash and "insufficient" in rejection_reason.lower() + ): + if broker.partial_fills_allowed and broker._try_partial_fill(order, fill_price): + filled_orders.append(order) + broker._partial_orders.pop(order.order_id, None) + elif broker.partial_fills_allowed and "insufficient" in rejection_reason.lower(): + if broker._try_partial_fill(order, fill_price): + filled_orders.append(order) + broker._partial_orders.pop(order.order_id, None) + else: + order.status = OrderStatus.REJECTED + order.rejection_reason = rejection_reason + else: + order.status = OrderStatus.REJECTED + order.rejection_reason = rejection_reason + + def _cleanup_filled_orders(self, filled_orders: list) -> None: + broker = self.broker + for order in filled_orders: + if order in broker.pending_orders: + broker.pending_orders.remove(order) + if order in broker._orders_this_bar: + broker._orders_this_bar.remove(order) + + for order in broker.pending_orders[:]: + if order.status == OrderStatus.REJECTED: + broker.pending_orders.remove(order) diff --git a/src/ml4t/backtest/core/order_book.py b/src/ml4t/backtest/core/order_book.py new file mode 100644 index 00000000..40214593 --- /dev/null +++ b/src/ml4t/backtest/core/order_book.py @@ -0,0 +1,102 @@ +"""Order-book operations extracted from Broker.""" + +from __future__ import annotations + +from ..types import Order, OrderSide, OrderStatus, OrderType +from .shared import SubmitOrderOptions + + +class OrderBook: + """Handles order submission/mutation/retrieval.""" + + _UPDATABLE_ORDER_FIELDS: frozenset[str] = frozenset( + {"quantity", "limit_price", "stop_price", "trail_amount"} + ) + + def __init__(self, broker): + self.broker = broker + + def submit_order( + self, + asset: str, + quantity: float, + side: OrderSide | None = None, + order_type: OrderType = OrderType.MARKET, + limit_price: float | None = None, + stop_price: float | None = None, + trail_amount: float | None = None, + options: SubmitOrderOptions | None = None, + ) -> Order | None: + broker = self.broker + + if side is None: + if quantity == 0: + return None + side = OrderSide.BUY if quantity > 0 else OrderSide.SELL + quantity = abs(quantity) + if quantity == 0: + return None + + if asset in broker._stop_exits_this_bar: + existing_pos = broker.positions.get(asset) + if existing_pos is None: + return None + + broker._order_counter += 1 + order = Order( + asset=asset, + side=side, + quantity=quantity, + order_type=order_type, + limit_price=limit_price, + stop_price=stop_price, + trail_amount=trail_amount, + order_id=f"ORD-{broker._order_counter}", + created_at=broker._current_time, + ) + + order._signal_price = broker._current_prices.get(asset) + + broker.orders.append(order) + broker.pending_orders.append(order) + + if broker.execution_mode.value == "next_bar" and ( + options is None or not options.eligible_in_next_bar_mode + ): + broker._orders_this_bar.append(order) + + return order + + def update_order(self, order_id: str, **kwargs) -> bool: + invalid_fields = set(kwargs.keys()) - self._UPDATABLE_ORDER_FIELDS + if invalid_fields: + raise ValueError( + f"Cannot update order fields: {invalid_fields}. " + f"Updatable fields: {sorted(self._UPDATABLE_ORDER_FIELDS)}" + ) + + for order in self.broker.pending_orders: + if order.order_id == order_id: + for key, value in kwargs.items(): + setattr(order, key, value) + return True + return False + + def cancel_order(self, order_id: str) -> bool: + for order in self.broker.pending_orders: + if order.order_id == order_id: + order.status = OrderStatus.CANCELLED + self.broker.pending_orders.remove(order) + return True + return False + + def get_order(self, order_id: str) -> Order | None: + for order in self.broker.orders: + if order.order_id == order_id: + return order + return None + + def get_pending_orders(self, asset: str | None = None) -> list[Order]: + if asset is None: + return list(self.broker.pending_orders) + return [o for o in self.broker.pending_orders if o.asset == asset] diff --git a/src/ml4t/backtest/core/portfolio_ledger.py b/src/ml4t/backtest/core/portfolio_ledger.py new file mode 100644 index 00000000..e9820027 --- /dev/null +++ b/src/ml4t/backtest/core/portfolio_ledger.py @@ -0,0 +1,31 @@ +"""Portfolio/account view helpers extracted from Broker.""" + +from __future__ import annotations + + +class PortfolioLedger: + """Read-model helpers for account/portfolio state.""" + + def __init__(self, broker): + self.broker = broker + + def get_account_value(self) -> float: + value = self.broker.cash + for asset, pos in self.broker.positions.items(): + price = self.broker._current_prices.get(asset) + if price is None: + continue + multiplier = self.broker.get_multiplier(asset) + value += pos.quantity * price * multiplier + return value + + def get_rejected_orders(self, asset: str | None = None): + rejected = [o for o in self.broker.orders if o.status.value == "rejected"] + if asset is not None: + rejected = [o for o in rejected if o.asset == asset] + return rejected + + @property + def last_rejection_reason(self): + rejected = [o for o in self.broker.orders if o.status.value == "rejected"] + return rejected[-1].rejection_reason if rejected else None diff --git a/src/ml4t/backtest/core/risk_engine.py b/src/ml4t/backtest/core/risk_engine.py new file mode 100644 index 00000000..18b686bc --- /dev/null +++ b/src/ml4t/backtest/core/risk_engine.py @@ -0,0 +1,119 @@ +"""Risk-rule orchestration extracted from Broker.""" + +from __future__ import annotations + +from ..types import OrderSide, OrderType +from ..risk.types import ActionType +from .shared import SubmitOrderOptions, reason_to_exit_reason + + +class RiskEngine: + """Evaluates position rules and manages deferred exits.""" + + def __init__(self, broker): + self.broker = broker + + def evaluate_position_rules(self): + broker = self.broker + exit_orders = [] + + for asset, pos in list(broker.positions.items()): + rules = broker._get_position_rules(asset) + if rules is None: + continue + + price = broker._current_prices.get(asset) + if price is None: + continue + + state = broker._build_position_state(pos, price) + action = rules.evaluate(state) + + if action.action == ActionType.EXIT_FULL: + if action.defer_fill: + broker._pending_exits[asset] = { + "reason": action.reason, + "pct": 1.0, + "quantity": pos.quantity, + "fill_price": action.fill_price, + } + else: + order = broker.submit_order( + asset, + -pos.quantity, + order_type=OrderType.MARKET, + _options=SubmitOrderOptions(eligible_in_next_bar_mode=True), + ) + if order: + order._risk_exit_reason = action.reason + order._exit_reason = reason_to_exit_reason(action.reason) + order._risk_fill_price = action.fill_price + exit_orders.append(order) + broker._stop_exits_this_bar.add(asset) + + elif action.action == ActionType.EXIT_PARTIAL: + if action.defer_fill: + exit_qty = abs(pos.quantity) * action.pct + if exit_qty > 0: + broker._pending_exits[asset] = { + "reason": action.reason, + "pct": action.pct, + "quantity": exit_qty if pos.quantity > 0 else -exit_qty, + "fill_price": action.fill_price, + } + else: + exit_qty = abs(pos.quantity) * action.pct + if exit_qty > 0: + actual_qty = -exit_qty if pos.quantity > 0 else exit_qty + order = broker.submit_order( + asset, + actual_qty, + order_type=OrderType.MARKET, + _options=SubmitOrderOptions(eligible_in_next_bar_mode=True), + ) + if order: + order._risk_exit_reason = action.reason + order._exit_reason = reason_to_exit_reason(action.reason) + order._risk_fill_price = action.fill_price + exit_orders.append(order) + + return exit_orders + + def process_pending_exits(self): + broker = self.broker + exit_orders = [] + + for asset, pending in list(broker._pending_exits.items()): + pos = broker.positions.get(asset) + if pos is None: + del broker._pending_exits[asset] + continue + + open_price = broker._current_opens.get(asset) + if open_price is None: + continue + + stored_fill_price = pending.get("fill_price") + if broker.stop_fill_mode.value == "stop_price" and stored_fill_price is not None: + exit_side = OrderSide.SELL if pending["quantity"] > 0 else OrderSide.BUY + gap_price = broker._check_gap_through(exit_side, stored_fill_price, open_price) + fill_price = gap_price if gap_price is not None else stored_fill_price + else: + fill_price = open_price + + exit_qty = pending["quantity"] + order = broker.submit_order( + asset, + -exit_qty, + order_type=OrderType.MARKET, + _options=SubmitOrderOptions(eligible_in_next_bar_mode=True), + ) + if order: + order._risk_exit_reason = pending["reason"] + order._exit_reason = reason_to_exit_reason(pending["reason"]) + order._risk_fill_price = fill_price + exit_orders.append(order) + + del broker._pending_exits[asset] + + return exit_orders diff --git a/src/ml4t/backtest/core/shared.py b/src/ml4t/backtest/core/shared.py new file mode 100644 index 00000000..b2158596 --- /dev/null +++ b/src/ml4t/backtest/core/shared.py @@ -0,0 +1,31 @@ +"""Shared core helpers for broker decomposition.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..types import ExitReason + + +@dataclass +class SubmitOrderOptions: + """Internal options for submit_order behavior.""" + + eligible_in_next_bar_mode: bool = False + + +def reason_to_exit_reason(reason: str) -> ExitReason: + """Map human-readable rule reason to typed ExitReason.""" + reason_lower = reason.lower() + if "stop_loss" in reason_lower: + return ExitReason.STOP_LOSS + elif "take_profit" in reason_lower: + return ExitReason.TAKE_PROFIT + elif "trailing" in reason_lower: + return ExitReason.TRAILING_STOP + elif "time" in reason_lower: + return ExitReason.TIME_STOP + elif "end_of_data" in reason_lower: + return ExitReason.END_OF_DATA + else: + return ExitReason.SIGNAL From 12df7b57aac3ad57d354e91970a81633f92845c0 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 09:03:25 -0500 Subject: [PATCH 07/24] feat: centralize engine profiles and wire validation harness --- src/ml4t/backtest/__init__.py | 35 ++- src/ml4t/backtest/config.py | 239 +------------------ src/ml4t/backtest/core/execution_engine.py | 3 +- src/ml4t/backtest/core/risk_engine.py | 2 +- src/ml4t/backtest/profiles.py | 259 +++++++++++++++++++++ src/ml4t/backtest/result.py | 13 ++ src/ml4t/backtest/types.py | 5 + validation/cross_impl_benchmark.py | 35 +-- validation/run_all_correctness.py | 14 ++ 9 files changed, 350 insertions(+), 255 deletions(-) create mode 100644 src/ml4t/backtest/profiles.py diff --git a/src/ml4t/backtest/__init__.py b/src/ml4t/backtest/__init__.py index 27ab7f61..45c76863 100644 --- a/src/ml4t/backtest/__init__.py +++ b/src/ml4t/backtest/__init__.py @@ -14,9 +14,26 @@ __version__ = "0.0.0.dev0" from .broker import Broker -from .config import BacktestConfig, Mode +from .config import ( + BacktestConfig, + InitialHwmSource, + Mode, + RebalanceMode, + WaterMarkSource, +) from .datafeed import DataFeed from .engine import Engine, run_backtest +from .execution.impact import LinearImpact +from .execution.limits import VolumeParticipationLimit +from .execution.rebalancer import RebalanceConfig, TargetWeightExecutor +from .models import ( + FixedSlippage, + NoCommission, + NoSlippage, + PercentageCommission, + PercentageSlippage, + PerShareCommission, +) from .result import BacktestResult # Risk management rules (position-level) @@ -38,6 +55,8 @@ Trade, ) +TrailHwmSource = WaterMarkSource + __all__ = [ # Core API "DataFeed", @@ -48,6 +67,20 @@ "BacktestConfig", "Mode", "BacktestResult", + "NoCommission", + "PercentageCommission", + "PerShareCommission", + "NoSlippage", + "FixedSlippage", + "PercentageSlippage", + "VolumeParticipationLimit", + "LinearImpact", + "RebalanceConfig", + "TargetWeightExecutor", + "WaterMarkSource", + "TrailHwmSource", + "InitialHwmSource", + "RebalanceMode", # Canonical domain types "OrderType", "OrderSide", diff --git a/src/ml4t/backtest/config.py b/src/ml4t/backtest/config.py index a6b52075..ce6c6f66 100644 --- a/src/ml4t/backtest/config.py +++ b/src/ml4t/backtest/config.py @@ -651,243 +651,10 @@ def from_preset(cls, preset: str) -> BacktestConfig: - "zipline": Match Zipline's default behavior - "realistic": Conservative settings for realistic simulation """ - presets = { - "default": cls._default_preset(), - "backtrader": cls._backtrader_preset(), - "vectorbt": cls._vectorbt_preset(), - "zipline": cls._zipline_preset(), - "realistic": cls._realistic_preset(), - } - - if preset not in presets: - available = ", ".join(presets.keys()) - raise ValueError(f"Unknown preset '{preset}'. Available: {available}") - - config = presets[preset] - config.preset_name = preset - return config - - @classmethod - def _default_preset(cls) -> BacktestConfig: - """Default configuration - balanced between realism and ease of use.""" - return cls( - # Account: cash-like (no shorts, no leverage) - allow_short_selling=False, - allow_leverage=False, - # Execution - fill_timing=FillTiming.NEXT_BAR_OPEN, - execution_price=ExecutionPrice.OPEN, - execution_mode=ExecutionMode.NEXT_BAR, - # Stops - stop_fill_mode=StopFillMode.STOP_PRICE, - stop_level_basis=StopLevelBasis.FILL_PRICE, - trail_hwm_source=WaterMarkSource.CLOSE, - trail_stop_timing=TrailStopTiming.LAGGED, - # Sizing - share_type=ShareType.FRACTIONAL, - default_position_pct=0.10, - signal_processing=SignalProcessing.CHECK_POSITION, - accumulate_positions=False, - # Costs - commission_model=CommissionModel.PERCENTAGE, - commission_rate=0.001, - slippage_model=SlippageModel.PERCENTAGE, - slippage_rate=0.001, - # Cash - initial_cash=100000.0, - cash_buffer_pct=0.0, - reject_on_insufficient_cash=True, - partial_fills_allowed=False, - fill_ordering=FillOrdering.EXIT_FIRST, - rebalance_mode=RebalanceMode.INCREMENTAL, - ) - - @classmethod - def _backtrader_preset(cls) -> BacktestConfig: - """ - Match Backtrader's default behavior. - - Key characteristics: - - INTEGER shares (rounds down to whole shares) - - Next-bar execution (COO disabled by default) - - Check position state before acting - - Percentage commission - - Margin account (shorts and leverage allowed) - - Stop level from signal price (not fill price) - """ - return cls( - # Account: margin (backtrader allows shorts and leverage) - allow_short_selling=True, - allow_leverage=True, - initial_margin=0.5, - long_maintenance_margin=0.25, - short_maintenance_margin=0.30, - # Execution - fill_timing=FillTiming.NEXT_BAR_OPEN, - execution_price=ExecutionPrice.OPEN, - execution_mode=ExecutionMode.NEXT_BAR, - # Stops: Backtrader calculates stops from signal price - stop_fill_mode=StopFillMode.STOP_PRICE, - stop_level_basis=StopLevelBasis.SIGNAL_PRICE, # Key Backtrader behavior! - trail_hwm_source=WaterMarkSource.CLOSE, - trail_stop_timing=TrailStopTiming.LAGGED, - # Sizing - share_type=ShareType.INTEGER, # Key difference! - default_position_pct=0.10, - signal_processing=SignalProcessing.CHECK_POSITION, - accumulate_positions=False, - # Costs - commission_model=CommissionModel.PERCENTAGE, - commission_rate=0.001, - slippage_model=SlippageModel.PERCENTAGE, - slippage_rate=0.001, - # Cash - initial_cash=100000.0, - cash_buffer_pct=0.0, - reject_on_insufficient_cash=True, - partial_fills_allowed=False, - fill_ordering=FillOrdering.FIFO, # Backtrader processes in submission order - rebalance_mode=RebalanceMode.SNAPSHOT, # BT batches all orders before filling - ) + from .profiles import get_profile_config - @classmethod - def _vectorbt_preset(cls) -> BacktestConfig: - """ - Match VectorBT Pro's default behavior. - - Key characteristics: - - FRACTIONAL shares - - Same-bar execution (vectorized) - - Process ALL signals (no position state check) - - Percentage fees - - Shorts allowed (crypto-like), no leverage - - Intrabar trailing stop timing (live HWM updates) - - HWM from bar high (not close) - """ - return cls( - # Account: crypto-like (shorts OK, no leverage) - allow_short_selling=True, - allow_leverage=False, - # Execution - fill_timing=FillTiming.SAME_BAR, # Vectorized = same bar - execution_price=ExecutionPrice.CLOSE, - execution_mode=ExecutionMode.SAME_BAR, - # Stops: VBT Pro uses INTRABAR timing with HIGH for HWM - stop_fill_mode=StopFillMode.STOP_PRICE, - stop_level_basis=StopLevelBasis.FILL_PRICE, - trail_hwm_source=WaterMarkSource.BAR_EXTREME, # VBT Pro with OHLC! - initial_hwm_source=InitialHwmSource.BAR_HIGH, # VBT Pro uses bar high - trail_stop_timing=TrailStopTiming.INTRABAR, # Live HWM updates! - # Sizing - share_type=ShareType.FRACTIONAL, - default_position_pct=0.10, - signal_processing=SignalProcessing.PROCESS_ALL, # Key difference! - accumulate_positions=False, - # Costs: often zero for quick prototyping - commission_model=CommissionModel.NONE, - commission_rate=0.0, - slippage_model=SlippageModel.NONE, - slippage_rate=0.0, - # Cash - initial_cash=100000.0, - cash_buffer_pct=0.0, - reject_on_insufficient_cash=False, # VectorBT is more permissive - partial_fills_allowed=True, - fill_ordering=FillOrdering.EXIT_FIRST, # VBT call_seq='auto' - rebalance_mode=RebalanceMode.HYBRID, # Frozen targets, sequential fills - ) - - @classmethod - def _zipline_preset(cls) -> BacktestConfig: - """ - Match Zipline's default behavior. - - Key characteristics: - - Next-bar execution (order on bar N, fill on bar N+1) - - Integer shares - - Per-share commission (IB-style) - - Volume-based slippage - - Cash account (no shorts by default) - """ - return cls( - # Account: cash (Zipline is conservative by default) - allow_short_selling=False, - allow_leverage=False, - # Execution - fill_timing=FillTiming.NEXT_BAR_OPEN, - execution_price=ExecutionPrice.OPEN, - execution_mode=ExecutionMode.NEXT_BAR, - # Stops - stop_fill_mode=StopFillMode.STOP_PRICE, - stop_level_basis=StopLevelBasis.FILL_PRICE, - trail_hwm_source=WaterMarkSource.CLOSE, - trail_stop_timing=TrailStopTiming.LAGGED, - # Sizing - share_type=ShareType.INTEGER, - default_position_pct=0.10, - signal_processing=SignalProcessing.CHECK_POSITION, - accumulate_positions=False, - # Costs: IB-style - commission_model=CommissionModel.PER_SHARE, # Zipline uses per-share - commission_rate=0.0, - commission_per_share=0.005, # $0.005 per share (IB-style) - commission_minimum=1.0, # $1 minimum - slippage_model=SlippageModel.VOLUME_BASED, # Key difference! - slippage_rate=0.1, # 10% of bar volume - # Cash - initial_cash=100000.0, - cash_buffer_pct=0.0, - reject_on_insufficient_cash=True, - partial_fills_allowed=True, # Volume-based = partial fills - fill_ordering=FillOrdering.EXIT_FIRST, - rebalance_mode=RebalanceMode.SNAPSHOT, # Zipline batches orders in handle_data - ) - - @classmethod - def _realistic_preset(cls) -> BacktestConfig: - """ - Conservative settings for realistic simulation. - - Key characteristics: - - Integer shares (like real brokers) - - Next-bar execution (no look-ahead) - - Higher costs (more conservative) - - Additional stop slippage (gaps hurt in fast markets) - - Cash buffer (margin of safety) - - Cash account (most conservative) - """ - return cls( - # Account: cash (most conservative) - allow_short_selling=False, - allow_leverage=False, - # Execution - fill_timing=FillTiming.NEXT_BAR_OPEN, - execution_price=ExecutionPrice.OPEN, - execution_mode=ExecutionMode.NEXT_BAR, - # Stops: realistic - stop_fill_mode=StopFillMode.NEXT_BAR_OPEN, # Conservative: fill at open - stop_level_basis=StopLevelBasis.FILL_PRICE, - trail_hwm_source=WaterMarkSource.CLOSE, - trail_stop_timing=TrailStopTiming.LAGGED, - # Sizing - share_type=ShareType.INTEGER, - default_position_pct=0.05, # Smaller positions - signal_processing=SignalProcessing.CHECK_POSITION, - accumulate_positions=False, - # Costs: higher for realism - commission_model=CommissionModel.PERCENTAGE, - commission_rate=0.002, # Higher commission - slippage_model=SlippageModel.PERCENTAGE, - slippage_rate=0.002, # Higher slippage - stop_slippage_rate=0.001, # Extra 0.1% slippage for stop fills - # Cash - initial_cash=100000.0, - cash_buffer_pct=0.02, # 2% cash buffer - reject_on_insufficient_cash=True, - partial_fills_allowed=False, - fill_ordering=FillOrdering.EXIT_FIRST, - rebalance_mode=RebalanceMode.INCREMENTAL, - ) + profile_data = get_profile_config(preset) + return cls.from_dict(profile_data, preset_name=preset, strict=True) def describe(self) -> str: """Return human-readable description of configuration.""" diff --git a/src/ml4t/backtest/core/execution_engine.py b/src/ml4t/backtest/core/execution_engine.py index 0f7dc957..1d17b96d 100644 --- a/src/ml4t/backtest/core/execution_engine.py +++ b/src/ml4t/backtest/core/execution_engine.py @@ -107,7 +107,8 @@ def _process_single_order(self, order, use_open: bool, filled_orders: list) -> N else: broker._update_partial_order(order) elif ( - not broker.reject_on_insufficient_cash and "insufficient" in rejection_reason.lower() + not broker.reject_on_insufficient_cash + and "insufficient" in rejection_reason.lower() ): if broker.partial_fills_allowed and broker._try_partial_fill(order, fill_price): filled_orders.append(order) diff --git a/src/ml4t/backtest/core/risk_engine.py b/src/ml4t/backtest/core/risk_engine.py index 18b686bc..e11b7a50 100644 --- a/src/ml4t/backtest/core/risk_engine.py +++ b/src/ml4t/backtest/core/risk_engine.py @@ -2,8 +2,8 @@ from __future__ import annotations -from ..types import OrderSide, OrderType from ..risk.types import ActionType +from ..types import OrderSide, OrderType from .shared import SubmitOrderOptions, reason_to_exit_reason diff --git a/src/ml4t/backtest/profiles.py b/src/ml4t/backtest/profiles.py new file mode 100644 index 00000000..34a891c9 --- /dev/null +++ b/src/ml4t/backtest/profiles.py @@ -0,0 +1,259 @@ +"""Centralized profile definitions for framework-aligned behavior.""" + +from __future__ import annotations + +from copy import deepcopy + +DEFAULT_PROFILE = { + "account": { + "allow_short_selling": False, + "allow_leverage": False, + }, + "execution": { + "fill_timing": "next_bar_open", + "execution_price": "open", + "execution_mode": "next_bar", + }, + "stops": { + "stop_fill_mode": "stop_price", + "stop_level_basis": "fill_price", + "trail_hwm_source": "close", + "trail_stop_timing": "lagged", + }, + "position_sizing": { + "share_type": "fractional", + "default_position_pct": 0.10, + }, + "signals": { + "signal_processing": "check_position", + "accumulate_positions": False, + }, + "commission": { + "model": "percentage", + "rate": 0.001, + }, + "slippage": { + "model": "percentage", + "rate": 0.001, + }, + "cash": { + "initial": 100000.0, + "buffer_pct": 0.0, + }, + "orders": { + "reject_on_insufficient_cash": True, + "partial_fills_allowed": False, + "fill_ordering": "exit_first", + "rebalance_mode": "incremental", + }, +} + +BACKTRADER_PROFILE = { + "account": { + "allow_short_selling": True, + "allow_leverage": True, + "initial_margin": 0.5, + "long_maintenance_margin": 0.25, + "short_maintenance_margin": 0.30, + }, + "execution": { + "fill_timing": "next_bar_open", + "execution_price": "open", + "execution_mode": "next_bar", + }, + "stops": { + "stop_fill_mode": "stop_price", + "stop_level_basis": "signal_price", + "trail_hwm_source": "close", + "trail_stop_timing": "lagged", + }, + "position_sizing": { + "share_type": "integer", + "default_position_pct": 0.10, + }, + "signals": { + "signal_processing": "check_position", + "accumulate_positions": False, + }, + "commission": { + "model": "percentage", + "rate": 0.001, + }, + "slippage": { + "model": "percentage", + "rate": 0.001, + }, + "cash": { + "initial": 100000.0, + "buffer_pct": 0.0, + }, + "orders": { + "reject_on_insufficient_cash": True, + "partial_fills_allowed": False, + "fill_ordering": "fifo", + "rebalance_mode": "snapshot", + }, +} + +VECTORBT_PROFILE = { + "account": { + "allow_short_selling": True, + "allow_leverage": False, + }, + "execution": { + "fill_timing": "same_bar", + "execution_price": "close", + "execution_mode": "same_bar", + }, + "stops": { + "stop_fill_mode": "stop_price", + "stop_level_basis": "fill_price", + "trail_hwm_source": "bar_extreme", + "initial_hwm_source": "bar_high", + "trail_stop_timing": "intrabar", + }, + "position_sizing": { + "share_type": "fractional", + "default_position_pct": 0.10, + }, + "signals": { + "signal_processing": "process_all", + "accumulate_positions": False, + }, + "commission": { + "model": "none", + "rate": 0.0, + }, + "slippage": { + "model": "none", + "rate": 0.0, + }, + "cash": { + "initial": 100000.0, + "buffer_pct": 0.0, + }, + "orders": { + "reject_on_insufficient_cash": False, + "partial_fills_allowed": True, + "fill_ordering": "exit_first", + "rebalance_mode": "hybrid", + }, +} + +ZIPLINE_PROFILE = { + "account": { + "allow_short_selling": False, + "allow_leverage": False, + }, + "execution": { + "fill_timing": "next_bar_open", + "execution_price": "open", + "execution_mode": "next_bar", + }, + "stops": { + "stop_fill_mode": "stop_price", + "stop_level_basis": "fill_price", + "trail_hwm_source": "close", + "trail_stop_timing": "lagged", + }, + "position_sizing": { + "share_type": "integer", + "default_position_pct": 0.10, + }, + "signals": { + "signal_processing": "check_position", + "accumulate_positions": False, + }, + "commission": { + "model": "per_share", + "rate": 0.0, + "per_share": 0.005, + "minimum": 1.0, + }, + "slippage": { + "model": "volume_based", + "rate": 0.1, + }, + "cash": { + "initial": 100000.0, + "buffer_pct": 0.0, + }, + "orders": { + "reject_on_insufficient_cash": True, + "partial_fills_allowed": True, + "fill_ordering": "exit_first", + "rebalance_mode": "snapshot", + }, +} + +REALISTIC_PROFILE = { + "account": { + "allow_short_selling": False, + "allow_leverage": False, + }, + "execution": { + "fill_timing": "next_bar_open", + "execution_price": "open", + "execution_mode": "next_bar", + }, + "stops": { + "stop_fill_mode": "next_bar_open", + "stop_level_basis": "fill_price", + "trail_hwm_source": "close", + "trail_stop_timing": "lagged", + }, + "position_sizing": { + "share_type": "integer", + "default_position_pct": 0.05, + }, + "signals": { + "signal_processing": "check_position", + "accumulate_positions": False, + }, + "commission": { + "model": "percentage", + "rate": 0.002, + }, + "slippage": { + "model": "percentage", + "rate": 0.002, + "stop_rate": 0.001, + }, + "cash": { + "initial": 100000.0, + "buffer_pct": 0.02, + }, + "orders": { + "reject_on_insufficient_cash": True, + "partial_fills_allowed": False, + "fill_ordering": "exit_first", + "rebalance_mode": "incremental", + }, +} + +_PROFILES = { + "default": DEFAULT_PROFILE, + "backtrader": BACKTRADER_PROFILE, + "vectorbt": VECTORBT_PROFILE, + "zipline": ZIPLINE_PROFILE, + "realistic": REALISTIC_PROFILE, +} + +_ALIASES = { + "vectorbt_pro": "vectorbt", + "vectorbt_oss": "vectorbt", +} + + +def get_profile_config(name: str) -> dict: + """Return a deep copy of nested config data for the named profile.""" + key = _ALIASES.get(name, name) + if key not in _PROFILES: + available = ", ".join(sorted(_PROFILES.keys())) + raise ValueError(f"Unknown preset '{name}'. Available: {available}") + return deepcopy(_PROFILES[key]) + + +def list_profiles() -> list[str]: + """List canonical preset names.""" + return sorted(_PROFILES.keys()) diff --git a/src/ml4t/backtest/result.py b/src/ml4t/backtest/result.py index f56e79bb..b9ac575c 100644 --- a/src/ml4t/backtest/result.py +++ b/src/ml4t/backtest/result.py @@ -531,6 +531,19 @@ def to_dict(self) -> dict[str, Any]: result["trade_analyzer"] = self.trade_analyzer return result + # Dict-like access keeps validation scripts and older notebook code working. + def __getitem__(self, key: str) -> Any: + return self.to_dict()[key] + + def get(self, key: str, default: Any = None) -> Any: + return self.to_dict().get(key, default) + + def keys(self): + return self.to_dict().keys() + + def items(self): + return self.to_dict().items() + def to_parquet( self, path: str | Path, diff --git a/src/ml4t/backtest/types.py b/src/ml4t/backtest/types.py index 6de38975..7df129aa 100644 --- a/src/ml4t/backtest/types.py +++ b/src/ml4t/backtest/types.py @@ -390,6 +390,11 @@ def is_open(self) -> bool: """Return True if this is an open (mark-to-market) trade.""" return self.status == "open" + @property + def commission(self) -> float: + """Backward-compat alias for validation scripts expecting `commission`.""" + return self.fees + @dataclass class PartialExit: diff --git a/validation/cross_impl_benchmark.py b/validation/cross_impl_benchmark.py index b4192686..fd48c9b7 100644 --- a/validation/cross_impl_benchmark.py +++ b/validation/cross_impl_benchmark.py @@ -40,11 +40,10 @@ # Import frameworks try: from ml4t.backtest import ( + BacktestConfig, Broker, DataFeed, Engine, - PercentageCommission, - PercentageSlippage, StopFillMode, Strategy, ) @@ -505,21 +504,25 @@ def run_ml4t_backtest( # Create DataFeed feed = DataFeed(prices_df=prices_with_ts, signals_df=signals_with_ts) - # Create commission/slippage models - commission_model = PercentageCommission(commission) if commission > 0 else None - slippage_model = PercentageSlippage(slippage) if slippage > 0 else None + from ml4t.backtest.config import CommissionModel, SlippageModel - # Create engine with CLOSE_PRICE fill mode to match VBT Pro default - # VBT Pro default (StopExitPrice.Stop/Close): stops fill at bar's close price - # Use STOP_PRICE only if matching VBT Pro with StopExitPrice.HardStop - engine = Engine( - feed=feed, - strategy=strategy, - initial_cash=initial_cash, - commission_model=commission_model, - slippage_model=slippage_model, - stop_fill_mode=StopFillMode.STOP_PRICE, - ) + config = BacktestConfig.from_preset("vectorbt") + config.initial_cash = initial_cash + config.stop_fill_mode = StopFillMode.STOP_PRICE + if commission > 0: + config.commission_model = CommissionModel.PERCENTAGE + config.commission_rate = commission + else: + config.commission_model = CommissionModel.NONE + config.commission_rate = 0.0 + if slippage > 0: + config.slippage_model = SlippageModel.PERCENTAGE + config.slippage_rate = slippage + else: + config.slippage_model = SlippageModel.NONE + config.slippage_rate = 0.0 + + engine = Engine.from_config(feed=feed, strategy=strategy, config=config) # Run with timing gc.collect() diff --git a/validation/run_all_correctness.py b/validation/run_all_correctness.py index 4efb679b..94205dd9 100644 --- a/validation/run_all_correctness.py +++ b/validation/run_all_correctness.py @@ -20,6 +20,7 @@ import argparse import json +import os import subprocess import sys from datetime import datetime @@ -35,26 +36,31 @@ "venv": ".venv-vectorbt-pro", "scenarios": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10"], "display_name": "VectorBT Pro", + "ml4t_profile": "vectorbt", }, "vectorbt_oss": { "venv": ".venv", # Can also use .venv-validation "scenarios": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10"], "display_name": "VectorBT OSS", + "ml4t_profile": "vectorbt", }, "backtrader": { "venv": ".venv-backtrader", "scenarios": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10"], "display_name": "Backtrader", + "ml4t_profile": "backtrader", }, "zipline": { "venv": ".venv-zipline", "scenarios": ["01", "02", "03", "04", "05", "06", "07", "08", "09"], # No scenario 10 "display_name": "Zipline", + "ml4t_profile": "zipline", }, "lean": { "venv": None, # Uses Docker "scenarios": ["01"], # Start with basic scenarios "display_name": "LEAN CLI", + "ml4t_profile": "default", }, } @@ -100,12 +106,20 @@ def run_scenario(framework: str, scenario: str) -> dict: return {"passed": None, "error": f"venv not found: {venv_path}", "output": ""} try: + env = { + **os.environ, + "ML4T_PROFILE": config.get("ml4t_profile", "default"), + } + if framework == "zipline": + env["ZIPLINE_ROOT"] = str(VALIDATION_DIR / ".zipline") + result = subprocess.run( [str(python_path), str(script_path)], capture_output=True, text=True, timeout=120, cwd=str(PROJECT_ROOT), + env=env, ) output = result.stdout + result.stderr From f519d3b3de15b560d0f7984ee89dae179012db0b Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 09:15:46 -0500 Subject: [PATCH 08/24] refactor: shrink root API and isolate validation imports --- src/ml4t/backtest/__init__.py | 35 +---------- src/ml4t/backtest/_validation_imports.py | 61 +++++++++++++++++++ tests/accounting/test_gatekeeper.py | 3 +- tests/execution/test_rebalancer.py | 6 +- tests/test_config_wiring.py | 6 +- tests/test_core.py | 4 +- tests/test_strategy_templates.py | 21 +++---- validation/analyze_remaining_mismatches.py | 2 +- .../backtrader/benchmark_performance.py | 2 +- validation/backtrader/scale_validation.py | 2 +- .../backtrader/scenario_01_long_only.py | 2 +- .../backtrader/scenario_02_long_short.py | 2 +- .../backtrader/scenario_03_stop_loss.py | 2 +- .../backtrader/scenario_04_take_profit.py | 2 +- .../backtrader/scenario_05_commission_pct.py | 2 +- .../scenario_06_commission_per_share.py | 2 +- .../backtrader/scenario_07_slippage_fixed.py | 2 +- .../backtrader/scenario_08_slippage_pct.py | 2 +- .../backtrader/scenario_09_trailing_stop.py | 2 +- .../backtrader/scenario_10_bracket_order.py | 2 +- .../backtrader/scenario_11_short_only.py | 2 +- .../scenario_12_short_trailing_stop.py | 2 +- .../backtrader/scenario_13_tsl_tp_combo.py | 2 +- .../backtrader/scenario_14_tsl_sl_combo.py | 2 +- .../backtrader/scenario_15_triple_rule.py | 2 +- .../backtrader/scenario_16_stress_1000bars.py | 2 +- validation/benchmark_suite.py | 2 +- validation/calendar_scale_test.py | 2 +- validation/cross_impl_benchmark.py | 2 +- validation/debug_asset000.py | 2 +- validation/debug_asset001_442.py | 2 +- validation/debug_exit_price_diff.py | 2 +- validation/debug_exit_price_mismatch.py | 2 +- validation/debug_hwm_precise.py | 2 +- validation/debug_hwm_tracking.py | 2 +- validation/debug_pnl_mismatch.py | 2 +- validation/debug_reentry.py | 2 +- validation/debug_trailing_fill.py | 2 +- validation/ml4t_vbt_scale_match.py | 2 +- validation/rebalancing_scale_test.py | 2 +- validation/risk_validation.py | 2 +- validation/run_all_benchmarks.py | 2 +- validation/short_selling_test.py | 2 +- .../vectorbt_oss/scenario_01_long_only.py | 2 +- .../vectorbt_oss/scenario_02_long_short.py | 2 +- .../vectorbt_oss/scenario_03_stop_loss.py | 2 +- .../vectorbt_oss/scenario_04_take_profit.py | 2 +- .../scenario_05_commission_pct.py | 2 +- .../scenario_06_commission_per_share.py | 2 +- .../scenario_07_slippage_fixed.py | 2 +- .../vectorbt_oss/scenario_08_slippage_pct.py | 2 +- .../vectorbt_oss/scenario_09_trailing_stop.py | 2 +- .../vectorbt_oss/scenario_10_bracket_order.py | 2 +- .../vectorbt_oss/scenario_11_short_only.py | 2 +- .../scenario_12_short_trailing_stop.py | 2 +- .../vectorbt_oss/scenario_13_tsl_tp_combo.py | 2 +- .../vectorbt_oss/scenario_14_tsl_sl_combo.py | 2 +- .../vectorbt_oss/scenario_15_triple_rule.py | 2 +- .../scenario_16_stress_1000bars.py | 2 +- .../vectorbt_pro/benchmark_performance.py | 2 +- .../vectorbt_pro/scenario_01_long_only.py | 2 +- .../vectorbt_pro/scenario_02_long_short.py | 2 +- .../vectorbt_pro/scenario_03_stop_loss.py | 2 +- .../vectorbt_pro/scenario_04_take_profit.py | 2 +- .../scenario_05_commission_pct.py | 2 +- .../scenario_06_commission_per_share.py | 2 +- .../scenario_07_slippage_fixed.py | 2 +- .../vectorbt_pro/scenario_08_slippage_pct.py | 2 +- .../vectorbt_pro/scenario_09_trailing_stop.py | 2 +- .../vectorbt_pro/scenario_10_bracket_order.py | 2 +- .../vectorbt_pro/scenario_11_short_only.py | 2 +- .../scenario_12_short_trailing_stop.py | 2 +- .../scenario_12b_short_tsl_stress.py | 2 +- .../vectorbt_pro/scenario_13_tsl_tp_combo.py | 4 +- .../vectorbt_pro/scenario_14_tsl_sl_combo.py | 2 +- .../vectorbt_pro/scenario_15_triple_rule.py | 2 +- .../scenario_16_stress_1000bars.py | 2 +- validation/zipline/benchmark_performance.py | 2 +- validation/zipline/scenario_01_long_only.py | 2 +- validation/zipline/scenario_02_long_short.py | 2 +- validation/zipline/scenario_03_stop_loss.py | 2 +- validation/zipline/scenario_04_take_profit.py | 2 +- .../zipline/scenario_05_commission_pct.py | 2 +- .../scenario_06_commission_per_share.py | 2 +- .../zipline/scenario_07_slippage_fixed.py | 2 +- .../zipline/scenario_08_slippage_pct.py | 2 +- .../zipline/scenario_09_trailing_stop.py | 2 +- validation/zipline/scenario_11_short_only.py | 2 +- .../scenario_12_short_trailing_stop.py | 2 +- .../zipline/scenario_13_tsl_tp_combo.py | 2 +- .../zipline/scenario_14_tsl_sl_combo.py | 2 +- validation/zipline/scenario_15_triple_rule.py | 2 +- .../zipline/scenario_16_stress_1000bars.py | 2 +- 93 files changed, 163 insertions(+), 147 deletions(-) create mode 100644 src/ml4t/backtest/_validation_imports.py diff --git a/src/ml4t/backtest/__init__.py b/src/ml4t/backtest/__init__.py index 45c76863..27ab7f61 100644 --- a/src/ml4t/backtest/__init__.py +++ b/src/ml4t/backtest/__init__.py @@ -14,26 +14,9 @@ __version__ = "0.0.0.dev0" from .broker import Broker -from .config import ( - BacktestConfig, - InitialHwmSource, - Mode, - RebalanceMode, - WaterMarkSource, -) +from .config import BacktestConfig, Mode from .datafeed import DataFeed from .engine import Engine, run_backtest -from .execution.impact import LinearImpact -from .execution.limits import VolumeParticipationLimit -from .execution.rebalancer import RebalanceConfig, TargetWeightExecutor -from .models import ( - FixedSlippage, - NoCommission, - NoSlippage, - PercentageCommission, - PercentageSlippage, - PerShareCommission, -) from .result import BacktestResult # Risk management rules (position-level) @@ -55,8 +38,6 @@ Trade, ) -TrailHwmSource = WaterMarkSource - __all__ = [ # Core API "DataFeed", @@ -67,20 +48,6 @@ "BacktestConfig", "Mode", "BacktestResult", - "NoCommission", - "PercentageCommission", - "PerShareCommission", - "NoSlippage", - "FixedSlippage", - "PercentageSlippage", - "VolumeParticipationLimit", - "LinearImpact", - "RebalanceConfig", - "TargetWeightExecutor", - "WaterMarkSource", - "TrailHwmSource", - "InitialHwmSource", - "RebalanceMode", # Canonical domain types "OrderType", "OrderSide", diff --git a/src/ml4t/backtest/_validation_imports.py b/src/ml4t/backtest/_validation_imports.py new file mode 100644 index 00000000..7bb306d8 --- /dev/null +++ b/src/ml4t/backtest/_validation_imports.py @@ -0,0 +1,61 @@ +"""Validation-only import bridge. + +This module is intentionally not exported from ml4t.backtest root. +Validation scripts should import from here to avoid widening public API. +""" + +from .broker import Broker +from .config import BacktestConfig, InitialHwmSource, WaterMarkSource +from .datafeed import DataFeed +from .engine import Engine +from .execution.impact import LinearImpact +from .execution.limits import VolumeParticipationLimit +from .execution.rebalancer import RebalanceConfig, TargetWeightExecutor +from .models import ( + FixedSlippage, + NoCommission, + NoSlippage, + PerShareCommission, + PercentageCommission, + PercentageSlippage, +) +from .strategy import Strategy +from .types import ( + ExecutionMode, + Order, + OrderSide, + OrderStatus, + OrderType, + StopFillMode, + StopLevelBasis, +) + +TrailHwmSource = WaterMarkSource + +__all__ = [ + "Broker", + "BacktestConfig", + "DataFeed", + "Engine", + "ExecutionMode", + "Strategy", + "Order", + "OrderSide", + "OrderStatus", + "OrderType", + "StopFillMode", + "StopLevelBasis", + "NoCommission", + "PercentageCommission", + "PerShareCommission", + "NoSlippage", + "FixedSlippage", + "PercentageSlippage", + "VolumeParticipationLimit", + "LinearImpact", + "RebalanceConfig", + "TargetWeightExecutor", + "WaterMarkSource", + "TrailHwmSource", + "InitialHwmSource", +] diff --git a/tests/accounting/test_gatekeeper.py b/tests/accounting/test_gatekeeper.py index 48f6f909..0d813545 100644 --- a/tests/accounting/test_gatekeeper.py +++ b/tests/accounting/test_gatekeeper.py @@ -3,10 +3,8 @@ from datetime import datetime from ml4t.backtest import ( - NoCommission, Order, OrderSide, - PercentageCommission, ) from ml4t.backtest.accounting import ( AccountState, @@ -14,6 +12,7 @@ Position, UnifiedAccountPolicy, ) +from ml4t.backtest.models import NoCommission, PercentageCommission class TestGatekeeperInitialization: diff --git a/tests/execution/test_rebalancer.py b/tests/execution/test_rebalancer.py index adef7153..af7eeb07 100644 --- a/tests/execution/test_rebalancer.py +++ b/tests/execution/test_rebalancer.py @@ -6,12 +6,10 @@ from ml4t.backtest import ( Broker, - NoCommission, - NoSlippage, OrderSide, - RebalanceConfig, - TargetWeightExecutor, ) +from ml4t.backtest.execution.rebalancer import RebalanceConfig, TargetWeightExecutor +from ml4t.backtest.models import NoCommission, NoSlippage class TestRebalanceConfig: diff --git a/tests/test_config_wiring.py b/tests/test_config_wiring.py index ce7d865a..a01e39d7 100644 --- a/tests/test_config_wiring.py +++ b/tests/test_config_wiring.py @@ -17,11 +17,9 @@ BacktestConfig, Broker, ExecutionMode, - FillOrdering, - NoCommission, - NoSlippage, - ShareType, ) +from ml4t.backtest.config import FillOrdering, ShareType +from ml4t.backtest.models import NoCommission, NoSlippage from ml4t.backtest.types import OrderSide # --------------------------------------------------------------------------- diff --git a/tests/test_core.py b/tests/test_core.py index 367aa498..c97a432a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -12,10 +12,7 @@ ExecutionMode, OrderSide, OrderType, - PercentageCommission, - PerShareCommission, Strategy, - VolumeShareSlippage, run_backtest, ) from ml4t.backtest.config import ( @@ -24,6 +21,7 @@ FillTiming, SlippageModel, ) +from ml4t.backtest.models import PercentageCommission, PerShareCommission, VolumeShareSlippage # === Test Data Generators === diff --git a/tests/test_strategy_templates.py b/tests/test_strategy_templates.py index f7fa6df4..1e63acee 100644 --- a/tests/test_strategy_templates.py +++ b/tests/test_strategy_templates.py @@ -296,19 +296,14 @@ def test_ranking(self): class TestStrategyImports: """Test that strategy templates are properly exported.""" - def test_import_from_package(self): - """Test importing from ml4t.backtest.""" - from ml4t.backtest import ( - LongShortStrategy, - MeanReversionStrategy, - MomentumStrategy, - SignalFollowingStrategy, - ) - - assert SignalFollowingStrategy is not None - assert MomentumStrategy is not None - assert MeanReversionStrategy is not None - assert LongShortStrategy is not None + def test_not_imported_from_package_root(self): + """Templates should not be imported into ml4t.backtest root.""" + import ml4t.backtest as bt + + assert not hasattr(bt, "SignalFollowingStrategy") + assert not hasattr(bt, "MomentumStrategy") + assert not hasattr(bt, "MeanReversionStrategy") + assert not hasattr(bt, "LongShortStrategy") def test_import_from_strategies(self): """Test importing from ml4t.backtest.strategies.""" diff --git a/validation/analyze_remaining_mismatches.py b/validation/analyze_remaining_mismatches.py index 55090b79..2c7bcc42 100644 --- a/validation/analyze_remaining_mismatches.py +++ b/validation/analyze_remaining_mismatches.py @@ -59,7 +59,7 @@ def run_vbt_pro(data: dict, n_bars: int, trail_pct: float = 0.03): def run_ml4t(data: dict, n_bars: int, trail_pct: float = 0.03): - from ml4t.backtest import Broker, OrderSide, TrailHwmSource, StopFillMode + from ml4t.backtest._validation_imports import Broker, OrderSide, TrailHwmSource, StopFillMode from ml4t.backtest.models import PercentageCommission, PercentageSlippage from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/backtrader/benchmark_performance.py b/validation/backtrader/benchmark_performance.py index 9a78b0ac..3d13a404 100644 --- a/validation/backtrader/benchmark_performance.py +++ b/validation/backtrader/benchmark_performance.py @@ -137,7 +137,7 @@ def benchmark_ml4t_backtest( """Benchmark ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy # Prepare data in polars format rows = [] diff --git a/validation/backtrader/scale_validation.py b/validation/backtrader/scale_validation.py index 5929a0ba..c71b851d 100644 --- a/validation/backtrader/scale_validation.py +++ b/validation/backtrader/scale_validation.py @@ -257,7 +257,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, signals: dict) -> dict: """Run multi-asset backtest using ml4t.backtest.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/backtrader/scenario_01_long_only.py b/validation/backtrader/scenario_01_long_only.py index b7272074..6ac9910b 100644 --- a/validation/backtrader/scenario_01_long_only.py +++ b/validation/backtrader/scenario_01_long_only.py @@ -183,7 +183,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nd """Run backtest using ml4t.backtest with next-bar execution to match Backtrader.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy # Convert to polars format prices_pl = pl.DataFrame( diff --git a/validation/backtrader/scenario_02_long_short.py b/validation/backtrader/scenario_02_long_short.py index 71f6cfda..d1d0a2ac 100644 --- a/validation/backtrader/scenario_02_long_short.py +++ b/validation/backtrader/scenario_02_long_short.py @@ -204,7 +204,7 @@ def run_ml4t_backtest( """Run backtest using ml4t.backtest with next-bar execution.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy prices_pl = pl.DataFrame( { diff --git a/validation/backtrader/scenario_03_stop_loss.py b/validation/backtrader/scenario_03_stop_loss.py index 88804254..ef29f896 100644 --- a/validation/backtrader/scenario_03_stop_loss.py +++ b/validation/backtrader/scenario_03_stop_loss.py @@ -205,7 +205,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, sl_pct: float) -> dict: """Run backtest using ml4t.backtest with stop-loss (next-bar mode).""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/backtrader/scenario_04_take_profit.py b/validation/backtrader/scenario_04_take_profit.py index 5cd3263a..35bd67c9 100644 --- a/validation/backtrader/scenario_04_take_profit.py +++ b/validation/backtrader/scenario_04_take_profit.py @@ -197,7 +197,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, tp_pct: float) -> dict: """Run backtest using ml4t.backtest with take-profit (next-bar mode).""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/backtrader/scenario_05_commission_pct.py b/validation/backtrader/scenario_05_commission_pct.py index ad9d5aad..939e79a2 100644 --- a/validation/backtrader/scenario_05_commission_pct.py +++ b/validation/backtrader/scenario_05_commission_pct.py @@ -156,7 +156,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nd """Run backtest using ml4t.backtest with percentage commission.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/backtrader/scenario_06_commission_per_share.py b/validation/backtrader/scenario_06_commission_per_share.py index 1747ad7a..e1a52d0b 100644 --- a/validation/backtrader/scenario_06_commission_per_share.py +++ b/validation/backtrader/scenario_06_commission_per_share.py @@ -120,7 +120,7 @@ def notify_order(self, order): def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/backtrader/scenario_07_slippage_fixed.py b/validation/backtrader/scenario_07_slippage_fixed.py index 873571a1..e3c7730c 100644 --- a/validation/backtrader/scenario_07_slippage_fixed.py +++ b/validation/backtrader/scenario_07_slippage_fixed.py @@ -111,7 +111,7 @@ def next(self): def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/backtrader/scenario_08_slippage_pct.py b/validation/backtrader/scenario_08_slippage_pct.py index 244175a5..29fd8596 100644 --- a/validation/backtrader/scenario_08_slippage_pct.py +++ b/validation/backtrader/scenario_08_slippage_pct.py @@ -111,7 +111,7 @@ def next(self): def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/backtrader/scenario_09_trailing_stop.py b/validation/backtrader/scenario_09_trailing_stop.py index f91ff2b2..130c62c1 100644 --- a/validation/backtrader/scenario_09_trailing_stop.py +++ b/validation/backtrader/scenario_09_trailing_stop.py @@ -136,7 +136,7 @@ def notify_order(self, order): def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/backtrader/scenario_10_bracket_order.py b/validation/backtrader/scenario_10_bracket_order.py index a443f009..ee2b4d71 100644 --- a/validation/backtrader/scenario_10_bracket_order.py +++ b/validation/backtrader/scenario_10_bracket_order.py @@ -154,7 +154,7 @@ def notify_order(self, order): def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/backtrader/scenario_11_short_only.py b/validation/backtrader/scenario_11_short_only.py index a234a0f3..c09cdded 100644 --- a/validation/backtrader/scenario_11_short_only.py +++ b/validation/backtrader/scenario_11_short_only.py @@ -172,7 +172,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nd """Run short-only backtest using ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, OrderSide, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, OrderSide, Strategy prices_pl = pl.DataFrame( { diff --git a/validation/backtrader/scenario_12_short_trailing_stop.py b/validation/backtrader/scenario_12_short_trailing_stop.py index ef7678ad..f377cf76 100644 --- a/validation/backtrader/scenario_12_short_trailing_stop.py +++ b/validation/backtrader/scenario_12_short_trailing_stop.py @@ -200,7 +200,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: """ml4t.backtest trailing stop for SHORT positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/backtrader/scenario_13_tsl_tp_combo.py b/validation/backtrader/scenario_13_tsl_tp_combo.py index 072fc84c..3e5611df 100644 --- a/validation/backtrader/scenario_13_tsl_tp_combo.py +++ b/validation/backtrader/scenario_13_tsl_tp_combo.py @@ -203,7 +203,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray, scenario: str) -> dic """ml4t.backtest with TSL + TP for LONG positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk import RuleChain diff --git a/validation/backtrader/scenario_14_tsl_sl_combo.py b/validation/backtrader/scenario_14_tsl_sl_combo.py index 74bcafa5..528c8780 100644 --- a/validation/backtrader/scenario_14_tsl_sl_combo.py +++ b/validation/backtrader/scenario_14_tsl_sl_combo.py @@ -249,7 +249,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray, scenario: str) -> dic """ml4t.backtest with TSL + SL for LONG positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk import RuleChain diff --git a/validation/backtrader/scenario_15_triple_rule.py b/validation/backtrader/scenario_15_triple_rule.py index 52e7d1f8..3ea7f8e2 100644 --- a/validation/backtrader/scenario_15_triple_rule.py +++ b/validation/backtrader/scenario_15_triple_rule.py @@ -241,7 +241,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray, scenario: str) -> dic """ml4t.backtest with TSL + TP + SL.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk import RuleChain diff --git a/validation/backtrader/scenario_16_stress_1000bars.py b/validation/backtrader/scenario_16_stress_1000bars.py index 95d6c6e9..2cf9ba1b 100644 --- a/validation/backtrader/scenario_16_stress_1000bars.py +++ b/validation/backtrader/scenario_16_stress_1000bars.py @@ -146,7 +146,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: """ml4t.backtest stress test with TSL.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/benchmark_suite.py b/validation/benchmark_suite.py index ef4e81b9..0eaadee7 100644 --- a/validation/benchmark_suite.py +++ b/validation/benchmark_suite.py @@ -567,7 +567,7 @@ def benchmark_ml4t( """ import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/calendar_scale_test.py b/validation/calendar_scale_test.py index 4331ad0f..a7662016 100644 --- a/validation/calendar_scale_test.py +++ b/validation/calendar_scale_test.py @@ -13,7 +13,7 @@ import numpy as np import polars as pl -from ml4t.backtest import Engine, Strategy, DataFeed, OrderSide +from ml4t.backtest._validation_imports import Engine, Strategy, DataFeed, OrderSide from ml4t.backtest.config import BacktestConfig, DataFrequency diff --git a/validation/cross_impl_benchmark.py b/validation/cross_impl_benchmark.py index fd48c9b7..4254a009 100644 --- a/validation/cross_impl_benchmark.py +++ b/validation/cross_impl_benchmark.py @@ -39,7 +39,7 @@ # Import frameworks try: - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( BacktestConfig, Broker, DataFeed, diff --git a/validation/debug_asset000.py b/validation/debug_asset000.py index 49d78f2b..ad3bbcfa 100644 --- a/validation/debug_asset000.py +++ b/validation/debug_asset000.py @@ -74,7 +74,7 @@ def main(): # Run ml4t from datetime import datetime, timedelta - from ml4t.backtest import Broker, OrderSide, TrailHwmSource, StopFillMode, InitialHwmSource + from ml4t.backtest._validation_imports import Broker, OrderSide, TrailHwmSource, StopFillMode, InitialHwmSource from ml4t.backtest.models import PercentageCommission, PercentageSlippage from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/debug_asset001_442.py b/validation/debug_asset001_442.py index 5ec96527..35c4fd1a 100644 --- a/validation/debug_asset001_442.py +++ b/validation/debug_asset001_442.py @@ -76,7 +76,7 @@ def run_vbt_with_trace(data, trail_pct=0.03, target_entry=442): def run_ml4t_with_trace(data, trail_pct=0.03, target_entry=442): """Run ml4t with detailed HWM tracing.""" - from ml4t.backtest import Broker, OrderSide, TrailHwmSource, StopFillMode + from ml4t.backtest._validation_imports import Broker, OrderSide, TrailHwmSource, StopFillMode from ml4t.backtest.models import PercentageCommission, PercentageSlippage from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/debug_exit_price_diff.py b/validation/debug_exit_price_diff.py index 84957e12..d5d4a666 100644 --- a/validation/debug_exit_price_diff.py +++ b/validation/debug_exit_price_diff.py @@ -72,7 +72,7 @@ def run_vbt(data, trail_pct=0.03): def run_ml4t(data, trail_pct=0.03): """Run ml4t.backtest.""" - from ml4t.backtest import Broker, OrderSide, TrailHwmSource + from ml4t.backtest._validation_imports import Broker, OrderSide, TrailHwmSource from ml4t.backtest.models import PercentageCommission, PercentageSlippage from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/debug_exit_price_mismatch.py b/validation/debug_exit_price_mismatch.py index 5d98327d..ba66bfe7 100644 --- a/validation/debug_exit_price_mismatch.py +++ b/validation/debug_exit_price_mismatch.py @@ -71,7 +71,7 @@ def run_vbt(data, trail_pct=0.03, target_entry=117): def run_ml4t_with_trace(data, trail_pct=0.03, target_entry=117): """Run ml4t with HWM trace for specific trade.""" - from ml4t.backtest import Broker, OrderSide, TrailHwmSource, StopFillMode + from ml4t.backtest._validation_imports import Broker, OrderSide, TrailHwmSource, StopFillMode from ml4t.backtest.models import PercentageCommission, PercentageSlippage from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/debug_hwm_precise.py b/validation/debug_hwm_precise.py index 04b120a4..6a1b6d65 100644 --- a/validation/debug_hwm_precise.py +++ b/validation/debug_hwm_precise.py @@ -85,7 +85,7 @@ def debug_vbt_pro(): def debug_ml4t(): """Debug ml4t HWM behavior for asset_038.""" from datetime import datetime, timedelta - from ml4t.backtest import Broker, OrderSide, TrailHwmSource, StopFillMode, InitialHwmSource + from ml4t.backtest._validation_imports import Broker, OrderSide, TrailHwmSource, StopFillMode, InitialHwmSource from ml4t.backtest.models import PercentageCommission, PercentageSlippage from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/debug_hwm_tracking.py b/validation/debug_hwm_tracking.py index d8e34d1e..133669b4 100644 --- a/validation/debug_hwm_tracking.py +++ b/validation/debug_hwm_tracking.py @@ -75,7 +75,7 @@ def run_vbt(data, trail_pct=0.03): def run_ml4t_with_trace(data, trail_pct=0.03, use_high=True): """Run ml4t.backtest with HWM tracing.""" - from ml4t.backtest import Broker, OrderSide, TrailHwmSource, StopFillMode + from ml4t.backtest._validation_imports import Broker, OrderSide, TrailHwmSource, StopFillMode from ml4t.backtest.models import PercentageCommission, PercentageSlippage from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/debug_pnl_mismatch.py b/validation/debug_pnl_mismatch.py index cdb1cb6e..ad2ea79a 100644 --- a/validation/debug_pnl_mismatch.py +++ b/validation/debug_pnl_mismatch.py @@ -88,7 +88,7 @@ def run_vbt(data, trail_pct=0.03): def run_ml4t(data, trail_pct=0.03): """Run ml4t.backtest.""" - from ml4t.backtest import Broker, OrderSide, TrailHwmSource + from ml4t.backtest._validation_imports import Broker, OrderSide, TrailHwmSource from ml4t.backtest.models import PercentageCommission, PercentageSlippage from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/debug_reentry.py b/validation/debug_reentry.py index c627f645..7bc6767f 100644 --- a/validation/debug_reentry.py +++ b/validation/debug_reentry.py @@ -81,7 +81,7 @@ def run_vbt(data, trail_pct=0.03): def run_ml4t(data, trail_pct=0.03): """Run ml4t.backtest.""" - from ml4t.backtest import Broker, OrderSide, TrailHwmSource + from ml4t.backtest._validation_imports import Broker, OrderSide, TrailHwmSource from ml4t.backtest.models import PercentageCommission, PercentageSlippage from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/debug_trailing_fill.py b/validation/debug_trailing_fill.py index c629aeac..c5410baa 100644 --- a/validation/debug_trailing_fill.py +++ b/validation/debug_trailing_fill.py @@ -13,7 +13,7 @@ def run_ml4t_debug(): """Run ml4t.backtest with debug output.""" - from ml4t.backtest import Broker, Order, OrderSide, OrderStatus, OrderType, TrailHwmSource + from ml4t.backtest._validation_imports import Broker, Order, OrderSide, OrderStatus, OrderType, TrailHwmSource from ml4t.backtest.models import NoCommission, PercentageSlippage from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/ml4t_vbt_scale_match.py b/validation/ml4t_vbt_scale_match.py index 354143ea..ef1f073a 100644 --- a/validation/ml4t_vbt_scale_match.py +++ b/validation/ml4t_vbt_scale_match.py @@ -148,7 +148,7 @@ def run_vbt_pro(data: dict, n_bars: int, trail_pct: float = 0.03) -> list[TradeR def run_ml4t(data: dict, n_bars: int, trail_pct: float = 0.03) -> list[TradeRecord]: """Run ml4t.backtest and extract trades.""" - from ml4t.backtest import Broker, OrderSide, Strategy, TrailHwmSource, StopFillMode, InitialHwmSource + from ml4t.backtest._validation_imports import Broker, OrderSide, Strategy, TrailHwmSource, StopFillMode, InitialHwmSource from ml4t.backtest.models import PercentageCommission, PercentageSlippage from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/rebalancing_scale_test.py b/validation/rebalancing_scale_test.py index 0ac3f979..cdef3a9a 100644 --- a/validation/rebalancing_scale_test.py +++ b/validation/rebalancing_scale_test.py @@ -22,7 +22,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT / "src")) -from ml4t.backtest import ( +from ml4t.backtest._validation_imports import ( DataFeed, Engine, NoCommission, diff --git a/validation/risk_validation.py b/validation/risk_validation.py index a12d886a..ad10092e 100644 --- a/validation/risk_validation.py +++ b/validation/risk_validation.py @@ -7,7 +7,7 @@ import polars as pl -from ml4t.backtest import ( +from ml4t.backtest._validation_imports import ( DataFeed, Engine, LinearImpact, diff --git a/validation/run_all_benchmarks.py b/validation/run_all_benchmarks.py index 2232c4a1..f9befe4b 100644 --- a/validation/run_all_benchmarks.py +++ b/validation/run_all_benchmarks.py @@ -125,7 +125,7 @@ def benchmark_ml4t(asset_data: dict, entries: np.ndarray, exits: np.ndarray, dat """Benchmark ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy # Prepare data in polars format rows = [] diff --git a/validation/short_selling_test.py b/validation/short_selling_test.py index ceabfc3a..8e1b86b1 100644 --- a/validation/short_selling_test.py +++ b/validation/short_selling_test.py @@ -29,7 +29,7 @@ PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT / "src")) -from ml4t.backtest import ( +from ml4t.backtest._validation_imports import ( DataFeed, Engine, NoCommission, diff --git a/validation/vectorbt_oss/scenario_01_long_only.py b/validation/vectorbt_oss/scenario_01_long_only.py index f6ef94dd..656e3621 100644 --- a/validation/vectorbt_oss/scenario_01_long_only.py +++ b/validation/vectorbt_oss/scenario_01_long_only.py @@ -107,7 +107,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nd """Run backtest using ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy # Convert to polars format prices_pl = pl.DataFrame( diff --git a/validation/vectorbt_oss/scenario_02_long_short.py b/validation/vectorbt_oss/scenario_02_long_short.py index 20d36e86..dd38492b 100644 --- a/validation/vectorbt_oss/scenario_02_long_short.py +++ b/validation/vectorbt_oss/scenario_02_long_short.py @@ -122,7 +122,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, signals: dict) -> dict: """Run backtest using ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy # Convert to polars format prices_pl = pl.DataFrame( diff --git a/validation/vectorbt_oss/scenario_03_stop_loss.py b/validation/vectorbt_oss/scenario_03_stop_loss.py index aa3f93db..8fc10057 100644 --- a/validation/vectorbt_oss/scenario_03_stop_loss.py +++ b/validation/vectorbt_oss/scenario_03_stop_loss.py @@ -169,7 +169,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, sl_pct: floa """Run backtest using ml4t.backtest with stop-loss.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_oss/scenario_04_take_profit.py b/validation/vectorbt_oss/scenario_04_take_profit.py index a4244de5..35d0c9ac 100644 --- a/validation/vectorbt_oss/scenario_04_take_profit.py +++ b/validation/vectorbt_oss/scenario_04_take_profit.py @@ -169,7 +169,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, tp_pct: floa """Run backtest using ml4t.backtest with take-profit.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_oss/scenario_05_commission_pct.py b/validation/vectorbt_oss/scenario_05_commission_pct.py index 7065272d..66bf1c23 100644 --- a/validation/vectorbt_oss/scenario_05_commission_pct.py +++ b/validation/vectorbt_oss/scenario_05_commission_pct.py @@ -109,7 +109,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nd """Run backtest using ml4t.backtest with percentage commission.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_oss/scenario_06_commission_per_share.py b/validation/vectorbt_oss/scenario_06_commission_per_share.py index 5c3ebf33..b3334673 100644 --- a/validation/vectorbt_oss/scenario_06_commission_per_share.py +++ b/validation/vectorbt_oss/scenario_06_commission_per_share.py @@ -93,7 +93,7 @@ def run_vectorbt_oss(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nda def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_oss/scenario_07_slippage_fixed.py b/validation/vectorbt_oss/scenario_07_slippage_fixed.py index cee228e2..47f3d8a1 100644 --- a/validation/vectorbt_oss/scenario_07_slippage_fixed.py +++ b/validation/vectorbt_oss/scenario_07_slippage_fixed.py @@ -55,7 +55,7 @@ def run_vectorbt_oss(prices_df, entries, exits): def run_ml4t_backtest(prices_df, entries, exits): import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, FixedSlippage, NoCommission, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, FixedSlippage, NoCommission, Strategy prices_pl = pl.DataFrame({ "timestamp": prices_df.index.to_pydatetime().tolist(), diff --git a/validation/vectorbt_oss/scenario_08_slippage_pct.py b/validation/vectorbt_oss/scenario_08_slippage_pct.py index 46722d84..d942889f 100644 --- a/validation/vectorbt_oss/scenario_08_slippage_pct.py +++ b/validation/vectorbt_oss/scenario_08_slippage_pct.py @@ -59,7 +59,7 @@ def run_vectorbt_oss(prices_df, entries, exits): def run_ml4t_backtest(prices_df, entries, exits): import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, PercentageSlippage, NoCommission, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, PercentageSlippage, NoCommission, Strategy prices_pl = pl.DataFrame({ "timestamp": prices_df.index.to_pydatetime().tolist(), diff --git a/validation/vectorbt_oss/scenario_09_trailing_stop.py b/validation/vectorbt_oss/scenario_09_trailing_stop.py index 7994e287..2a8f2f36 100644 --- a/validation/vectorbt_oss/scenario_09_trailing_stop.py +++ b/validation/vectorbt_oss/scenario_09_trailing_stop.py @@ -77,7 +77,7 @@ def run_vectorbt_oss(prices_df, entries): def run_ml4t_backtest(prices_df, entries): import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoSlippage, NoCommission, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoSlippage, NoCommission, Strategy from ml4t.backtest.risk.position import TrailingStop prices_pl = pl.DataFrame({ diff --git a/validation/vectorbt_oss/scenario_10_bracket_order.py b/validation/vectorbt_oss/scenario_10_bracket_order.py index 8e6bc979..dd372fac 100644 --- a/validation/vectorbt_oss/scenario_10_bracket_order.py +++ b/validation/vectorbt_oss/scenario_10_bracket_order.py @@ -86,7 +86,7 @@ def run_vectorbt_oss(prices_df, entries): def run_ml4t_backtest(prices_df, entries): import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoSlippage, NoCommission, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoSlippage, NoCommission, Strategy from ml4t.backtest.risk.position import StopLoss, TakeProfit from ml4t.backtest.risk.position.composite import RuleChain diff --git a/validation/vectorbt_oss/scenario_11_short_only.py b/validation/vectorbt_oss/scenario_11_short_only.py index c357822b..381c1ecd 100644 --- a/validation/vectorbt_oss/scenario_11_short_only.py +++ b/validation/vectorbt_oss/scenario_11_short_only.py @@ -120,7 +120,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nd """Run short-only backtest using ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, OrderSide, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, OrderSide, Strategy prices_pl = pl.DataFrame( { diff --git a/validation/vectorbt_oss/scenario_12_short_trailing_stop.py b/validation/vectorbt_oss/scenario_12_short_trailing_stop.py index 69bab240..ad67935f 100644 --- a/validation/vectorbt_oss/scenario_12_short_trailing_stop.py +++ b/validation/vectorbt_oss/scenario_12_short_trailing_stop.py @@ -179,7 +179,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: """ml4t.backtest trailing stop for SHORT positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_oss/scenario_13_tsl_tp_combo.py b/validation/vectorbt_oss/scenario_13_tsl_tp_combo.py index 11be8ce5..caecc726 100644 --- a/validation/vectorbt_oss/scenario_13_tsl_tp_combo.py +++ b/validation/vectorbt_oss/scenario_13_tsl_tp_combo.py @@ -155,7 +155,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray, scenario: str) -> dic """ml4t.backtest with TSL + TP for LONG positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk import RuleChain diff --git a/validation/vectorbt_oss/scenario_14_tsl_sl_combo.py b/validation/vectorbt_oss/scenario_14_tsl_sl_combo.py index 31b8711f..5116c365 100644 --- a/validation/vectorbt_oss/scenario_14_tsl_sl_combo.py +++ b/validation/vectorbt_oss/scenario_14_tsl_sl_combo.py @@ -191,7 +191,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray, scenario: str) -> dic """ml4t.backtest with TSL + SL for LONG positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk import RuleChain diff --git a/validation/vectorbt_oss/scenario_15_triple_rule.py b/validation/vectorbt_oss/scenario_15_triple_rule.py index a842a9d8..0377a646 100644 --- a/validation/vectorbt_oss/scenario_15_triple_rule.py +++ b/validation/vectorbt_oss/scenario_15_triple_rule.py @@ -227,7 +227,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray, scenario: str) -> dic """ml4t.backtest with TSL + TP + SL.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk import RuleChain diff --git a/validation/vectorbt_oss/scenario_16_stress_1000bars.py b/validation/vectorbt_oss/scenario_16_stress_1000bars.py index c9d4f561..ab79c5c2 100644 --- a/validation/vectorbt_oss/scenario_16_stress_1000bars.py +++ b/validation/vectorbt_oss/scenario_16_stress_1000bars.py @@ -134,7 +134,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: """ml4t.backtest stress test with TSL.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/vectorbt_pro/benchmark_performance.py b/validation/vectorbt_pro/benchmark_performance.py index 2d55b15c..4a5d1f49 100644 --- a/validation/vectorbt_pro/benchmark_performance.py +++ b/validation/vectorbt_pro/benchmark_performance.py @@ -121,7 +121,7 @@ def benchmark_ml4t_backtest( """Benchmark ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy # Prepare data in polars format rows = [] diff --git a/validation/vectorbt_pro/scenario_01_long_only.py b/validation/vectorbt_pro/scenario_01_long_only.py index 02d8428d..6570a029 100644 --- a/validation/vectorbt_pro/scenario_01_long_only.py +++ b/validation/vectorbt_pro/scenario_01_long_only.py @@ -128,7 +128,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nd """Run backtest using ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy # Convert to polars format prices_pl = pl.DataFrame( diff --git a/validation/vectorbt_pro/scenario_02_long_short.py b/validation/vectorbt_pro/scenario_02_long_short.py index 9ea74680..3fb15a3a 100644 --- a/validation/vectorbt_pro/scenario_02_long_short.py +++ b/validation/vectorbt_pro/scenario_02_long_short.py @@ -158,7 +158,7 @@ def run_ml4t_backtest( """Run backtest using ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy # Convert to polars format prices_pl = pl.DataFrame( diff --git a/validation/vectorbt_pro/scenario_03_stop_loss.py b/validation/vectorbt_pro/scenario_03_stop_loss.py index 605bed07..f2a6caa7 100644 --- a/validation/vectorbt_pro/scenario_03_stop_loss.py +++ b/validation/vectorbt_pro/scenario_03_stop_loss.py @@ -165,7 +165,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, sl_pct: floa """Run backtest using ml4t.backtest with stop-loss.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_pro/scenario_04_take_profit.py b/validation/vectorbt_pro/scenario_04_take_profit.py index 70339cca..c7fcc6ab 100644 --- a/validation/vectorbt_pro/scenario_04_take_profit.py +++ b/validation/vectorbt_pro/scenario_04_take_profit.py @@ -165,7 +165,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, tp_pct: floa """Run backtest using ml4t.backtest with take-profit.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_pro/scenario_05_commission_pct.py b/validation/vectorbt_pro/scenario_05_commission_pct.py index 32390a4b..51669d63 100644 --- a/validation/vectorbt_pro/scenario_05_commission_pct.py +++ b/validation/vectorbt_pro/scenario_05_commission_pct.py @@ -109,7 +109,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nd """Run backtest using ml4t.backtest with percentage commission.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_pro/scenario_06_commission_per_share.py b/validation/vectorbt_pro/scenario_06_commission_per_share.py index fa887bfc..70af3728 100644 --- a/validation/vectorbt_pro/scenario_06_commission_per_share.py +++ b/validation/vectorbt_pro/scenario_06_commission_per_share.py @@ -113,7 +113,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nd """Run backtest using ml4t.backtest with per-share commission.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_pro/scenario_07_slippage_fixed.py b/validation/vectorbt_pro/scenario_07_slippage_fixed.py index 947dc36f..2a8992fd 100644 --- a/validation/vectorbt_pro/scenario_07_slippage_fixed.py +++ b/validation/vectorbt_pro/scenario_07_slippage_fixed.py @@ -98,7 +98,7 @@ def run_vectorbt_pro(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nda def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_pro/scenario_08_slippage_pct.py b/validation/vectorbt_pro/scenario_08_slippage_pct.py index 955ec997..80579d09 100644 --- a/validation/vectorbt_pro/scenario_08_slippage_pct.py +++ b/validation/vectorbt_pro/scenario_08_slippage_pct.py @@ -88,7 +88,7 @@ def run_vectorbt_pro(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nda def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_pro/scenario_09_trailing_stop.py b/validation/vectorbt_pro/scenario_09_trailing_stop.py index b46a1f40..f5b12701 100644 --- a/validation/vectorbt_pro/scenario_09_trailing_stop.py +++ b/validation/vectorbt_pro/scenario_09_trailing_stop.py @@ -128,7 +128,7 @@ def run_vectorbt_pro(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_pro/scenario_10_bracket_order.py b/validation/vectorbt_pro/scenario_10_bracket_order.py index 0166126e..9bcbd18f 100644 --- a/validation/vectorbt_pro/scenario_10_bracket_order.py +++ b/validation/vectorbt_pro/scenario_10_bracket_order.py @@ -134,7 +134,7 @@ def run_vectorbt_pro(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_pro/scenario_11_short_only.py b/validation/vectorbt_pro/scenario_11_short_only.py index 97588650..add3226f 100644 --- a/validation/vectorbt_pro/scenario_11_short_only.py +++ b/validation/vectorbt_pro/scenario_11_short_only.py @@ -145,7 +145,7 @@ def run_ml4t_backtest(data_list, entries, exits): """Run short-only backtest using ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, NoCommission, NoSlippage, OrderSide, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, NoCommission, NoSlippage, OrderSide, Strategy # Build polars DataFrame all_rows = [] diff --git a/validation/vectorbt_pro/scenario_12_short_trailing_stop.py b/validation/vectorbt_pro/scenario_12_short_trailing_stop.py index 24a6b9fd..3cf1b2a7 100644 --- a/validation/vectorbt_pro/scenario_12_short_trailing_stop.py +++ b/validation/vectorbt_pro/scenario_12_short_trailing_stop.py @@ -179,7 +179,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: """ml4t.backtest trailing stop for SHORT positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_pro/scenario_12b_short_tsl_stress.py b/validation/vectorbt_pro/scenario_12b_short_tsl_stress.py index c2a1715f..189c2b92 100644 --- a/validation/vectorbt_pro/scenario_12b_short_tsl_stress.py +++ b/validation/vectorbt_pro/scenario_12b_short_tsl_stress.py @@ -162,7 +162,7 @@ def run_comparison(prices_df: pd.DataFrame, entries: np.ndarray, import vectorbtpro as vbt import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/vectorbt_pro/scenario_13_tsl_tp_combo.py b/validation/vectorbt_pro/scenario_13_tsl_tp_combo.py index 7fa1d1dc..a662b3e7 100644 --- a/validation/vectorbt_pro/scenario_13_tsl_tp_combo.py +++ b/validation/vectorbt_pro/scenario_13_tsl_tp_combo.py @@ -269,7 +269,7 @@ def run_ml4t_long(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: """ml4t.backtest with TSL + TP for LONG positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk import RuleChain @@ -346,7 +346,7 @@ def run_ml4t_short(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: """ml4t.backtest with TSL + TP for SHORT positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, OrderSide, Strategy, ) from ml4t.backtest.risk import RuleChain diff --git a/validation/vectorbt_pro/scenario_14_tsl_sl_combo.py b/validation/vectorbt_pro/scenario_14_tsl_sl_combo.py index a876b934..67df2c1b 100644 --- a/validation/vectorbt_pro/scenario_14_tsl_sl_combo.py +++ b/validation/vectorbt_pro/scenario_14_tsl_sl_combo.py @@ -228,7 +228,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray, scenario: str) -> dic """ml4t.backtest with TSL + SL for LONG positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk import RuleChain diff --git a/validation/vectorbt_pro/scenario_15_triple_rule.py b/validation/vectorbt_pro/scenario_15_triple_rule.py index 09b69d59..7feeedff 100644 --- a/validation/vectorbt_pro/scenario_15_triple_rule.py +++ b/validation/vectorbt_pro/scenario_15_triple_rule.py @@ -254,7 +254,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray, scenario: str) -> dic """ml4t.backtest with TSL + TP + SL.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk import RuleChain diff --git a/validation/vectorbt_pro/scenario_16_stress_1000bars.py b/validation/vectorbt_pro/scenario_16_stress_1000bars.py index d3f7549c..c3ca6616 100644 --- a/validation/vectorbt_pro/scenario_16_stress_1000bars.py +++ b/validation/vectorbt_pro/scenario_16_stress_1000bars.py @@ -174,7 +174,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: """ml4t.backtest stress test with TSL.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/zipline/benchmark_performance.py b/validation/zipline/benchmark_performance.py index 405444cf..067c3941 100644 --- a/validation/zipline/benchmark_performance.py +++ b/validation/zipline/benchmark_performance.py @@ -233,7 +233,7 @@ def benchmark_ml4t_backtest( """Benchmark ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy # Prepare data in polars format rows = [] diff --git a/validation/zipline/scenario_01_long_only.py b/validation/zipline/scenario_01_long_only.py index 1cb3bb9a..56790347 100644 --- a/validation/zipline/scenario_01_long_only.py +++ b/validation/zipline/scenario_01_long_only.py @@ -266,7 +266,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nd """Run backtest using ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy # Convert to polars format - use same dates as input prices_pl = pl.DataFrame( diff --git a/validation/zipline/scenario_02_long_short.py b/validation/zipline/scenario_02_long_short.py index f9a75867..52c0ce21 100644 --- a/validation/zipline/scenario_02_long_short.py +++ b/validation/zipline/scenario_02_long_short.py @@ -261,7 +261,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, signals: dict) -> dict: """Run backtest using ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy prices_pl = pl.DataFrame( { diff --git a/validation/zipline/scenario_03_stop_loss.py b/validation/zipline/scenario_03_stop_loss.py index e511e54b..d2d7e139 100644 --- a/validation/zipline/scenario_03_stop_loss.py +++ b/validation/zipline/scenario_03_stop_loss.py @@ -247,7 +247,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, sl_pct: floa """Run backtest using ml4t.backtest with stop-loss.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/zipline/scenario_04_take_profit.py b/validation/zipline/scenario_04_take_profit.py index d85e0fd7..ec1fc542 100644 --- a/validation/zipline/scenario_04_take_profit.py +++ b/validation/zipline/scenario_04_take_profit.py @@ -246,7 +246,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, tp_pct: floa """Run backtest using ml4t.backtest with take-profit.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/zipline/scenario_05_commission_pct.py b/validation/zipline/scenario_05_commission_pct.py index bc165f1d..e0f8544a 100644 --- a/validation/zipline/scenario_05_commission_pct.py +++ b/validation/zipline/scenario_05_commission_pct.py @@ -228,7 +228,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: """Run backtest using ml4t.backtest with percentage commission.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/zipline/scenario_06_commission_per_share.py b/validation/zipline/scenario_06_commission_per_share.py index 7dce7cd1..37cbd867 100644 --- a/validation/zipline/scenario_06_commission_per_share.py +++ b/validation/zipline/scenario_06_commission_per_share.py @@ -154,7 +154,7 @@ def handle_data(context, data): def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoSlippage, PerShareCommission, Strategy, ) diff --git a/validation/zipline/scenario_07_slippage_fixed.py b/validation/zipline/scenario_07_slippage_fixed.py index c2ced107..b1093780 100644 --- a/validation/zipline/scenario_07_slippage_fixed.py +++ b/validation/zipline/scenario_07_slippage_fixed.py @@ -154,7 +154,7 @@ def handle_data(context, data): def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, FixedSlippage, NoCommission, Strategy, ) diff --git a/validation/zipline/scenario_08_slippage_pct.py b/validation/zipline/scenario_08_slippage_pct.py index 671b248e..07848e57 100644 --- a/validation/zipline/scenario_08_slippage_pct.py +++ b/validation/zipline/scenario_08_slippage_pct.py @@ -165,7 +165,7 @@ def handle_data(context, data): def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, PercentageSlippage, Strategy, ) diff --git a/validation/zipline/scenario_09_trailing_stop.py b/validation/zipline/scenario_09_trailing_stop.py index a730f8cf..86d76922 100644 --- a/validation/zipline/scenario_09_trailing_stop.py +++ b/validation/zipline/scenario_09_trailing_stop.py @@ -179,7 +179,7 @@ def handle_data(context, data): def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk.position import TrailingStop diff --git a/validation/zipline/scenario_11_short_only.py b/validation/zipline/scenario_11_short_only.py index 9949582d..c815b8cf 100644 --- a/validation/zipline/scenario_11_short_only.py +++ b/validation/zipline/scenario_11_short_only.py @@ -220,7 +220,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray, exits: np.nd """Run short-only backtest using ml4t.backtest.""" import polars as pl - from ml4t.backtest import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, OrderSide, Strategy + from ml4t.backtest._validation_imports import DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, OrderSide, Strategy # Remove timezone for ml4t prices_pl = pl.DataFrame( diff --git a/validation/zipline/scenario_12_short_trailing_stop.py b/validation/zipline/scenario_12_short_trailing_stop.py index d89baaef..5515e91c 100644 --- a/validation/zipline/scenario_12_short_trailing_stop.py +++ b/validation/zipline/scenario_12_short_trailing_stop.py @@ -239,7 +239,7 @@ def run_ml4t_backtest(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: """ml4t.backtest trailing stop for SHORT positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, diff --git a/validation/zipline/scenario_13_tsl_tp_combo.py b/validation/zipline/scenario_13_tsl_tp_combo.py index 82013f97..7ae9e6a5 100644 --- a/validation/zipline/scenario_13_tsl_tp_combo.py +++ b/validation/zipline/scenario_13_tsl_tp_combo.py @@ -252,7 +252,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray, scenario: str) -> dic """ml4t.backtest with TSL + TP for LONG positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk import RuleChain diff --git a/validation/zipline/scenario_14_tsl_sl_combo.py b/validation/zipline/scenario_14_tsl_sl_combo.py index da3f712b..9291e1d7 100644 --- a/validation/zipline/scenario_14_tsl_sl_combo.py +++ b/validation/zipline/scenario_14_tsl_sl_combo.py @@ -205,7 +205,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray, scenario: str) -> dic """ml4t.backtest with TSL + SL for LONG positions.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk import RuleChain diff --git a/validation/zipline/scenario_15_triple_rule.py b/validation/zipline/scenario_15_triple_rule.py index c9cfb98b..4e1585f8 100644 --- a/validation/zipline/scenario_15_triple_rule.py +++ b/validation/zipline/scenario_15_triple_rule.py @@ -219,7 +219,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray, scenario: str) -> dic """ml4t.backtest with TSL + TP + SL.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk import RuleChain diff --git a/validation/zipline/scenario_16_stress_1000bars.py b/validation/zipline/scenario_16_stress_1000bars.py index a25da4af..bcfa99b7 100644 --- a/validation/zipline/scenario_16_stress_1000bars.py +++ b/validation/zipline/scenario_16_stress_1000bars.py @@ -131,7 +131,7 @@ def run_ml4t(prices_df: pd.DataFrame, entries: np.ndarray) -> dict: """ml4t.backtest stress test with TSL.""" import polars as pl - from ml4t.backtest import ( + from ml4t.backtest._validation_imports import ( DataFeed, Engine, ExecutionMode, NoCommission, NoSlippage, Strategy, ) from ml4t.backtest.risk.position import TrailingStop From 45241e1bc0e9bac5b54f5c54954e38d249b2c421 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 09:20:29 -0500 Subject: [PATCH 09/24] test: add deterministic core contract tests (phase C1) --- src/ml4t/backtest/_validation_imports.py | 2 +- tests/contracts/test_execution_contracts.py | 58 +++++++++++++++ tests/contracts/test_ledger_invariants.py | 69 ++++++++++++++++++ tests/contracts/test_profile_parity_basics.py | 71 +++++++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 tests/contracts/test_execution_contracts.py create mode 100644 tests/contracts/test_ledger_invariants.py create mode 100644 tests/contracts/test_profile_parity_basics.py diff --git a/src/ml4t/backtest/_validation_imports.py b/src/ml4t/backtest/_validation_imports.py index 7bb306d8..4c81a1d6 100644 --- a/src/ml4t/backtest/_validation_imports.py +++ b/src/ml4t/backtest/_validation_imports.py @@ -15,9 +15,9 @@ FixedSlippage, NoCommission, NoSlippage, - PerShareCommission, PercentageCommission, PercentageSlippage, + PerShareCommission, ) from .strategy import Strategy from .types import ( diff --git a/tests/contracts/test_execution_contracts.py b/tests/contracts/test_execution_contracts.py new file mode 100644 index 00000000..6992aa93 --- /dev/null +++ b/tests/contracts/test_execution_contracts.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from datetime import datetime, timedelta + +import polars as pl + +from ml4t.backtest.engine import run_backtest +from ml4t.backtest.strategy import Strategy +from ml4t.backtest.types import ExecutionMode + + +def _prices() -> pl.DataFrame: + start = datetime(2024, 1, 1) + rows = [ + { + "timestamp": start, + "asset": "AAPL", + "open": 90.0, + "high": 105.0, + "low": 89.0, + "close": 100.0, + "volume": 1_000_000.0, + }, + { + "timestamp": start + timedelta(days=1), + "asset": "AAPL", + "open": 110.0, + "high": 112.0, + "low": 109.0, + "close": 111.0, + "volume": 1_000_000.0, + }, + ] + return pl.DataFrame(rows) + + +class _BuyOnce(Strategy): + def __init__(self) -> None: + self.done = False + + def on_data(self, timestamp, data, context, broker) -> None: + if not self.done: + broker.submit_order("AAPL", 1.0) + self.done = True + + +def _entry_price(mode: ExecutionMode) -> float: + result = run_backtest(prices=_prices(), strategy=_BuyOnce(), execution_mode=mode) + assert result.trades + return result.trades[0].entry_price + + +def test_same_bar_fills_at_signal_bar_close() -> None: + assert _entry_price(ExecutionMode.SAME_BAR) == 100.0 + + +def test_next_bar_fills_at_following_bar_open() -> None: + assert _entry_price(ExecutionMode.NEXT_BAR) == 110.0 diff --git a/tests/contracts/test_ledger_invariants.py b/tests/contracts/test_ledger_invariants.py new file mode 100644 index 00000000..c2a455c8 --- /dev/null +++ b/tests/contracts/test_ledger_invariants.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from datetime import datetime, timedelta + +import polars as pl + +from ml4t.backtest.engine import run_backtest +from ml4t.backtest.strategy import Strategy + + +def _prices(closes: list[float]) -> pl.DataFrame: + start = datetime(2024, 1, 1) + rows = [] + for i, close in enumerate(closes): + ts = start + timedelta(days=i) + rows.append( + { + "timestamp": ts, + "asset": "AAPL", + "open": close, + "high": close, + "low": close, + "close": close, + "volume": 1_000_000.0, + } + ) + return pl.DataFrame(rows) + + +class _NoopStrategy(Strategy): + def on_data(self, timestamp, data, context, broker) -> None: + return + + +class _SingleRoundTrip(Strategy): + def __init__(self) -> None: + self.bar = 0 + + def on_data(self, timestamp, data, context, broker) -> None: + self.bar += 1 + if self.bar == 1: + broker.submit_order("AAPL", 10.0) + elif self.bar == 3: + broker.close_position("AAPL") + + +def test_no_trade_preserves_cash_and_equity() -> None: + result = run_backtest(prices=_prices([100.0, 101.0, 102.0]), strategy=_NoopStrategy()) + + assert result.metrics["initial_cash"] == 100000.0 + assert result.metrics["final_value"] == 100000.0 + assert result.metrics["num_trades"] == 0 + assert len(result.trades) == 0 + assert len(result.fills) == 0 + assert all(eq == 100000.0 for _, eq in result.equity_curve) + + +def test_closed_trade_pnl_reconciles_to_final_value() -> None: + result = run_backtest(prices=_prices([100.0, 110.0, 120.0, 130.0]), strategy=_SingleRoundTrip()) + + closed_trades = [t for t in result.trades if t.status == "closed"] + assert len(closed_trades) == 1 + + total_pnl = sum(t.pnl for t in closed_trades) + initial_cash = result.metrics["initial_cash"] + final_value = result.metrics["final_value"] + + assert abs((initial_cash + total_pnl) - final_value) < 1e-9 + assert abs(result.equity_curve[-1][1] - final_value) < 1e-9 diff --git a/tests/contracts/test_profile_parity_basics.py b/tests/contracts/test_profile_parity_basics.py new file mode 100644 index 00000000..71d235c7 --- /dev/null +++ b/tests/contracts/test_profile_parity_basics.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from datetime import datetime, timedelta + +import polars as pl + +from ml4t.backtest.config import BacktestConfig +from ml4t.backtest.engine import run_backtest +from ml4t.backtest.profiles import list_profiles +from ml4t.backtest.strategy import Strategy +from ml4t.backtest.types import StopLevelBasis + + +def _prices() -> pl.DataFrame: + start = datetime(2024, 1, 1) + rows = [] + for i, (open_, close) in enumerate([(100.0, 101.0), (110.0, 111.0)]): + ts = start + timedelta(days=i) + rows.append( + { + "timestamp": ts, + "asset": "AAPL", + "open": open_, + "high": max(open_, close), + "low": min(open_, close), + "close": close, + "volume": 1_000_000.0, + } + ) + return pl.DataFrame(rows) + + +class _BuyOnce(Strategy): + def __init__(self) -> None: + self.done = False + + def on_data(self, timestamp, data, context, broker) -> None: + if not self.done: + broker.submit_order("AAPL", 1.0) + self.done = True + + +def test_profile_registry_has_expected_core_profiles() -> None: + assert list_profiles() == ["backtrader", "default", "realistic", "vectorbt", "zipline"] + + +def test_string_preset_and_explicit_preset_config_match() -> None: + by_name = run_backtest(prices=_prices(), strategy=_BuyOnce(), config="vectorbt") + by_config = run_backtest( + prices=_prices(), + strategy=_BuyOnce(), + config=BacktestConfig.from_preset("vectorbt"), + ) + + assert by_name.metrics["final_value"] == by_config.metrics["final_value"] + assert by_name.trades[0].entry_price == by_config.trades[0].entry_price + + +def test_profiles_enforce_expected_entry_timing_contract() -> None: + vbt = run_backtest(prices=_prices(), strategy=_BuyOnce(), config="vectorbt") + bt = run_backtest(prices=_prices(), strategy=_BuyOnce(), config="backtrader") + zl = run_backtest(prices=_prices(), strategy=_BuyOnce(), config="zipline") + + assert vbt.trades[0].entry_price == 101.0 # same-bar close + assert 110.0 < bt.trades[0].entry_price < 111.0 # next-bar open with default slippage + assert zl.trades[0].entry_price == 110.0 # next-bar open + + +def test_backtrader_profile_uses_signal_price_stop_basis() -> None: + cfg = BacktestConfig.from_preset("backtrader") + assert cfg.stop_level_basis == StopLevelBasis.SIGNAL_PRICE From d467671ec7aa3bdbc1616ad329dc5ccea214512f Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 09:27:12 -0500 Subject: [PATCH 10/24] test: add property-based accounting and ordering invariants (phase C2) --- .gitignore | 5 ++ tests/property/test_accounting_invariants.py | 51 ++++++++++++++++ .../test_order_sequence_invariants.py | 60 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 tests/property/test_accounting_invariants.py create mode 100644 tests/property/test_order_sequence_invariants.py diff --git a/.gitignore b/.gitignore index b7d66c3a..8cf16826 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ ENV/ # Testing .pytest_cache/ +.hypothesis/ .coverage htmlcov/ .tox/ @@ -39,3 +40,7 @@ src/ml4t/backtest/_version.py # Claude Code (local development only) CLAUDE.md .claude/ + +# Validation-generated local artifacts +validation/.zipline/ +validation/CORRECTNESS_RESULTS.md diff --git a/tests/property/test_accounting_invariants.py b/tests/property/test_accounting_invariants.py new file mode 100644 index 00000000..2c6baea7 --- /dev/null +++ b/tests/property/test_accounting_invariants.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import datetime + +from hypothesis import given, settings +from hypothesis import strategies as st + +from ml4t.backtest import Broker, OrderSide +from ml4t.backtest.models import NoCommission, NoSlippage + + +def _set_bar(broker: Broker, price: float) -> None: + ts = datetime(2024, 1, 1) + broker._update_time( + ts, + {"AAPL": price}, + {"AAPL": price}, + {"AAPL": price}, + {"AAPL": price}, + {"AAPL": 1_000_000.0}, + {"AAPL": {}}, + ) + + +@settings(max_examples=60) +@given( + entry=st.floats(min_value=10.0, max_value=500.0, allow_nan=False, allow_infinity=False), + exit_=st.floats(min_value=10.0, max_value=500.0, allow_nan=False, allow_infinity=False), + qty=st.floats(min_value=0.1, max_value=100.0, allow_nan=False, allow_infinity=False), +) +def test_round_trip_pnl_reconciles_cash(entry: float, exit_: float, qty: float) -> None: + initial_cash = 200_000.0 + broker = Broker(initial_cash, NoCommission(), NoSlippage()) + + _set_bar(broker, entry) + broker.submit_order("AAPL", qty, OrderSide.BUY) + broker._process_orders() + + _set_bar(broker, exit_) + broker.close_position("AAPL") + broker._process_orders() + + assert broker.get_position("AAPL") is None + assert broker.trades + + trade = broker.trades[-1] + expected_pnl = (exit_ - entry) * qty + assert abs(trade.pnl - expected_pnl) < 1e-8 + assert abs((initial_cash + expected_pnl) - broker.cash) < 1e-8 + assert abs(broker.get_account_value() - broker.cash) < 1e-8 + diff --git a/tests/property/test_order_sequence_invariants.py b/tests/property/test_order_sequence_invariants.py new file mode 100644 index 00000000..e6d52f8b --- /dev/null +++ b/tests/property/test_order_sequence_invariants.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from datetime import datetime + +from hypothesis import given, settings +from hypothesis import strategies as st + +from ml4t.backtest import Broker, OrderSide +from ml4t.backtest.config import FillOrdering +from ml4t.backtest.models import NoCommission, NoSlippage + + +def _set_bar(broker: Broker, price: float) -> None: + ts = datetime(2024, 1, 1) + broker._update_time( + ts, + {"AAPL": price}, + {"AAPL": price}, + {"AAPL": price}, + {"AAPL": price}, + {"AAPL": 1_000_000.0}, + {"AAPL": {}}, + ) + + +def _scenario(fill_ordering: FillOrdering, price: float, qty: float) -> tuple[float, float]: + broker = Broker( + initial_cash=price * qty, + commission_model=NoCommission(), + slippage_model=NoSlippage(), + fill_ordering=fill_ordering, + reject_on_insufficient_cash=True, + ) + + _set_bar(broker, price) + broker.submit_order("AAPL", qty, OrderSide.BUY) + broker._process_orders() + + # Re-enter before close submission: ordering decides whether re-entry can execute. + broker.submit_order("AAPL", qty, OrderSide.BUY) + broker.close_position("AAPL") + broker._process_orders() + + pos = broker.get_position("AAPL") + position_qty = 0.0 if pos is None else pos.quantity + return position_qty, broker.get_account_value() + + +@settings(max_examples=60) +@given( + price=st.floats(min_value=10.0, max_value=500.0, allow_nan=False, allow_infinity=False), + qty=st.floats(min_value=0.1, max_value=200.0, allow_nan=False, allow_infinity=False), +) +def test_exit_first_never_underfills_vs_fifo(price: float, qty: float) -> None: + exit_first_qty, exit_first_value = _scenario(FillOrdering.EXIT_FIRST, price, qty) + fifo_qty, fifo_value = _scenario(FillOrdering.FIFO, price, qty) + + assert exit_first_qty >= fifo_qty + assert abs(exit_first_value - fifo_value) < 1e-8 + From 60473adcb91ad3678de0a9c375967a928b4a548a Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 09:29:27 -0500 Subject: [PATCH 11/24] test: enforce slim root API surface (phase C3) --- tests/contracts/test_public_api_surface.py | 47 ++++++++++++++++++++++ tests/test_strategy_templates.py | 11 +---- 2 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 tests/contracts/test_public_api_surface.py diff --git a/tests/contracts/test_public_api_surface.py b/tests/contracts/test_public_api_surface.py new file mode 100644 index 00000000..09ae786e --- /dev/null +++ b/tests/contracts/test_public_api_surface.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import ml4t.backtest as bt + + +def test_root_api_contains_only_intended_core_surface() -> None: + required = { + "DataFeed", + "Broker", + "Strategy", + "Engine", + "run_backtest", + "BacktestConfig", + "Mode", + "BacktestResult", + "OrderType", + "OrderSide", + "OrderStatus", + "ExecutionMode", + "ExitReason", + "StopFillMode", + "StopLevelBasis", + "Order", + "Position", + "Fill", + "Trade", + "StopLoss", + "TrailingStop", + "RuleChain", + } + assert required.issubset(set(bt.__all__)) + + removed_legacy_exports = { + "NoCommission", + "NoSlippage", + "PercentageCommission", + "PercentageSlippage", + "PerShareCommission", + "RebalanceConfig", + "TargetWeightExecutor", + "LinearImpact", + "VolumeParticipationLimit", + "WaterMarkSource", + "InitialHwmSource", + "TrailHwmSource", + } + assert removed_legacy_exports.isdisjoint(set(bt.__all__)) diff --git a/tests/test_strategy_templates.py b/tests/test_strategy_templates.py index 1e63acee..669c1b7a 100644 --- a/tests/test_strategy_templates.py +++ b/tests/test_strategy_templates.py @@ -294,16 +294,7 @@ def test_ranking(self): class TestStrategyImports: - """Test that strategy templates are properly exported.""" - - def test_not_imported_from_package_root(self): - """Templates should not be imported into ml4t.backtest root.""" - import ml4t.backtest as bt - - assert not hasattr(bt, "SignalFollowingStrategy") - assert not hasattr(bt, "MomentumStrategy") - assert not hasattr(bt, "MeanReversionStrategy") - assert not hasattr(bt, "LongShortStrategy") + """Test strategy template import paths.""" def test_import_from_strategies(self): """Test importing from ml4t.backtest.strategies.""" From 74932e306b17be4287f671d1e3e0e7f72fb006cd Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 09:36:03 -0500 Subject: [PATCH 12/24] chore(validation): improve correctness runner failure diagnostics --- validation/run_all_correctness.py | 34 ++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/validation/run_all_correctness.py b/validation/run_all_correctness.py index 94205dd9..8ab12453 100644 --- a/validation/run_all_correctness.py +++ b/validation/run_all_correctness.py @@ -79,6 +79,26 @@ } +def _extract_error_summary(output: str, returncode: int) -> str | None: + """Extract a concise failure summary from script output.""" + if returncode == 0: + return None + + lines = [line.strip() for line in output.splitlines() if line.strip()] + if not lines: + return f"Process exited with code {returncode}" + + # Prefer explicit Python exception lines if present. + for line in reversed(lines): + if line.startswith(("AssertionError", "ValueError", "TypeError", "RuntimeError")): + return line + if "Error" in line or "Exception" in line or line.startswith("Traceback"): + return line + + # Fall back to last emitted line. + return lines[-1][:200] + + def run_scenario(framework: str, scenario: str) -> dict: """Run a single validation scenario. @@ -124,16 +144,10 @@ def run_scenario(framework: str, scenario: str) -> dict: output = result.stdout + result.stderr - # Check for PASS/FAIL in output - if "PASS" in output.upper() and "FAIL" not in output.upper(): - passed = True - elif "FAIL" in output.upper() or result.returncode != 0: - passed = False - else: - # Check for error indicators - passed = result.returncode == 0 - - return {"passed": passed, "error": None, "output": output} + # Use process status as source of truth for determinism. + passed = result.returncode == 0 + error = _extract_error_summary(output, result.returncode) + return {"passed": passed, "error": error, "output": output} except subprocess.TimeoutExpired: return {"passed": False, "error": "Timeout (120s)", "output": ""} From 35315d947b8375982d67ee5bc1631e34b813b43b Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 09:37:09 -0500 Subject: [PATCH 13/24] refactor(validation): use profiles in benchmark suite ml4t runner --- validation/benchmark_suite.py | 58 ++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/validation/benchmark_suite.py b/validation/benchmark_suite.py index 0eaadee7..4d0547df 100644 --- a/validation/benchmark_suite.py +++ b/validation/benchmark_suite.py @@ -568,18 +568,15 @@ def benchmark_ml4t( import polars as pl from ml4t.backtest._validation_imports import ( + BacktestConfig, DataFeed, Engine, - ExecutionMode, - NoCommission, - NoSlippage, - PercentageCommission, - PercentageSlippage, Strategy, ) + from ml4t.backtest.config import CommissionModel, SlippageModel - # Select execution mode - exec_mode = ExecutionMode.NEXT_BAR if execution_mode == "next_bar" else ExecutionMode.SAME_BAR + # Select profile by execution style + profile_name = "backtrader" if execution_mode == "next_bar" else "vectorbt" framework_name = ( "ml4t.backtest" if execution_mode == "same_bar" else "ml4t.backtest (backtrader-mode)" ) @@ -671,25 +668,40 @@ def on_data(self, timestamp, data, context, broker): if target_qty != 0: broker.submit_order(asset_name, target_qty) - # Set up commission/slippage - commission = ( - PercentageCommission(config.commission_pct) if config.commission_pct > 0 else NoCommission() - ) - slippage = PercentageSlippage(config.slippage_pct) if config.slippage_pct > 0 else NoSlippage() + def build_ml4t_config(no_costs: bool) -> BacktestConfig: + cfg = BacktestConfig.from_preset(profile_name) + cfg.initial_cash = 1e15 # Unlimited cash to eliminate margin rejections + cfg.allow_short_selling = True + cfg.allow_leverage = True + if no_costs: + cfg.commission_model = CommissionModel.NONE + cfg.commission_rate = 0.0 + cfg.slippage_model = SlippageModel.NONE + cfg.slippage_rate = 0.0 + else: + if config.commission_pct > 0: + cfg.commission_model = CommissionModel.PERCENTAGE + cfg.commission_rate = config.commission_pct + else: + cfg.commission_model = CommissionModel.NONE + cfg.commission_rate = 0.0 + if config.slippage_pct > 0: + cfg.slippage_model = SlippageModel.PERCENTAGE + cfg.slippage_rate = config.slippage_pct + else: + cfg.slippage_model = SlippageModel.NONE + cfg.slippage_rate = 0.0 + return cfg # Warm-up run (smaller data) n_warmup = min(1000, config.n_bars // 10) warmup_prices = prices_pl.head(n_warmup * config.n_assets) warmup_signals = signals_pl.filter(pl.col("timestamp") <= dates[n_warmup]) warmup_feed = DataFeed(prices_df=warmup_prices, signals_df=warmup_signals) - warmup_engine = Engine( + warmup_engine = Engine.from_config( warmup_feed, TopBottomStrategy(config.top_n, config.bottom_n, config.stop_loss, config.take_profit), - initial_cash=1e15, # Unlimited cash to eliminate margin rejections - allow_short_selling=True, allow_leverage=True, - commission_model=NoCommission(), - slippage_model=NoSlippage(), - execution_mode=exec_mode, + config=build_ml4t_config(no_costs=True), ) _ = warmup_engine.run() @@ -703,14 +715,10 @@ def on_data(self, timestamp, data, context, broker): config.top_n, config.bottom_n, config.stop_loss, config.take_profit ) - engine = Engine( + engine = Engine.from_config( feed, strategy, - initial_cash=1e15, # Unlimited cash to eliminate margin rejections - allow_short_selling=True, allow_leverage=True, - commission_model=commission, - slippage_model=slippage, - execution_mode=exec_mode, + config=build_ml4t_config(no_costs=False), ) results = engine.run() @@ -728,7 +736,7 @@ def on_data(self, timestamp, data, context, broker): { "timestamp": t.entry_time, "exit_time": t.exit_time, - "asset": t.asset, + "asset": t.symbol, "side": "long" if t.quantity > 0 else "short", "quantity": abs(t.quantity), "entry_price": t.entry_price, From ecc616bcf8b818acfbcd6ae7bdebb4b585c39616 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 09:39:42 -0500 Subject: [PATCH 14/24] refactor(broker): remove dead order-processing branches after core split --- src/ml4t/backtest/broker.py | 136 ------------------------------------ 1 file changed, 136 deletions(-) diff --git a/src/ml4t/backtest/broker.py b/src/ml4t/backtest/broker.py index 4532c11e..0d0e2257 100644 --- a/src/ml4t/backtest/broker.py +++ b/src/ml4t/backtest/broker.py @@ -30,7 +30,6 @@ Fill, Order, OrderSide, - OrderStatus, OrderType, Position, StopFillMode, @@ -1476,141 +1475,6 @@ def _get_fill_price_for_order(self, order: Order, use_open: bool) -> float | Non return self._current_opens.get(order.asset) return self._current_prices.get(order.asset) - def _process_single_order( - self, order: Order, use_open: bool, filled_orders: list[Order] - ) -> None: - """Process a single order (shared logic for both ordering modes). - - Handles exit vs entry detection, gatekeeper validation, share rounding, - partial fills, and reject_on_insufficient_cash bypass. - """ - price = self._get_fill_price_for_order(order, use_open) - if price is None: - return - - is_exit = self._is_exit_order(order) - - if is_exit: - # Exit orders always allowed (frees capital) - fill_price = self._check_fill(order, price) - if fill_price is not None: - fully_filled = self._execute_fill(order, fill_price) - if fully_filled: - filled_orders.append(order) - self._partial_orders.pop(order.order_id, None) - else: - self._update_partial_order(order) - else: - # Entry order — apply share rounding before validation - self._apply_share_rounding(order) - if order.quantity <= 0: - order.status = OrderStatus.REJECTED - order.rejection_reason = "Quantity rounds to zero (share_type=INTEGER)" - return - - fill_price = self._check_fill(order, price) - if fill_price is None: - return - - # Validate via gatekeeper - valid, rejection_reason = self.gatekeeper.validate_order(order, fill_price) - - if valid: - fully_filled = self._execute_fill(order, fill_price) - if fully_filled: - filled_orders.append(order) - self._partial_orders.pop(order.order_id, None) - else: - self._update_partial_order(order) - elif ( - not self.reject_on_insufficient_cash and "insufficient" in rejection_reason.lower() - ): - # Permissive mode: skip instead of rejecting - if self.partial_fills_allowed and self._try_partial_fill(order, fill_price): - filled_orders.append(order) - self._partial_orders.pop(order.order_id, None) - # else: silently skip (VBT-like behavior) - elif self.partial_fills_allowed and "insufficient" in rejection_reason.lower(): - # Strict mode but partial fills allowed - if self._try_partial_fill(order, fill_price): - filled_orders.append(order) - self._partial_orders.pop(order.order_id, None) - else: - order.status = OrderStatus.REJECTED - order.rejection_reason = rejection_reason - else: - order.status = OrderStatus.REJECTED - order.rejection_reason = rejection_reason - - def _cleanup_filled_orders(self, filled_orders: list[Order]) -> None: - """Remove filled and rejected orders from pending lists.""" - for order in filled_orders: - if order in self.pending_orders: - self.pending_orders.remove(order) - if order in self._orders_this_bar: - self._orders_this_bar.remove(order) - - for order in self.pending_orders[:]: - if order.status == OrderStatus.REJECTED: - self.pending_orders.remove(order) - - def _process_orders_exit_first(self, use_open: bool = False): - """EXIT_FIRST ordering: all exits → mark-to-market → all entries.""" - exit_orders = [] - entry_orders = [] - - for order in self.pending_orders[:]: - if self.execution_mode == ExecutionMode.NEXT_BAR and order in self._orders_this_bar: - continue - if self._is_exit_order(order): - exit_orders.append(order) - else: - entry_orders.append(order) - - filled_orders: list[Order] = [] - - # Phase 1: Process exit orders - for order in exit_orders: - price = self._get_fill_price_for_order(order, use_open) - if price is None: - continue - fill_price = self._check_fill(order, price) - if fill_price is not None: - fully_filled = self._execute_fill(order, fill_price) - if fully_filled: - filled_orders.append(order) - self._partial_orders.pop(order.order_id, None) - else: - self._update_partial_order(order) - - # Phase 2: Update account equity after exits - self.account.mark_to_market(self._current_prices) - - # Phase 3: Process entry orders - for order in entry_orders: - self._process_single_order(order, use_open, filled_orders) - - self._cleanup_filled_orders(filled_orders) - - def _process_orders_fifo(self, use_open: bool = False): - """FIFO ordering: process orders in submission order with sequential cash updates.""" - eligible_orders = [] - for order in self.pending_orders[:]: - if self.execution_mode == ExecutionMode.NEXT_BAR and order in self._orders_this_bar: - continue - eligible_orders.append(order) - - filled_orders: list[Order] = [] - - for order in eligible_orders: - self._process_single_order(order, use_open, filled_orders) - # Sequential cash update: mark-to-market after each fill - # so the next order sees updated buying power - if filled_orders and filled_orders[-1] is order: - self.account.mark_to_market(self._current_prices) - - self._cleanup_filled_orders(filled_orders) - def _get_effective_quantity(self, order: Order) -> float: """Get effective order quantity (considering partial fills). From 4a8a4c664c8006865362552ad06fd661fc1e6ba5 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 09:44:13 -0500 Subject: [PATCH 15/24] refactor(risk): move position-state helpers into risk engine --- src/ml4t/backtest/broker.py | 49 --------------------------- src/ml4t/backtest/core/risk_engine.py | 49 +++++++++++++++++++++++++-- tests/test_broker.py | 10 +++--- 3 files changed, 51 insertions(+), 57 deletions(-) diff --git a/src/ml4t/backtest/broker.py b/src/ml4t/backtest/broker.py index 0d0e2257..f06b09fd 100644 --- a/src/ml4t/backtest/broker.py +++ b/src/ml4t/backtest/broker.py @@ -536,55 +536,6 @@ def update_position_context(self, asset: str, context: dict) -> None: if pos: pos.context.update(context) - def _get_position_rules(self, asset: str): - """Get applicable rules for an asset (per-asset or global).""" - return self._position_rules_by_asset.get(asset) or self._position_rules - - def _build_position_state(self, pos: Position, current_price: float): - """Build PositionState from Position for rule evaluation.""" - # Import here to avoid circular imports - from .risk.types import PositionState - - asset = pos.asset - - # Merge stop configuration into context for rules to access - context = { - **pos.context, - "stop_fill_mode": self.stop_fill_mode, - "stop_level_basis": self.stop_level_basis, - "trail_hwm_source": self.trail_hwm_source, - "trail_stop_timing": self.trail_stop_timing, - } - - return PositionState( - asset=asset, - side=pos.side, - entry_price=pos.entry_price, - current_price=current_price, - quantity=abs(pos.quantity), - initial_quantity=abs(pos.initial_quantity) - if pos.initial_quantity - else abs(pos.quantity), - unrealized_pnl=pos.unrealized_pnl(current_price), - unrealized_return=pos.pnl_percent(current_price), - bars_held=pos.bars_held, - high_water_mark=pos.high_water_mark - if pos.high_water_mark is not None - else pos.entry_price, - low_water_mark=pos.low_water_mark - if pos.low_water_mark is not None - else pos.entry_price, - # Bar OHLC for intrabar stop/limit detection - bar_open=self._current_opens.get(asset), - bar_high=self._current_highs.get(asset), - bar_low=self._current_lows.get(asset), - max_favorable_excursion=pos.max_favorable_excursion, - max_adverse_excursion=pos.max_adverse_excursion, - entry_time=pos.entry_time, - current_time=self._current_time, - context=context, - ) - def evaluate_position_rules(self) -> list[Order]: """Evaluate position rules for all open positions. diff --git a/src/ml4t/backtest/core/risk_engine.py b/src/ml4t/backtest/core/risk_engine.py index e11b7a50..f7e37e76 100644 --- a/src/ml4t/backtest/core/risk_engine.py +++ b/src/ml4t/backtest/core/risk_engine.py @@ -2,7 +2,7 @@ from __future__ import annotations -from ..risk.types import ActionType +from ..risk.types import ActionType, PositionState from ..types import OrderSide, OrderType from .shared import SubmitOrderOptions, reason_to_exit_reason @@ -18,7 +18,7 @@ def evaluate_position_rules(self): exit_orders = [] for asset, pos in list(broker.positions.items()): - rules = broker._get_position_rules(asset) + rules = self._get_position_rules(asset) if rules is None: continue @@ -26,7 +26,7 @@ def evaluate_position_rules(self): if price is None: continue - state = broker._build_position_state(pos, price) + state = self._build_position_state(pos, price) action = rules.evaluate(state) if action.action == ActionType.EXIT_FULL: @@ -79,6 +79,49 @@ def evaluate_position_rules(self): return exit_orders + def _get_position_rules(self, asset: str): + broker = self.broker + return broker._position_rules_by_asset.get(asset) or broker._position_rules + + def _build_position_state(self, pos, current_price: float): + broker = self.broker + asset = pos.asset + context = { + **pos.context, + "stop_fill_mode": broker.stop_fill_mode, + "stop_level_basis": broker.stop_level_basis, + "trail_hwm_source": broker.trail_hwm_source, + "trail_stop_timing": broker.trail_stop_timing, + } + + return PositionState( + asset=asset, + side=pos.side, + entry_price=pos.entry_price, + current_price=current_price, + quantity=abs(pos.quantity), + initial_quantity=abs(pos.initial_quantity) + if pos.initial_quantity + else abs(pos.quantity), + unrealized_pnl=pos.unrealized_pnl(current_price), + unrealized_return=pos.pnl_percent(current_price), + bars_held=pos.bars_held, + high_water_mark=pos.high_water_mark + if pos.high_water_mark is not None + else pos.entry_price, + low_water_mark=pos.low_water_mark + if pos.low_water_mark is not None + else pos.entry_price, + bar_open=broker._current_opens.get(asset), + bar_high=broker._current_highs.get(asset), + bar_low=broker._current_lows.get(asset), + max_favorable_excursion=pos.max_favorable_excursion, + max_adverse_excursion=pos.max_adverse_excursion, + entry_time=pos.entry_time, + current_time=broker._current_time, + context=context, + ) + def process_pending_exits(self): broker = self.broker exit_orders = [] diff --git a/tests/test_broker.py b/tests/test_broker.py index 6d1e8130..6012c571 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -1137,7 +1137,7 @@ def test_set_position_rules_per_asset(self): # Verify it's stored per-asset assert "AAPL" in broker._position_rules_by_asset - assert broker._get_position_rules("AAPL") == stop_rule + assert broker._position_rules_by_asset["AAPL"] == stop_rule # Global rules should be None assert broker._position_rules is None @@ -1153,8 +1153,8 @@ def test_set_position_rules_global(self): # Verify it's stored globally assert broker._position_rules == tp_rule - # Should apply to any asset - assert broker._get_position_rules("AAPL") == tp_rule + # Should apply to assets without explicit overrides + assert broker._position_rules_by_asset.get("AAPL") is None def test_update_position_context(self): """Test updating position context.""" @@ -1277,7 +1277,7 @@ def test_order_skipped_when_no_price(self): class TestEvaluatePositionRules: - """Test evaluate_position_rules and _build_position_state.""" + """Test evaluate_position_rules and RiskEngine position-state construction.""" def test_evaluate_position_rules_exit_full_immediate(self): """Test EXIT_FULL action without defer_fill.""" @@ -1461,7 +1461,7 @@ def test_build_position_state_populated(self): ) # Build state - state = broker._build_position_state(pos, 105.0) + state = broker._risk_engine._build_position_state(pos, 105.0) assert state.asset == "AAPL" assert state.side == "long" From 3a48104080826f0447ff33bf1786966b11e372dc Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 10:00:21 -0500 Subject: [PATCH 16/24] refactor(core): extract fill engine from broker --- src/ml4t/backtest/broker.py | 292 +------------------ src/ml4t/backtest/core/__init__.py | 2 + src/ml4t/backtest/core/execution_engine.py | 30 +- src/ml4t/backtest/core/fill_engine.py | 156 ++++++++++ src/ml4t/backtest/core/risk_engine.py | 4 +- src/ml4t/backtest/execution/fill_executor.py | 2 +- tests/test_broker.py | 8 +- 7 files changed, 184 insertions(+), 310 deletions(-) create mode 100644 src/ml4t/backtest/core/fill_engine.py diff --git a/src/ml4t/backtest/broker.py b/src/ml4t/backtest/broker.py index f06b09fd..4f7da6ff 100644 --- a/src/ml4t/backtest/broker.py +++ b/src/ml4t/backtest/broker.py @@ -16,6 +16,7 @@ ) from .core import ( ExecutionEngine, + FillEngine, OrderBook, PortfolioLedger, RiskEngine, @@ -180,6 +181,7 @@ def __init__( # Extracted orchestration components (Phase B1 alpha-reset) self._order_book = OrderBook(self) self._risk_engine = RiskEngine(self) + self._fill_engine = FillEngine(self) self._execution_engine = ExecutionEngine(self) self._portfolio_ledger = PortfolioLedger(self) @@ -1355,55 +1357,6 @@ def _update_water_marks(self): use_low_for_lwm=use_extremes, ) - def _get_available_cash(self) -> float: - """Get available cash after applying cash buffer. - - Returns: - Cash available for new orders (total cash minus buffer reserve). - """ - if self.cash_buffer_pct > 0: - return self.account.cash * (1.0 - self.cash_buffer_pct) - return self.account.cash - - def _apply_share_rounding(self, order: Order) -> None: - """Round order quantity to integer if share_type is INTEGER. - - Modifies order in-place. If rounding reduces quantity to zero, - the order will be rejected during validation (zero-quantity check). - """ - if self.share_type == ShareType.INTEGER: - order.quantity = float(int(order.quantity)) - - def _try_partial_fill(self, order: Order, fill_price: float) -> bool: - """Attempt a partial fill when full order cannot be afforded. - - Only used when partial_fills_allowed is True and the gatekeeper - rejects an entry order for insufficient cash. - - Returns: - True if a partial fill was executed, False otherwise. - """ - available = self._get_available_cash() - commission_rate = 0.0 - # Estimate commission rate for sizing - test_commission = self.commission_model.calculate(order.asset, 1.0, fill_price) - if fill_price > 0: - commission_rate = test_commission / fill_price - - # Max affordable shares (accounting for commission) - max_value = available / (1.0 + commission_rate) if commission_rate > 0 else available - max_shares = max_value / fill_price if fill_price > 0 else 0 - - if self.share_type == ShareType.INTEGER: - max_shares = float(int(max_shares)) - - if max_shares <= 0: - return False - - # Modify order to affordable quantity and execute - order.quantity = max_shares - return bool(self._execute_fill(order, fill_price)) - def _process_orders(self, use_open: bool = False): """Process pending orders against current prices. @@ -1419,244 +1372,3 @@ def _process_orders(self, use_open: bool = False): use_open: If True, use open prices (for next-bar mode at bar start). """ self._execution_engine.process_orders(use_open=use_open) - - def _get_fill_price_for_order(self, order: Order, use_open: bool) -> float | None: - """Get the fill price for an order based on execution mode.""" - if use_open and self.execution_mode == ExecutionMode.NEXT_BAR: - return self._current_opens.get(order.asset) - return self._current_prices.get(order.asset) - - def _get_effective_quantity(self, order: Order) -> float: - """Get effective order quantity (considering partial fills). - - For orders with partial fills in progress, returns the remaining quantity. - """ - remaining = self._partial_orders.get(order.order_id) - if remaining is not None: - return remaining - return order.quantity - - def _update_partial_order(self, order: Order) -> None: - """Update order quantity after partial fill for next bar.""" - remaining = self._partial_orders.get(order.order_id) - if remaining is not None: - order.quantity = remaining - - def _check_gap_through( - self, side: OrderSide, stop_price: float, bar_open: float - ) -> float | None: - """Check if bar gapped through stop level. - - If the bar opened beyond our stop level, we must fill at the open price - (worse execution due to gap). - - Args: - side: Order side (BUY or SELL) - stop_price: The stop price level - bar_open: The bar's opening price - - Returns: - bar_open if gapped through, None if normal trigger - """ - if side == OrderSide.SELL and bar_open <= stop_price: - return bar_open # Gapped down through stop - elif side == OrderSide.BUY and bar_open >= stop_price: - return bar_open # Gapped up through stop - return None - - def _check_market_fill(self, order: Order, price: float) -> float: - """Check fill price for market order. - - For risk-triggered exits (stop-loss, take-profit, trailing stop), uses - the fill price already computed by the position rule. Position rules - handle gap-through scenarios correctly in their own logic. - - Also applies additional stop slippage if configured. - - Args: - order: Market order to check - price: Current market price (close) - - Returns: - Fill price for the market order - """ - risk_fill_price = getattr(order, "_risk_fill_price", None) - if risk_fill_price is None: - return price - - # Use the fill price computed by the position rule directly. - # Position rules (TakeProfit, StopLoss, TrailingStop) already handle - # gap-through scenarios correctly in their fill price calculation. - # We should NOT override their logic with a generic gap-through check - # because the direction differs for stop-loss vs take-profit orders. - fill_price = risk_fill_price - - # Apply additional stop slippage if configured - # This models the reality that stop orders often fill at worse prices in fast markets - if self.stop_slippage_rate > 0: - if order.side == OrderSide.SELL: - # Selling: slippage makes price worse (lower) - fill_price = fill_price * (1 - self.stop_slippage_rate) - else: - # Buying (covering short): slippage makes price worse (higher) - fill_price = fill_price * (1 + self.stop_slippage_rate) - - return fill_price - - def _check_limit_fill(self, order: Order, high: float, low: float) -> float | None: - """Check if limit order should fill. - - Limit buy fills if price dipped to our level (Low <= limit). - Limit sell fills if price rose to our level (High >= limit). - - Args: - order: Limit order to check - high: Bar high price - low: Bar low price - - Returns: - Limit price if order should fill, None otherwise - """ - if order.limit_price is None: - return None - - if ( - order.side == OrderSide.BUY - and low <= order.limit_price - or order.side == OrderSide.SELL - and high >= order.limit_price - ): - return order.limit_price - return None - - def _check_stop_fill( - self, order: Order, high: float, low: float, bar_open: float - ) -> float | None: - """Check if stop order should fill. - - Stop buy triggers if price rose to trigger (High >= stop). - Stop sell triggers if price fell to trigger (Low <= stop). - Handles gap-through scenarios. - - Args: - order: Stop order to check - high: Bar high price - low: Bar low price - bar_open: Bar open price - - Returns: - Fill price if triggered, None otherwise - """ - if order.stop_price is None: - return None - - triggered = False - if ( - order.side == OrderSide.BUY - and high >= order.stop_price - or order.side == OrderSide.SELL - and low <= order.stop_price - ): - triggered = True - - if not triggered: - return None - - gap_price = self._check_gap_through(order.side, order.stop_price, bar_open) - return gap_price if gap_price is not None else order.stop_price - - def _update_and_check_trailing_stop( - self, order: Order, high: float, low: float, bar_open: float - ) -> float | None: - """Update trailing stop level and check if triggered. - - Updates the stop price based on the water mark, then checks - if the stop has been triggered. - - Note: This method mutates order.stop_price as trailing stops - must track the water mark. - - Args: - order: Trailing stop order (SELL protects longs, BUY protects shorts) - high: Bar high price - low: Bar low price - bar_open: Bar open price - - Returns: - Fill price if triggered, None otherwise - """ - if order.trail_amount is None: - return None - - if order.side == OrderSide.SELL: - # SELL trailing stop: protects long positions - # Stop trails below the high water mark - new_stop = high - order.trail_amount - if order.stop_price is None or new_stop > order.stop_price: - order.stop_price = new_stop - - # Check if triggered: low touched or crossed stop - if order.stop_price is None or low > order.stop_price: - return None - - else: # OrderSide.BUY - Bug #5 fix - # BUY trailing stop: protects short positions - # Stop trails above the low water mark - new_stop = low + order.trail_amount - if order.stop_price is None or new_stop < order.stop_price: - order.stop_price = new_stop - - # Check if triggered: high touched or crossed stop - if order.stop_price is None or high < order.stop_price: - return None - - # At this point stop_price is guaranteed non-None (set above, returned if None) - assert order.stop_price is not None - gap_price = self._check_gap_through(order.side, order.stop_price, bar_open) - return gap_price if gap_price is not None else order.stop_price - - def _check_fill(self, order: Order, price: float) -> float | None: - """Check if order should fill, return fill price or None. - - Delegates to specialized methods based on order type: - - Market orders: immediate fill, with gap-through handling for risk exits - - Limit orders: fill if bar range touched limit price - - Stop orders: fill at stop price (or worse) if bar triggered it - - Trailing stops: update stop level, then check for trigger - - Args: - order: Order to check - price: Current market price (close) - - Returns: - Fill price if order should fill, None otherwise - """ - high = self._current_highs.get(order.asset, price) - low = self._current_lows.get(order.asset, price) - bar_open = self._current_opens.get(order.asset, price) - - if order.order_type == OrderType.MARKET: - return self._check_market_fill(order, price) - elif order.order_type == OrderType.LIMIT: - return self._check_limit_fill(order, high, low) - elif order.order_type == OrderType.STOP: - return self._check_stop_fill(order, high, low, bar_open) - elif order.order_type == OrderType.TRAILING_STOP: - return self._update_and_check_trailing_stop(order, high, low, bar_open) - - return None - - def _execute_fill(self, order: Order, base_price: float) -> bool: - """Execute a fill and update positions. - - This method delegates to FillExecutor for the actual implementation. - See execution/fill_executor.py for the detailed logic. - - Args: - order: Order to fill - base_price: Base fill price before adjustments - - Returns: - True if order is fully filled, False if partially filled (remainder pending) - """ - return self._fill_executor.execute(order, base_price) diff --git a/src/ml4t/backtest/core/__init__.py b/src/ml4t/backtest/core/__init__.py index 906b5224..f8cf48e1 100644 --- a/src/ml4t/backtest/core/__init__.py +++ b/src/ml4t/backtest/core/__init__.py @@ -1,6 +1,7 @@ """Core orchestration components for alpha-reset architecture.""" from .execution_engine import ExecutionEngine +from .fill_engine import FillEngine from .order_book import OrderBook from .portfolio_ledger import PortfolioLedger from .risk_engine import RiskEngine @@ -8,6 +9,7 @@ __all__ = [ "ExecutionEngine", + "FillEngine", "OrderBook", "PortfolioLedger", "RiskEngine", diff --git a/src/ml4t/backtest/core/execution_engine.py b/src/ml4t/backtest/core/execution_engine.py index 1d17b96d..0d0432d9 100644 --- a/src/ml4t/backtest/core/execution_engine.py +++ b/src/ml4t/backtest/core/execution_engine.py @@ -19,6 +19,7 @@ def process_orders(self, use_open: bool = False): def _process_orders_exit_first(self, use_open: bool = False): broker = self.broker + fill = broker._fill_engine exit_orders = [] entry_orders = [] @@ -33,17 +34,17 @@ def _process_orders_exit_first(self, use_open: bool = False): filled_orders: list = [] for order in exit_orders: - price = broker._get_fill_price_for_order(order, use_open) + price = fill.get_fill_price_for_order(order, use_open) if price is None: continue - fill_price = broker._check_fill(order, price) + fill_price = fill.check_fill(order, price) if fill_price is not None: - fully_filled = broker._execute_fill(order, fill_price) + fully_filled = fill.execute_fill(order, fill_price) if fully_filled: filled_orders.append(order) broker._partial_orders.pop(order.order_id, None) else: - broker._update_partial_order(order) + fill.update_partial_order(order) broker.account.mark_to_market(broker._current_prices) @@ -71,50 +72,51 @@ def _process_orders_fifo(self, use_open: bool = False): def _process_single_order(self, order, use_open: bool, filled_orders: list) -> None: broker = self.broker - price = broker._get_fill_price_for_order(order, use_open) + fill = broker._fill_engine + price = fill.get_fill_price_for_order(order, use_open) if price is None: return is_exit = broker._is_exit_order(order) if is_exit: - fill_price = broker._check_fill(order, price) + fill_price = fill.check_fill(order, price) if fill_price is not None: - fully_filled = broker._execute_fill(order, fill_price) + fully_filled = fill.execute_fill(order, fill_price) if fully_filled: filled_orders.append(order) broker._partial_orders.pop(order.order_id, None) else: - broker._update_partial_order(order) + fill.update_partial_order(order) else: - broker._apply_share_rounding(order) + fill.apply_share_rounding(order) if order.quantity <= 0: order.status = OrderStatus.REJECTED order.rejection_reason = "Quantity rounds to zero (share_type=INTEGER)" return - fill_price = broker._check_fill(order, price) + fill_price = fill.check_fill(order, price) if fill_price is None: return valid, rejection_reason = broker.gatekeeper.validate_order(order, fill_price) if valid: - fully_filled = broker._execute_fill(order, fill_price) + fully_filled = fill.execute_fill(order, fill_price) if fully_filled: filled_orders.append(order) broker._partial_orders.pop(order.order_id, None) else: - broker._update_partial_order(order) + fill.update_partial_order(order) elif ( not broker.reject_on_insufficient_cash and "insufficient" in rejection_reason.lower() ): - if broker.partial_fills_allowed and broker._try_partial_fill(order, fill_price): + if broker.partial_fills_allowed and fill.try_partial_fill(order, fill_price): filled_orders.append(order) broker._partial_orders.pop(order.order_id, None) elif broker.partial_fills_allowed and "insufficient" in rejection_reason.lower(): - if broker._try_partial_fill(order, fill_price): + if fill.try_partial_fill(order, fill_price): filled_orders.append(order) broker._partial_orders.pop(order.order_id, None) else: diff --git a/src/ml4t/backtest/core/fill_engine.py b/src/ml4t/backtest/core/fill_engine.py new file mode 100644 index 00000000..1d2b1c66 --- /dev/null +++ b/src/ml4t/backtest/core/fill_engine.py @@ -0,0 +1,156 @@ +"""Fill checks and fill execution helpers extracted from Broker.""" + +from __future__ import annotations + +from ..config import ShareType +from ..types import ExecutionMode, OrderSide, OrderType + + +class FillEngine: + """Owns fill-price checks, quantity helpers, and fill execution delegation.""" + + def __init__(self, broker): + self.broker = broker + + def get_available_cash(self) -> float: + broker = self.broker + if broker.cash_buffer_pct > 0: + return broker.account.cash * (1.0 - broker.cash_buffer_pct) + return broker.account.cash + + def apply_share_rounding(self, order) -> None: + if self.broker.share_type == ShareType.INTEGER: + order.quantity = float(int(order.quantity)) + + def try_partial_fill(self, order, fill_price: float) -> bool: + broker = self.broker + available = self.get_available_cash() + commission_rate = 0.0 + test_commission = broker.commission_model.calculate(order.asset, 1.0, fill_price) + if fill_price > 0: + commission_rate = test_commission / fill_price + + max_value = available / (1.0 + commission_rate) if commission_rate > 0 else available + max_shares = max_value / fill_price if fill_price > 0 else 0 + + if broker.share_type == ShareType.INTEGER: + max_shares = float(int(max_shares)) + + if max_shares <= 0: + return False + + order.quantity = max_shares + return bool(self.execute_fill(order, fill_price)) + + def get_fill_price_for_order(self, order, use_open: bool) -> float | None: + broker = self.broker + if use_open and broker.execution_mode == ExecutionMode.NEXT_BAR: + return broker._current_opens.get(order.asset) + return broker._current_prices.get(order.asset) + + def get_effective_quantity(self, order) -> float: + remaining = self.broker._partial_orders.get(order.order_id) + if remaining is not None: + return remaining + return order.quantity + + def update_partial_order(self, order) -> None: + remaining = self.broker._partial_orders.get(order.order_id) + if remaining is not None: + order.quantity = remaining + + def check_gap_through( + self, side: OrderSide, stop_price: float, bar_open: float + ) -> float | None: + if side == OrderSide.SELL and bar_open <= stop_price: + return bar_open + if side == OrderSide.BUY and bar_open >= stop_price: + return bar_open + return None + + def check_market_fill(self, order, price: float) -> float: + broker = self.broker + risk_fill_price = getattr(order, "_risk_fill_price", None) + if risk_fill_price is None: + return price + + fill_price = risk_fill_price + if broker.stop_slippage_rate > 0: + if order.side == OrderSide.SELL: + fill_price = fill_price * (1 - broker.stop_slippage_rate) + else: + fill_price = fill_price * (1 + broker.stop_slippage_rate) + return fill_price + + def check_limit_fill(self, order, high: float, low: float) -> float | None: + if order.limit_price is None: + return None + if ( + order.side == OrderSide.BUY + and low <= order.limit_price + or order.side == OrderSide.SELL + and high >= order.limit_price + ): + return order.limit_price + return None + + def check_stop_fill(self, order, high: float, low: float, bar_open: float) -> float | None: + if order.stop_price is None: + return None + + triggered = False + if ( + order.side == OrderSide.BUY + and high >= order.stop_price + or order.side == OrderSide.SELL + and low <= order.stop_price + ): + triggered = True + + if not triggered: + return None + + gap_price = self.check_gap_through(order.side, order.stop_price, bar_open) + return gap_price if gap_price is not None else order.stop_price + + def update_and_check_trailing_stop( + self, order, high: float, low: float, bar_open: float + ) -> float | None: + if order.trail_amount is None: + return None + + if order.side == OrderSide.SELL: + new_stop = high - order.trail_amount + if order.stop_price is None or new_stop > order.stop_price: + order.stop_price = new_stop + if order.stop_price is None or low > order.stop_price: + return None + else: + new_stop = low + order.trail_amount + if order.stop_price is None or new_stop < order.stop_price: + order.stop_price = new_stop + if order.stop_price is None or high < order.stop_price: + return None + + assert order.stop_price is not None + gap_price = self.check_gap_through(order.side, order.stop_price, bar_open) + return gap_price if gap_price is not None else order.stop_price + + def check_fill(self, order, price: float) -> float | None: + broker = self.broker + high = broker._current_highs.get(order.asset, price) + low = broker._current_lows.get(order.asset, price) + bar_open = broker._current_opens.get(order.asset, price) + + if order.order_type == OrderType.MARKET: + return self.check_market_fill(order, price) + if order.order_type == OrderType.LIMIT: + return self.check_limit_fill(order, high, low) + if order.order_type == OrderType.STOP: + return self.check_stop_fill(order, high, low, bar_open) + if order.order_type == OrderType.TRAILING_STOP: + return self.update_and_check_trailing_stop(order, high, low, bar_open) + return None + + def execute_fill(self, order, base_price: float) -> bool: + return self.broker._fill_executor.execute(order, base_price) diff --git a/src/ml4t/backtest/core/risk_engine.py b/src/ml4t/backtest/core/risk_engine.py index f7e37e76..a9b04243 100644 --- a/src/ml4t/backtest/core/risk_engine.py +++ b/src/ml4t/backtest/core/risk_engine.py @@ -139,7 +139,9 @@ def process_pending_exits(self): stored_fill_price = pending.get("fill_price") if broker.stop_fill_mode.value == "stop_price" and stored_fill_price is not None: exit_side = OrderSide.SELL if pending["quantity"] > 0 else OrderSide.BUY - gap_price = broker._check_gap_through(exit_side, stored_fill_price, open_price) + gap_price = broker._fill_engine.check_gap_through( + exit_side, stored_fill_price, open_price + ) fill_price = gap_price if gap_price is not None else stored_fill_price else: fill_price = open_price diff --git a/src/ml4t/backtest/execution/fill_executor.py b/src/ml4t/backtest/execution/fill_executor.py index 7eaf592a..112524e0 100644 --- a/src/ml4t/backtest/execution/fill_executor.py +++ b/src/ml4t/backtest/execution/fill_executor.py @@ -100,7 +100,7 @@ def execute(self, order: Order, base_price: float) -> bool: volume = broker._current_volumes.get(order.asset) # Get effective quantity (considering partial fills from previous bars) - effective_quantity = broker._get_effective_quantity(order) + effective_quantity = broker._fill_engine.get_effective_quantity(order) fill_quantity = effective_quantity # Apply execution limits (volume participation) diff --git a/tests/test_broker.py b/tests/test_broker.py index 6012c571..cece1343 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -2771,7 +2771,7 @@ def test_stop_slippage_applied_to_long_exit(self): order._risk_fill_price = 95.0 # Stop triggered at $95 # Get the fill price - should have 1% slippage applied - fill_price = broker._check_market_fill(order, 100.0) + fill_price = broker._fill_engine.check_market_fill(order, 100.0) # Expected: 95.0 * (1 - 0.01) = 94.05 assert fill_price == pytest.approx(94.05, rel=1e-6) @@ -2801,7 +2801,7 @@ def test_stop_slippage_applied_to_short_exit(self): order._risk_fill_price = 105.0 # Stop triggered at $105 # Get the fill price - should have 1% slippage applied (price goes up) - fill_price = broker._check_market_fill(order, 100.0) + fill_price = broker._fill_engine.check_market_fill(order, 100.0) # Expected: 105.0 * (1 + 0.01) = 106.05 assert fill_price == pytest.approx(106.05, rel=1e-6) @@ -2831,7 +2831,7 @@ def test_no_stop_slippage_for_normal_orders(self): # Do NOT set _risk_fill_price # Get the fill price - should be the market price, no stop slippage - fill_price = broker._check_market_fill(order, 100.0) + fill_price = broker._fill_engine.check_market_fill(order, 100.0) # Expected: 100.0 (no slippage applied to non-risk orders) assert fill_price == 100.0 @@ -2861,7 +2861,7 @@ def test_zero_stop_slippage_no_effect(self): order._risk_fill_price = 95.0 # Get the fill price - fill_price = broker._check_market_fill(order, 100.0) + fill_price = broker._fill_engine.check_market_fill(order, 100.0) # Expected: 95.0 (exactly the risk fill price, no additional slippage) assert fill_price == 95.0 From e316d41156b5afd1da14b2ac8c2a3edec8519cbf Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 10:00:28 -0500 Subject: [PATCH 17/24] test/docs: add cross-engine contracts and align strategy-order guides --- docs/user-guide/orders.md | 58 ++++++---- docs/user-guide/strategies.md | 107 +++++++----------- .../contracts/test_cross_engine_contracts.py | 43 +++++++ 3 files changed, 121 insertions(+), 87 deletions(-) create mode 100644 tests/contracts/test_cross_engine_contracts.py diff --git a/docs/user-guide/orders.md b/docs/user-guide/orders.md index b0538c89..4c7d06e8 100644 --- a/docs/user-guide/orders.md +++ b/docs/user-guide/orders.md @@ -2,13 +2,19 @@ ML4T Backtest supports multiple order types for realistic simulation. +Orders are submitted from a strategy using `broker.submit_order(...)`. + ## Market Orders -Execute at the next bar's open price: +```python +from ml4t.backtest.types import OrderType +``` + +Execute at market according to the configured execution mode/profile: ```python -self.buy(size=100) # Market buy -self.sell(size=100) # Market sell +broker.submit_order("AAPL", 100, order_type=OrderType.MARKET) # buy +broker.submit_order("AAPL", -100, order_type=OrderType.MARKET) # sell ``` ## Limit Orders @@ -16,9 +22,12 @@ self.sell(size=100) # Market sell Execute only if price reaches the limit: ```python -from ml4t.backtest import OrderType - -self.buy(size=100, price=99.50, order_type=OrderType.LIMIT) +broker.submit_order( + "AAPL", + 100, + order_type=OrderType.LIMIT, + limit_price=99.50, +) ``` - **Buy limit**: Fills if price drops to or below limit @@ -29,22 +38,27 @@ self.buy(size=100, price=99.50, order_type=OrderType.LIMIT) Trigger a market order when stop price is reached: ```python -self.buy(size=100, stop=101.00, order_type=OrderType.STOP) +broker.submit_order( + "AAPL", + -100, + order_type=OrderType.STOP, + stop_price=95.00, +) ``` - **Buy stop**: Triggers when price rises to stop (breakout entry) - **Sell stop**: Triggers when price falls to stop (stop loss) -## Stop-Limit Orders +## Trailing Stop Orders -Trigger a limit order when stop is reached: +Trailing stops dynamically update the stop level as price moves favorably. ```python -self.buy( - size=100, - stop=101.00, - price=101.50, - order_type=OrderType.STOP_LIMIT +broker.submit_order( + "AAPL", + -100, + order_type=OrderType.TRAILING_STOP, + trail_amount=2.50, ) ``` @@ -60,12 +74,12 @@ This matches real broker behavior and prevents unrealistic fills. ## Order Management ```python -# Set stop loss after entry -self.set_stop(price=95.00) - -# Set profit target -self.set_target(price=110.00) - -# Cancel all open orders -self.cancel_all() +broker.get_order(order_id) +broker.get_pending_orders() +broker.update_order(order_id, limit_price=100.0) +broker.cancel_order(order_id) +broker.close_position("AAPL") + +# Bracket order helper: entry + take profit + stop loss +broker.submit_bracket("AAPL", quantity=100, take_profit=110, stop_loss=95) ``` diff --git a/docs/user-guide/strategies.md b/docs/user-guide/strategies.md index be2ef624..23cf5d50 100644 --- a/docs/user-guide/strategies.md +++ b/docs/user-guide/strategies.md @@ -4,98 +4,75 @@ Learn how to build effective trading strategies with ML4T Backtest. ## Strategy Base Class -All strategies inherit from `Strategy`: +All strategies inherit from `Strategy` and implement `on_data(...)`: ```python -from ml4t.backtest import Strategy +from ml4t.backtest.strategy import Strategy class MyStrategy(Strategy): - def on_bar(self, bar): - # Called for each price bar - pass - - def on_fill(self, fill): - # Called when an order is filled + def on_data(self, timestamp, data, context, broker): + # Called once per bar/timestamp. + # data: {asset: {"open","high","low","close","volume",...}} pass ``` -## Available Methods - -### Order Methods +Optional lifecycle hooks: ```python -self.buy(size, price=None) # Submit buy order -self.sell(size, price=None) # Submit sell order -self.close() # Close current position -self.set_stop(price) # Set stop loss -self.set_target(price) # Set profit target +def on_start(self, broker): ... +def on_end(self, broker): ... ``` -### Position Info +## Trading Through The Broker -```python -self.position # Current position size (int) -self.equity # Current equity (float) -self.cash # Available cash (float) -``` - -## Strategy Patterns +Strategies submit orders via the provided `broker` object. -### Momentum Strategy +### Core broker methods ```python -class MomentumStrategy(Strategy): - def __init__(self, fast=10, slow=30): - self.fast = fast - self.slow = slow - - def on_bar(self, bar): - fast_ma = bar.close_ma(self.fast) - slow_ma = bar.close_ma(self.slow) - - if fast_ma > slow_ma and self.position == 0: - self.buy(size=100) - elif fast_ma < slow_ma and self.position > 0: - self.close() +broker.submit_order("AAPL", 100) # buy market +broker.submit_order("AAPL", -100) # sell market +broker.submit_order("AAPL", 100, order_type=...) # limit/stop/trailing stop +broker.close_position("AAPL") +broker.order_target_percent("AAPL", 0.25) +broker.order_target_value("AAPL", 50_000) +broker.get_position("AAPL") +broker.get_account_value() +broker.get_cash() ``` -### Risk-Managed Position Sizing +## Strategy Patterns + +### Signal threshold (single asset) ```python -class RiskManagedStrategy(Strategy): - def __init__(self, risk_per_trade=0.02): - self.risk_per_trade = risk_per_trade - - def on_bar(self, bar): - if self.position == 0: - # Size based on 2% risk - stop_distance = bar.atr(14) * 2 - size = int(self.equity * self.risk_per_trade / stop_distance) - self.buy(size=size) - self.set_stop(bar.close - stop_distance) +class SignalStrategy(Strategy): + def on_data(self, timestamp, data, context, broker): + bar = data.get("AAPL") + if not bar: + return + signal = context.get("signal", 0.0) + pos = broker.get_position("AAPL") + if signal > 0.5 and pos is None: + broker.submit_order("AAPL", 100) + elif signal < -0.5 and pos is not None: + broker.close_position("AAPL") ``` -### Signal-Based Strategy - -Use ML model predictions as signals: +### Multi-asset rebalance ```python -class SignalStrategy(Strategy): - def __init__(self, signals): - self.signals = signals - - def on_bar(self, bar): - signal = self.signals.get(bar.datetime) +class RebalanceStrategy(Strategy): + def __init__(self, target_weights): + self.target_weights = target_weights - if signal > 0.5 and self.position == 0: - self.buy(size=100) - elif signal < -0.5 and self.position > 0: - self.close() + def on_data(self, timestamp, data, context, broker): + broker.rebalance_to_weights(self.target_weights) ``` ## Best Practices -1. **Avoid look-ahead bias**: Only use data available at `bar.datetime` +1. **Avoid look-ahead bias**: use only data from the current callback arguments 2. **Account for costs**: Test with realistic commission and slippage -3. **Validate with VectorBT**: Ensure results match expected behavior +3. **Validate engine profile behavior**: check `vectorbt`, `backtrader`, and `zipline` profiles 4. **Size positions appropriately**: Don't risk more than 1-2% per trade diff --git a/tests/contracts/test_cross_engine_contracts.py b/tests/contracts/test_cross_engine_contracts.py new file mode 100644 index 00000000..99b183b4 --- /dev/null +++ b/tests/contracts/test_cross_engine_contracts.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +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" + +FRAMEWORK_VENVS = { + "vectorbt_oss": ".venv", + "backtrader": ".venv-backtrader", + "zipline": ".venv-zipline", +} + + +@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: + venv_dir = PROJECT_ROOT / FRAMEWORK_VENVS[framework] + if not (venv_dir / "bin" / "python").exists(): + pytest.skip(f"{framework} environment not available: {venv_dir}") + + 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}" + + assert result.returncode == 0, combined + assert "PASS" in combined and "FAIL" not in combined, combined From 3de74248dff078b96a7102bce5df178c9885284d Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 10:05:33 -0500 Subject: [PATCH 18/24] refactor(ci): finalize E4 broker cleanup and contract gate --- .github/workflows/ci.yml | 24 +++++++- README.md | 12 ++++ src/ml4t/backtest/broker.py | 34 ------------ src/ml4t/backtest/core/execution_engine.py | 21 ++++++- .../contracts/test_cross_engine_contracts.py | 55 +++++++++++++------ tests/test_broker.py | 14 ++--- 6 files changed, 100 insertions(+), 60 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a83febfc..a8b77063 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,10 +79,32 @@ jobs: - name: Run tests run: uv run pytest tests/ -v --tb=short -x --no-cov + contracts: + name: Cross-Engine Contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install ${{ env.PYTHON_VERSION }} + + - name: Install dependencies (with comparison frameworks) + run: uv sync --dev --extra comparison + + - name: Run cross-engine contract test (scenario 01) + env: + ML4T_COMPARISON_INPROC: "1" + run: uv run pytest tests/contracts/test_cross_engine_contracts.py -v --tb=short --no-cov + build: name: Build Package runs-on: ubuntu-latest - needs: [lint, typecheck, test] + needs: [lint, typecheck, test, contracts] steps: - uses: actions/checkout@v4 with: diff --git a/README.md b/README.md index fa9270b3..f311120e 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,18 @@ The library is validated against VectorBT Pro, Backtrader, and Zipline: See [validation/README.md](validation/README.md) for test methodology. +Release-gate commands: + +```bash +# Fast parity contract gate (scenario 01 across vectorbt/backtrader/zipline) +ML4T_COMPARISON_INPROC=1 uv run pytest tests/contracts/test_cross_engine_contracts.py -q + +# Full correctness runner (selected scenarios) +python validation/run_all_correctness.py --framework vectorbt_oss --scenarios 01,03,05,09 +python validation/run_all_correctness.py --framework backtrader --scenarios 01,03,05,09 +python validation/run_all_correctness.py --framework zipline --scenarios 01,03,05,09 +``` + ## Technical Characteristics - **Event-driven**: Each bar processes sequentially with exit-first logic diff --git a/src/ml4t/backtest/broker.py b/src/ml4t/backtest/broker.py index 4f7da6ff..4883b65d 100644 --- a/src/ml4t/backtest/broker.py +++ b/src/ml4t/backtest/broker.py @@ -1241,40 +1241,6 @@ def get_pending_orders(self, asset: str | None = None) -> list[Order]: """Get pending orders, optionally filtered by asset.""" return self._order_book.get_pending_orders(asset=asset) - def _is_exit_order(self, order: Order) -> bool: - """Check if order is an exit (reducing existing position). - - Exit orders are: - - SELL when we have a long position (reducing long) - - BUY when we have a short position (covering short) - - Does NOT reverse the position - - Args: - order: Order to check - - Returns: - True if order is reducing an existing position, False otherwise - """ - pos = self.positions.get(order.asset) - if pos is None or pos.quantity == 0: - return False # No position, so this is entry, not exit - - # Calculate signed quantity delta - signed_qty = order.quantity if order.side == OrderSide.BUY else -order.quantity - - # Check if opposite sign (reducing) and doesn't reverse - if pos.quantity > 0 and signed_qty < 0: - # Long position, sell order - new_qty = pos.quantity + signed_qty - return new_qty >= 0 # Exit if still long or flat, not reversal - elif pos.quantity < 0 and signed_qty > 0: - # Short position, buy order - new_qty = pos.quantity + signed_qty - return new_qty <= 0 # Exit if still short or flat, not reversal - else: - # Same sign - adding to position, not exiting - return False - def _process_pending_exits(self) -> list[Order]: """Process pending exits from NEXT_BAR_OPEN mode. diff --git a/src/ml4t/backtest/core/execution_engine.py b/src/ml4t/backtest/core/execution_engine.py index 0d0432d9..faaaf0d2 100644 --- a/src/ml4t/backtest/core/execution_engine.py +++ b/src/ml4t/backtest/core/execution_engine.py @@ -17,6 +17,23 @@ def process_orders(self, use_open: bool = False): else: self._process_orders_fifo(use_open) + def _is_exit_order(self, order) -> bool: + """Check if an order reduces an existing position without reversing.""" + broker = self.broker + pos = broker.positions.get(order.asset) + if pos is None or pos.quantity == 0: + return False + + signed_qty = order.quantity if order.side.name == "BUY" else -order.quantity + + if pos.quantity > 0 and signed_qty < 0: + new_qty = pos.quantity + signed_qty + return new_qty >= 0 + if pos.quantity < 0 and signed_qty > 0: + new_qty = pos.quantity + signed_qty + return new_qty <= 0 + return False + def _process_orders_exit_first(self, use_open: bool = False): broker = self.broker fill = broker._fill_engine @@ -26,7 +43,7 @@ def _process_orders_exit_first(self, use_open: bool = False): for order in broker.pending_orders[:]: if broker.execution_mode.value == "next_bar" and order in broker._orders_this_bar: continue - if broker._is_exit_order(order): + if self._is_exit_order(order): exit_orders.append(order) else: entry_orders.append(order) @@ -77,7 +94,7 @@ def _process_single_order(self, order, use_open: bool, filled_orders: list) -> N if price is None: return - is_exit = broker._is_exit_order(order) + is_exit = self._is_exit_order(order) if is_exit: fill_price = fill.check_fill(order, price) diff --git a/tests/contracts/test_cross_engine_contracts.py b/tests/contracts/test_cross_engine_contracts.py index 99b183b4..7ad067dc 100644 --- a/tests/contracts/test_cross_engine_contracts.py +++ b/tests/contracts/test_cross_engine_contracts.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import subprocess import sys from pathlib import Path @@ -15,6 +16,12 @@ "zipline": ".venv-zipline", } +FRAMEWORK_PROFILES = { + "vectorbt_oss": "vectorbt", + "backtrader": "backtrader", + "zipline": "zipline", +} + @pytest.mark.requires_comparison @pytest.mark.integration @@ -22,22 +29,38 @@ @pytest.mark.parametrize("framework", ["vectorbt_oss", "backtrader", "zipline"]) def test_cross_engine_scenario_01_contract(framework: str, tmp_path: Path) -> None: venv_dir = PROJECT_ROOT / FRAMEWORK_VENVS[framework] - if not (venv_dir / "bin" / "python").exists(): - pytest.skip(f"{framework} environment not available: {venv_dir}") - - 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}" + 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 diff --git a/tests/test_broker.py b/tests/test_broker.py index cece1343..60c1d885 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -200,7 +200,7 @@ def test_close_short_position(self, broker): class TestIsExitOrder: - """Test _is_exit_order internal method.""" + """Test ExecutionEngine._is_exit_order internal method.""" def test_no_position_is_not_exit(self, broker): """Test order with no position is not exit.""" @@ -210,7 +210,7 @@ def test_no_position_is_not_exit(self, broker): side=OrderSide.BUY, order_type=OrderType.MARKET, ) - assert broker._is_exit_order(order) is False + assert broker._execution_engine._is_exit_order(order) is False def test_sell_with_long_is_exit(self, broker_with_position): """Test sell order with long position is exit.""" @@ -220,7 +220,7 @@ def test_sell_with_long_is_exit(self, broker_with_position): side=OrderSide.SELL, order_type=OrderType.MARKET, ) - assert broker_with_position._is_exit_order(order) is True + assert broker_with_position._execution_engine._is_exit_order(order) is True def test_sell_full_position_is_exit(self, broker_with_position): """Test sell order that flattens is exit.""" @@ -230,7 +230,7 @@ def test_sell_full_position_is_exit(self, broker_with_position): side=OrderSide.SELL, order_type=OrderType.MARKET, ) - assert broker_with_position._is_exit_order(order) is True + assert broker_with_position._execution_engine._is_exit_order(order) is True def test_sell_reversal_is_not_exit(self, broker_with_position): """Test sell that reverses position is not exit.""" @@ -240,7 +240,7 @@ def test_sell_reversal_is_not_exit(self, broker_with_position): side=OrderSide.SELL, order_type=OrderType.MARKET, ) - assert broker_with_position._is_exit_order(order) is False + assert broker_with_position._execution_engine._is_exit_order(order) is False def test_buy_with_long_is_not_exit(self, broker_with_position): """Test buy with long position is not exit (adding).""" @@ -250,7 +250,7 @@ def test_buy_with_long_is_not_exit(self, broker_with_position): side=OrderSide.BUY, order_type=OrderType.MARKET, ) - assert broker_with_position._is_exit_order(order) is False + assert broker_with_position._execution_engine._is_exit_order(order) is False def test_buy_with_short_is_exit(self, broker): """Test buy with short position is exit.""" @@ -269,7 +269,7 @@ def test_buy_with_short_is_exit(self, broker): side=OrderSide.BUY, order_type=OrderType.MARKET, ) - assert broker._is_exit_order(order) is True + assert broker._execution_engine._is_exit_order(order) is True class TestContractSpec: From 93b39bde8275b3b0ffa834631865b07e22d50b88 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 11:59:31 -0500 Subject: [PATCH 19/24] test(contracts): add book-parity behavior contracts --- tests/contracts/test_book_parity_behaviors.py | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/contracts/test_book_parity_behaviors.py diff --git a/tests/contracts/test_book_parity_behaviors.py b/tests/contracts/test_book_parity_behaviors.py new file mode 100644 index 00000000..69765cda --- /dev/null +++ b/tests/contracts/test_book_parity_behaviors.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from datetime import datetime, timedelta + +import polars as pl + +from ml4t.backtest.config import ( + BacktestConfig, + CommissionModel, + FillOrdering, + FillTiming, + RebalanceMode, + ShareType, + SlippageModel, +) +from ml4t.backtest.engine import run_backtest +from ml4t.backtest.execution import RebalanceConfig, TargetWeightExecutor +from ml4t.backtest.strategy import Strategy +from ml4t.backtest.types import ExecutionMode + + +def _bar(ts: datetime, asset: str, close: float, volume: float = 1_000_000.0) -> dict: + return { + "timestamp": ts, + "asset": asset, + "open": close, + "high": close, + "low": close, + "close": close, + "volume": volume, + } + + +def _qty_for_symbol(result, symbol: str) -> float: + for trade in result.trades: + if trade.symbol == symbol and trade.status == "open": + return abs(trade.quantity) + return 0.0 + + +class _RebalanceByMode(Strategy): + def __init__(self, mode: RebalanceMode) -> None: + self.mode = mode + self.bar = 0 + self.msft_order_qty = 0.0 + self.executor = TargetWeightExecutor( + RebalanceConfig( + rebalance_mode=mode, + min_trade_value=0.0, + min_weight_change=0.0, + allow_fractional=False, + ) + ) + + def on_data(self, timestamp, data, context, broker) -> None: + self.bar += 1 + if self.bar == 1: + broker.submit_order("AAPL", 1900.0) + elif self.bar == 2: + orders = self.executor.execute({"AAPL": 0.5, "MSFT": 0.5}, data, broker) + for order in orders: + if order.asset == "MSFT": + self.msft_order_qty = order.quantity + + +def test_snapshot_value_freezes_targets_vs_incremental_recompute() -> None: + start = datetime(2024, 1, 1) + prices = pl.DataFrame( + [ + _bar(start, "AAPL", 100.0), + _bar(start, "MSFT", 100.0), + _bar(start + timedelta(days=1), "AAPL", 100.0), + _bar(start + timedelta(days=1), "MSFT", 100.0), + ] + ) + cfg = BacktestConfig( + initial_cash=200_000.0, + fill_timing=FillTiming.SAME_BAR, + execution_mode=ExecutionMode.SAME_BAR, + share_type=ShareType.INTEGER, + fill_ordering=FillOrdering.FIFO, + commission_model=CommissionModel.PERCENTAGE, + commission_rate=0.01, + slippage_model=SlippageModel.NONE, + ) + + snapshot_strategy = _RebalanceByMode(RebalanceMode.SNAPSHOT) + incremental_strategy = _RebalanceByMode(RebalanceMode.INCREMENTAL) + run_backtest(prices=prices, strategy=snapshot_strategy, config=cfg) + run_backtest(prices=prices, strategy=incremental_strategy, config=cfg) + + assert snapshot_strategy.msft_order_qty > incremental_strategy.msft_order_qty + + +class _RotateSellThenBuy(Strategy): + def __init__(self) -> None: + self.bar = 0 + + def on_data(self, timestamp, data, context, broker) -> None: + self.bar += 1 + if self.bar == 1: + broker.submit_order("AAPL", 100.0) + elif self.bar == 2: + broker.rebalance_to_weights({"MSFT": 1.0}) + + +def test_sell_before_buy_rotation_works_under_tight_cash() -> None: + start = datetime(2024, 1, 1) + prices = pl.DataFrame( + [ + _bar(start, "AAPL", 100.0), + _bar(start, "MSFT", 100.0), + _bar(start + timedelta(days=1), "AAPL", 100.0), + _bar(start + timedelta(days=1), "MSFT", 100.0), + ] + ) + cfg = BacktestConfig( + initial_cash=10_000.0, + fill_timing=FillTiming.SAME_BAR, + execution_mode=ExecutionMode.SAME_BAR, + share_type=ShareType.INTEGER, + fill_ordering=FillOrdering.FIFO, + commission_model=CommissionModel.NONE, + slippage_model=SlippageModel.NONE, + ) + result = run_backtest(prices=prices, strategy=_RotateSellThenBuy(), config=cfg) + + assert _qty_for_symbol(result, "AAPL") == 0.0 + assert _qty_for_symbol(result, "MSFT") > 0.0 + + +class _MissingBarsRebalance(Strategy): + def __init__(self) -> None: + self.bar = 0 + self.orders_by_bar: dict[int, list[str]] = {} + + def on_data(self, timestamp, data, context, broker) -> None: + self.bar += 1 + if self.bar == 1: + broker.submit_order("MSFT", 10.0) + else: + orders = broker.rebalance_to_weights({"AAPL": 0.5, "MSFT": 0.5}) + self.orders_by_bar[self.bar] = [o.asset for o in orders] + + +def test_missing_bar_skips_orders_for_unpriced_asset() -> None: + start = datetime(2024, 1, 1) + prices = pl.DataFrame( + [ + _bar(start, "AAPL", 100.0), + _bar(start, "MSFT", 100.0), + _bar(start + timedelta(days=1), "AAPL", 101.0), # MSFT missing this bar + _bar(start + timedelta(days=2), "AAPL", 102.0), + _bar(start + timedelta(days=2), "MSFT", 102.0), + ] + ) + strategy = _MissingBarsRebalance() + cfg = BacktestConfig( + initial_cash=20_000.0, + fill_timing=FillTiming.SAME_BAR, + execution_mode=ExecutionMode.SAME_BAR, + share_type=ShareType.INTEGER, + fill_ordering=FillOrdering.FIFO, + commission_model=CommissionModel.NONE, + slippage_model=SlippageModel.NONE, + ) + run_backtest(prices=prices, strategy=strategy, config=cfg) + + # On bar 2 MSFT has no price, so no MSFT rebalance order should be submitted. + assert "MSFT" not in strategy.orders_by_bar.get(2, []) + + +class _LateAssetRebalance(Strategy): + def on_data(self, timestamp, data, context, broker) -> None: + broker.rebalance_to_weights({"AAPL": 0.5, "MSFT": 0.5}) + + +def test_late_asset_start_only_trades_after_first_price() -> None: + start = datetime(2024, 1, 1) + msft_start = start + timedelta(days=2) + prices = pl.DataFrame( + [ + _bar(start, "AAPL", 100.0), + _bar(start + timedelta(days=1), "AAPL", 101.0), + _bar(msft_start, "AAPL", 102.0), + _bar(msft_start, "MSFT", 50.0), # late-start asset + _bar(start + timedelta(days=3), "AAPL", 103.0), + _bar(start + timedelta(days=3), "MSFT", 51.0), + ] + ) + cfg = BacktestConfig( + initial_cash=10_000.0, + fill_timing=FillTiming.SAME_BAR, + execution_mode=ExecutionMode.SAME_BAR, + share_type=ShareType.INTEGER, + fill_ordering=FillOrdering.FIFO, + commission_model=CommissionModel.NONE, + slippage_model=SlippageModel.NONE, + ) + result = run_backtest(prices=prices, strategy=_LateAssetRebalance(), config=cfg) + + msft_fills = [f for f in result.fills if f.asset == "MSFT"] + assert msft_fills + assert min(f.timestamp for f in msft_fills) >= msft_start From 09b545f1417cee5d584a83b7364f9a70c811ea9c Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 12:23:02 -0500 Subject: [PATCH 20/24] harden parity config wiring and rebalance behavior --- src/ml4t/backtest/broker.py | 69 ++++++++++++++++--- src/ml4t/backtest/config.py | 41 +++++++++++ src/ml4t/backtest/engine.py | 13 +--- src/ml4t/backtest/profiles.py | 20 ++++++ tests/contracts/test_profile_parity_basics.py | 17 ++++- tests/test_config_wiring.py | 56 ++++++++++++++- tests/test_core.py | 26 +++++-- uv.lock | 2 - 8 files changed, 213 insertions(+), 31 deletions(-) diff --git a/src/ml4t/backtest/broker.py b/src/ml4t/backtest/broker.py index 4883b65d..69ca3a25 100644 --- a/src/ml4t/backtest/broker.py +++ b/src/ml4t/backtest/broker.py @@ -9,6 +9,8 @@ from .config import ( FillOrdering, InitialHwmSource, + LateAssetPolicy, + MissingPricePolicy, ShareType, StatsConfig, TrailStopTiming, @@ -73,6 +75,10 @@ def __init__( reject_on_insufficient_cash: bool = True, cash_buffer_pct: float = 0.0, partial_fills_allowed: bool = False, + rebalance_headroom_pct: float = 1.0, + missing_price_policy: MissingPricePolicy = MissingPricePolicy.SKIP, + late_asset_policy: LateAssetPolicy = LateAssetPolicy.ALLOW, + late_asset_min_bars: int = 1, ): # Runtime imports for accounting classes. # These are imported here rather than at module level because: @@ -101,6 +107,10 @@ def __init__( self.reject_on_insufficient_cash = reject_on_insufficient_cash self.cash_buffer_pct = cash_buffer_pct self.partial_fills_allowed = partial_fills_allowed + self.rebalance_headroom_pct = rebalance_headroom_pct + self.missing_price_policy = missing_price_policy + self.late_asset_policy = late_asset_policy + self.late_asset_min_bars = late_asset_min_bars # Create AccountState with UnifiedAccountPolicy policy: AccountPolicy = UnifiedAccountPolicy( @@ -145,6 +155,8 @@ def __init__( self._current_lows: dict[str, float] = {} # low prices for limit/stop checks self._current_volumes: dict[str, float] = {} self._current_signals: dict[str, dict[str, float]] = {} + self._last_prices: dict[str, float] = {} + self._asset_bars_seen: dict[str, int] = {} self._orders_this_bar: list[Order] = [] # Orders placed this bar (for next-bar mode) # Risk management @@ -214,12 +226,15 @@ def from_config( from .config import CommissionModel as CommModelEnum from .config import SlippageModel as SlipModelEnum from .models import ( + CombinedCommission, FixedSlippage, NoCommission, NoSlippage, PercentageCommission, PercentageSlippage, PerShareCommission, + TieredCommission, + VolumeShareSlippage, ) # Build commission model from config @@ -231,6 +246,12 @@ def from_config( per_share=config.commission_per_share, minimum=config.commission_minimum, ) + elif config.commission_model == CommModelEnum.PER_TRADE: + commission_model = CombinedCommission(fixed=config.commission_per_trade) + elif config.commission_model == CommModelEnum.TIERED: + commission_model = TieredCommission( + tiers=[(float("inf"), config.commission_rate)], + ) elif config.commission_model == CommModelEnum.NONE: commission_model = NoCommission() @@ -240,6 +261,8 @@ def from_config( slippage_model = PercentageSlippage(rate=config.slippage_rate) elif config.slippage_model == SlipModelEnum.FIXED: slippage_model = FixedSlippage(amount=config.slippage_fixed) + elif config.slippage_model == SlipModelEnum.VOLUME_BASED: + slippage_model = VolumeShareSlippage(impact_factor=config.slippage_rate) elif config.slippage_model == SlipModelEnum.NONE: slippage_model = NoSlippage() @@ -268,6 +291,10 @@ def from_config( reject_on_insufficient_cash=config.reject_on_insufficient_cash, cash_buffer_pct=config.cash_buffer_pct, partial_fills_allowed=config.partial_fills_allowed, + rebalance_headroom_pct=config.rebalance_headroom_pct, + missing_price_policy=config.missing_price_policy, + late_asset_policy=config.late_asset_policy, + late_asset_min_bars=config.late_asset_min_bars, ) # Phase 4.1: Make cash a property delegating to account to prevent state drift @@ -1188,10 +1215,31 @@ def rebalance_to_weights( sells: list[tuple[str, float]] = [] # (asset, target_value) buys: list[tuple[str, float]] = [] # (asset, target_value) - # Calculate target values and categorize as buys or sells - for asset, weight in target_weights.items(): + scaled_weights = { + asset: weight * self.rebalance_headroom_pct for asset, weight in target_weights.items() + } + + def resolve_price(asset: str) -> float | None: price = self._current_prices.get(asset) - if price is None or price <= 0: + if price is not None and price > 0: + return price + if self.missing_price_policy == MissingPricePolicy.USE_LAST: + last = self._last_prices.get(asset) + if last is not None and last > 0: + return last + return None + + def allows_trading(asset: str) -> bool: + if self.late_asset_policy != LateAssetPolicy.REQUIRE_HISTORY: + return True + return self._asset_bars_seen.get(asset, 0) >= self.late_asset_min_bars + + # Calculate target values and categorize as buys or sells + for asset, weight in scaled_weights.items(): + if not allows_trading(asset): + continue + price = resolve_price(asset) + if price is None: continue target_value = portfolio_value * weight @@ -1212,21 +1260,21 @@ def rebalance_to_weights( # Also close positions not in target weights for asset, pos in self.positions.items(): - if pos.quantity != 0 and asset not in target_weights: + if pos.quantity != 0 and asset not in scaled_weights: sells.append((asset, 0.0)) # Process sells first (frees capital for buys) for asset, target_value in sells: - price = self._current_prices.get(asset) - if price and price > 0: + price = resolve_price(asset) + if price is not None: order = self._order_to_target_value(asset, target_value, price, order_type, None) if order: orders.append(order) # Then process buys for asset, target_value in buys: - price = self._current_prices.get(asset) - if price and price > 0: + price = resolve_price(asset) + if price is not None: order = self._order_to_target_value(asset, target_value, price, order_type, None) if order: orders.append(order) @@ -1272,6 +1320,11 @@ def _update_time( self._current_volumes = volumes self._current_signals = signals + for asset, price in prices.items(): + if price > 0: + self._last_prices[asset] = price + self._asset_bars_seen[asset] = self._asset_bars_seen.get(asset, 0) + 1 + # Clear per-bar tracking at start of new bar self._filled_this_bar.clear() self._stop_exits_this_bar.clear() # VBT Pro: allow re-entry on next bar diff --git a/src/ml4t/backtest/config.py b/src/ml4t/backtest/config.py index ce6c6f66..37e359a1 100644 --- a/src/ml4t/backtest/config.py +++ b/src/ml4t/backtest/config.py @@ -107,6 +107,20 @@ class RebalanceMode(str, Enum): HYBRID = "hybrid" +class MissingPricePolicy(str, Enum): + """How target-weight rebalancing handles missing current-bar prices.""" + + SKIP = "skip" + USE_LAST = "use_last" + + +class LateAssetPolicy(str, Enum): + """How target-weight rebalancing handles assets that start late.""" + + ALLOW = "allow" + REQUIRE_HISTORY = "require_history" + + class SignalProcessing(str, Enum): """How signals are processed relative to existing positions.""" @@ -371,6 +385,13 @@ def validate(self, warn: bool = True) -> list[str]: f"initial_margin ({self.initial_margin})" ) + if not 0.0 < self.rebalance_headroom_pct <= 1.0: + issues.append( + f"rebalance_headroom_pct ({self.rebalance_headroom_pct}) must be in (0.0, 1.0]" + ) + if self.late_asset_min_bars < 1: + issues.append(f"late_asset_min_bars ({self.late_asset_min_bars}) must be >= 1") + # Emit warnings if requested if warn and issues: for msg in issues: @@ -429,6 +450,10 @@ def get_effective_account_type(self) -> str: partial_fills_allowed: bool = False fill_ordering: FillOrdering = FillOrdering.EXIT_FIRST rebalance_mode: RebalanceMode = RebalanceMode.SNAPSHOT + rebalance_headroom_pct: float = 1.0 + missing_price_policy: MissingPricePolicy = MissingPricePolicy.SKIP + late_asset_policy: LateAssetPolicy = LateAssetPolicy.ALLOW + late_asset_min_bars: int = 1 # === Calendar & Timezone === calendar: str | None = None # Exchange calendar (e.g., "NYSE", "CME_Equity", "LSE") @@ -492,6 +517,10 @@ def to_dict(self) -> dict: "partial_fills_allowed": self.partial_fills_allowed, "fill_ordering": self.fill_ordering.value, "rebalance_mode": self.rebalance_mode.value, + "rebalance_headroom_pct": self.rebalance_headroom_pct, + "missing_price_policy": self.missing_price_policy.value, + "late_asset_policy": self.late_asset_policy.value, + "late_asset_min_bars": self.late_asset_min_bars, }, } @@ -552,6 +581,10 @@ def from_dict( "partial_fills_allowed", "fill_ordering", "rebalance_mode", + "rebalance_headroom_pct", + "missing_price_policy", + "late_asset_policy", + "late_asset_min_bars", }, } for section, cfg in data.items(): @@ -621,6 +654,10 @@ def from_dict( partial_fills_allowed=order_cfg.get("partial_fills_allowed", False), fill_ordering=FillOrdering(order_cfg.get("fill_ordering", "exit_first")), rebalance_mode=RebalanceMode(order_cfg.get("rebalance_mode", "snapshot")), + rebalance_headroom_pct=order_cfg.get("rebalance_headroom_pct", 1.0), + missing_price_policy=MissingPricePolicy(order_cfg.get("missing_price_policy", "skip")), + late_asset_policy=LateAssetPolicy(order_cfg.get("late_asset_policy", "allow")), + late_asset_min_bars=order_cfg.get("late_asset_min_bars", 1), # Metadata preset_name=preset_name, ) @@ -717,6 +754,10 @@ def describe(self) -> str: "Orders:", f" Fill ordering: {self.fill_ordering.value}", f" Rebalance mode: {self.rebalance_mode.value}", + f" Rebalance headroom: {self.rebalance_headroom_pct:.3f}", + f" Missing price policy: {self.missing_price_policy.value}", + f" Late asset policy: {self.late_asset_policy.value}", + f" Late asset min bars: {self.late_asset_min_bars}", f" Reject insufficient: {self.reject_on_insufficient_cash}", f" Partial fills: {self.partial_fills_allowed}", "", diff --git a/src/ml4t/backtest/engine.py b/src/ml4t/backtest/engine.py index 44038af9..b9bc83f8 100644 --- a/src/ml4t/backtest/engine.py +++ b/src/ml4t/backtest/engine.py @@ -346,25 +346,14 @@ def from_config( Returns: Configured Engine instance """ - from .config import FillTiming - # Create broker from config (handles all commission/slippage/account setup) broker = Broker.from_config(config) - # Map fill_timing to Engine's execution_mode - # SAME_BAR fill_timing → SAME_BAR execution - # NEXT_BAR_OPEN or NEXT_BAR_CLOSE → NEXT_BAR execution - execution_mode = ( - ExecutionMode.SAME_BAR - if config.fill_timing == FillTiming.SAME_BAR - else ExecutionMode.NEXT_BAR - ) - # Create engine with pre-configured broker engine = cls.__new__(cls) engine.feed = feed engine.strategy = strategy - engine.execution_mode = execution_mode + engine.execution_mode = config.execution_mode engine.stop_fill_mode = broker.stop_fill_mode engine.stop_level_basis = broker.stop_level_basis engine.config = config diff --git a/src/ml4t/backtest/profiles.py b/src/ml4t/backtest/profiles.py index 34a891c9..b5f33409 100644 --- a/src/ml4t/backtest/profiles.py +++ b/src/ml4t/backtest/profiles.py @@ -45,6 +45,10 @@ "partial_fills_allowed": False, "fill_ordering": "exit_first", "rebalance_mode": "incremental", + "rebalance_headroom_pct": 1.0, + "missing_price_policy": "skip", + "late_asset_policy": "allow", + "late_asset_min_bars": 1, }, } @@ -92,6 +96,10 @@ "partial_fills_allowed": False, "fill_ordering": "fifo", "rebalance_mode": "snapshot", + "rebalance_headroom_pct": 0.998, + "missing_price_policy": "use_last", + "late_asset_policy": "require_history", + "late_asset_min_bars": 2, }, } @@ -137,6 +145,10 @@ "partial_fills_allowed": True, "fill_ordering": "exit_first", "rebalance_mode": "hybrid", + "rebalance_headroom_pct": 1.0, + "missing_price_policy": "use_last", + "late_asset_policy": "allow", + "late_asset_min_bars": 1, }, } @@ -183,6 +195,10 @@ "partial_fills_allowed": True, "fill_ordering": "exit_first", "rebalance_mode": "snapshot", + "rebalance_headroom_pct": 0.998, + "missing_price_policy": "use_last", + "late_asset_policy": "allow", + "late_asset_min_bars": 1, }, } @@ -228,6 +244,10 @@ "partial_fills_allowed": False, "fill_ordering": "exit_first", "rebalance_mode": "incremental", + "rebalance_headroom_pct": 1.0, + "missing_price_policy": "skip", + "late_asset_policy": "allow", + "late_asset_min_bars": 1, }, } diff --git a/tests/contracts/test_profile_parity_basics.py b/tests/contracts/test_profile_parity_basics.py index 71d235c7..9fe4cfc6 100644 --- a/tests/contracts/test_profile_parity_basics.py +++ b/tests/contracts/test_profile_parity_basics.py @@ -63,9 +63,24 @@ def test_profiles_enforce_expected_entry_timing_contract() -> None: assert vbt.trades[0].entry_price == 101.0 # same-bar close assert 110.0 < bt.trades[0].entry_price < 111.0 # next-bar open with default slippage - assert zl.trades[0].entry_price == 110.0 # next-bar open + assert 110.0 < zl.trades[0].entry_price < 111.0 # next-bar open with volume slippage def test_backtrader_profile_uses_signal_price_stop_basis() -> None: cfg = BacktestConfig.from_preset("backtrader") assert cfg.stop_level_basis == StopLevelBasis.SIGNAL_PRICE + + +def test_backtrader_profile_parity_order_knobs() -> None: + cfg = BacktestConfig.from_preset("backtrader") + assert cfg.rebalance_headroom_pct == 0.998 + assert cfg.missing_price_policy.value == "use_last" + assert cfg.late_asset_policy.value == "require_history" + assert cfg.late_asset_min_bars == 2 + + +def test_zipline_profile_parity_order_knobs() -> None: + cfg = BacktestConfig.from_preset("zipline") + assert cfg.rebalance_headroom_pct == 0.998 + assert cfg.missing_price_policy.value == "use_last" + assert cfg.late_asset_policy.value == "allow" diff --git a/tests/test_config_wiring.py b/tests/test_config_wiring.py index a01e39d7..2bb6deb6 100644 --- a/tests/test_config_wiring.py +++ b/tests/test_config_wiring.py @@ -18,8 +18,15 @@ Broker, ExecutionMode, ) -from ml4t.backtest.config import FillOrdering, ShareType -from ml4t.backtest.models import NoCommission, NoSlippage +from ml4t.backtest.config import CommissionModel, FillOrdering, ShareType, SlippageModel +from ml4t.backtest.models import ( + CombinedCommission, + NoCommission, + NoSlippage, + PerShareCommission, + TieredCommission, + VolumeShareSlippage, +) from ml4t.backtest.types import OrderSide # --------------------------------------------------------------------------- @@ -371,3 +378,48 @@ def test_allow_negative_cash_removed(self): """allow_negative_cash was removed from BacktestConfig fields.""" config = BacktestConfig() assert not hasattr(config, "allow_negative_cash") + + +class TestConfigModelWiring: + """All commission/slippage enum choices should map to model instances.""" + + def test_per_trade_commission_maps_to_combined_commission(self): + broker = Broker.from_config( + BacktestConfig( + commission_model=CommissionModel.PER_TRADE, + commission_per_trade=2.5, + ) + ) + assert isinstance(broker.commission_model, CombinedCommission) + assert broker.commission_model.fixed == 2.5 + + def test_tiered_commission_maps_to_tiered_commission(self): + broker = Broker.from_config( + BacktestConfig( + commission_model=CommissionModel.TIERED, + commission_rate=0.0012, + ) + ) + assert isinstance(broker.commission_model, TieredCommission) + assert broker.commission_model.tiers == [(float("inf"), 0.0012)] + + def test_volume_based_slippage_maps_to_volume_share_slippage(self): + broker = Broker.from_config( + BacktestConfig( + slippage_model=SlippageModel.VOLUME_BASED, + slippage_rate=0.25, + ) + ) + assert isinstance(broker.slippage_model, VolumeShareSlippage) + assert broker.slippage_model.impact_factor == 0.25 + + def test_per_share_commission_still_maps_correctly(self): + broker = Broker.from_config( + BacktestConfig( + commission_model=CommissionModel.PER_SHARE, + commission_per_share=0.01, + commission_minimum=1.0, + ) + ) + assert isinstance(broker.commission_model, PerShareCommission) + assert broker.commission_model.per_share == 0.01 diff --git a/tests/test_core.py b/tests/test_core.py index c97a432a..c020a0ac 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -607,28 +607,42 @@ def test_from_config_fixed_slippage(self): assert results.metrics["total_slippage"] > 0 - def test_from_config_fill_timing_same_bar(self): - """Test from_config with SAME_BAR fill timing.""" + def test_from_config_execution_mode_same_bar(self): + """Test from_config with SAME_BAR execution mode.""" prices = generate_prices(["AAPL"], datetime(2024, 1, 1), 10, {"AAPL": 100}) feed = DataFeed(prices_df=prices) strategy = BuyAndHoldStrategy("AAPL") - config = BacktestConfig(fill_timing=FillTiming.SAME_BAR) + config = BacktestConfig(execution_mode=ExecutionMode.SAME_BAR) engine = Engine.from_config(feed, strategy, config) assert engine.execution_mode == ExecutionMode.SAME_BAR - def test_from_config_fill_timing_next_bar(self): - """Test from_config with NEXT_BAR_OPEN fill timing.""" + def test_from_config_execution_mode_next_bar(self): + """Test from_config with NEXT_BAR execution mode.""" prices = generate_prices(["AAPL"], datetime(2024, 1, 1), 10, {"AAPL": 100}) feed = DataFeed(prices_df=prices) strategy = BuyAndHoldStrategy("AAPL") - config = BacktestConfig(fill_timing=FillTiming.NEXT_BAR_OPEN) + config = BacktestConfig(execution_mode=ExecutionMode.NEXT_BAR) engine = Engine.from_config(feed, strategy, config) assert engine.execution_mode == ExecutionMode.NEXT_BAR + def test_from_config_execution_mode_takes_precedence_over_fill_timing(self): + """execution_mode is authoritative even if fill_timing is inconsistent.""" + prices = generate_prices(["AAPL"], datetime(2024, 1, 1), 10, {"AAPL": 100}) + feed = DataFeed(prices_df=prices) + strategy = BuyAndHoldStrategy("AAPL") + + config = BacktestConfig( + execution_mode=ExecutionMode.SAME_BAR, + fill_timing=FillTiming.NEXT_BAR_OPEN, + ) + engine = Engine.from_config(feed, strategy, config) + + assert engine.execution_mode == ExecutionMode.SAME_BAR + def test_from_config_margin_account(self): """Test from_config with margin account.""" prices = generate_prices(["AAPL"], datetime(2024, 1, 1), 10, {"AAPL": 100}) diff --git a/uv.lock b/uv.lock index 5d2cd97f..0b473b0e 100644 --- a/uv.lock +++ b/uv.lock @@ -2018,7 +2018,6 @@ dependencies = [ { name = "numpy" }, { name = "pandas" }, { name = "pandas-market-calendars" }, - { name = "plotly" }, { name = "polars" }, { name = "pyarrow" }, { name = "pydantic" }, @@ -2058,7 +2057,6 @@ requires-dist = [ { name = "numpy", specifier = ">=1.24.0" }, { name = "pandas", specifier = ">=2.0.0" }, { name = "pandas-market-calendars", specifier = ">=4.0.0" }, - { name = "plotly", specifier = ">=5.15.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" }, From dbf80b5ae50d00bc93631f5df02055e30c379ad3 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 12:46:45 -0500 Subject: [PATCH 21/24] test: harden coverage measurement and result edge cases --- tests/accounting/test_account_state.py | 2 +- tests/accounting/test_cash_account_policy.py | 2 +- .../accounting/test_crypto_account_policy.py | 2 +- .../accounting/test_margin_account_policy.py | 2 +- .../test_validation_import_bridge.py | 8 + tests/test_result.py | 147 +++++++++++++++++- 6 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 tests/contracts/test_validation_import_bridge.py diff --git a/tests/accounting/test_account_state.py b/tests/accounting/test_account_state.py index 39e514d1..39dad615 100644 --- a/tests/accounting/test_account_state.py +++ b/tests/accounting/test_account_state.py @@ -4,7 +4,7 @@ import pytest -from src.ml4t.backtest.accounting import ( +from ml4t.backtest.accounting import ( AccountState, UnifiedAccountPolicy, ) diff --git a/tests/accounting/test_cash_account_policy.py b/tests/accounting/test_cash_account_policy.py index fa4d5b46..be9c4918 100644 --- a/tests/accounting/test_cash_account_policy.py +++ b/tests/accounting/test_cash_account_policy.py @@ -3,7 +3,7 @@ from datetime import datetime from ml4t.backtest import Position -from src.ml4t.backtest.accounting.policy import UnifiedAccountPolicy +from ml4t.backtest.accounting.policy import UnifiedAccountPolicy class TestCashAccountPolicyBuyingPower: diff --git a/tests/accounting/test_crypto_account_policy.py b/tests/accounting/test_crypto_account_policy.py index 98031354..e19fbe6b 100644 --- a/tests/accounting/test_crypto_account_policy.py +++ b/tests/accounting/test_crypto_account_policy.py @@ -3,7 +3,7 @@ from datetime import datetime from ml4t.backtest import Position -from src.ml4t.backtest.accounting.policy import UnifiedAccountPolicy +from ml4t.backtest.accounting.policy import UnifiedAccountPolicy class TestCryptoAccountPolicyBuyingPower: diff --git a/tests/accounting/test_margin_account_policy.py b/tests/accounting/test_margin_account_policy.py index 89d26a5d..091ae0a6 100644 --- a/tests/accounting/test_margin_account_policy.py +++ b/tests/accounting/test_margin_account_policy.py @@ -5,7 +5,7 @@ import pytest from ml4t.backtest import Position -from src.ml4t.backtest.accounting.policy import UnifiedAccountPolicy +from ml4t.backtest.accounting.policy import UnifiedAccountPolicy class TestMarginAccountPolicyInitialization: diff --git a/tests/contracts/test_validation_import_bridge.py b/tests/contracts/test_validation_import_bridge.py new file mode 100644 index 00000000..d47b59ad --- /dev/null +++ b/tests/contracts/test_validation_import_bridge.py @@ -0,0 +1,8 @@ +from ml4t.backtest import _validation_imports as bridge + + +def test_validation_bridge_exports_stable_aliases() -> None: + assert "TrailHwmSource" in bridge.__all__ + assert bridge.TrailHwmSource is bridge.WaterMarkSource + assert "TargetWeightExecutor" in bridge.__all__ + assert "VolumeParticipationLimit" in bridge.__all__ diff --git a/tests/test_result.py b/tests/test_result.py index 2ede9824..e02a7992 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -6,11 +6,16 @@ import tempfile from datetime import datetime, timedelta from pathlib import Path +from types import SimpleNamespace import polars as pl import pytest -from ml4t.backtest.result import BacktestResult, enrich_trades_with_signals +from ml4t.backtest.result import ( + BacktestResult, + _get_annualization_factor, + enrich_trades_with_signals, +) from ml4t.backtest.types import Fill, OrderSide, Trade @@ -345,6 +350,27 @@ def test_repr(self, backtest_result: BacktestResult): assert "BacktestResult" in s assert "trades=2" in s + def test_dict_like_accessors(self, backtest_result: BacktestResult): + """Test __getitem__, get, keys, and items helpers.""" + assert backtest_result["sharpe"] == 1.5 + assert backtest_result.get("missing", 42) == 42 + assert "sharpe" in dict(backtest_result.items()) + assert ("sharpe", 1.5) in list(backtest_result.items()) + + def test_to_dict_includes_optional_analytics(self): + """Test to_dict includes equity and trade_analyzer when set.""" + result = BacktestResult( + trades=[], + equity_curve=[], + fills=[], + metrics={}, + equity=SimpleNamespace(name="eq"), + trade_analyzer=SimpleNamespace(name="ta"), + ) + d = result.to_dict() + assert "equity" in d + assert "trade_analyzer" in d + class TestBacktestResultParquet: """Tests for Parquet serialization.""" @@ -538,6 +564,125 @@ def test_enrich_multi_asset(self): assert aapl_row["entry_momentum"][0] == 0.5 assert msft_row["entry_momentum"][0] == 0.3 + def test_enrich_multi_asset_requires_trade_asset_column(self): + """Test multi-asset enrichment fails if trades have no asset/symbol column.""" + trades_df = pl.DataFrame( + { + "entry_time": [datetime(2024, 1, 1, 10, 0)], + "exit_time": [datetime(2024, 1, 1, 14, 0)], + } + ) + signals_df = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, 1, 10, 0)], + "asset": ["AAPL"], + "momentum": [0.5], + } + ) + with pytest.raises(ValueError, match="requires trades_df to include"): + enrich_trades_with_signals( + trades_df, + signals_df, + signal_columns=["momentum"], + asset_col="asset", + ) + + def test_enrich_multi_asset_rejects_unknown_trade_asset_column(self): + """Test multi-asset enrichment validates explicit trades_asset_col.""" + trades_df = pl.DataFrame( + { + "symbol": ["AAPL"], + "entry_time": [datetime(2024, 1, 1, 10, 0)], + "exit_time": [datetime(2024, 1, 1, 14, 0)], + } + ) + signals_df = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, 1, 10, 0)], + "asset": ["AAPL"], + "momentum": [0.5], + } + ) + with pytest.raises(ValueError, match="not found in trades_df"): + enrich_trades_with_signals( + trades_df, + signals_df, + signal_columns=["momentum"], + asset_col="asset", + trades_asset_col="ticker", + ) + + +class TestBacktestResultMetrics: + """Tests for annualization and compute_metrics branches.""" + + def test_get_annualization_factor_known_and_fallback(self): + assert _get_annualization_factor("nyse") == 252 + assert _get_annualization_factor("crypto") == 365 + assert _get_annualization_factor(None) == 252 + assert _get_annualization_factor("not_a_real_calendar") == 252 + + def test_compute_metrics_import_error(self, backtest_result: BacktestResult, monkeypatch): + def _raise(_name: str): + raise ImportError("nope") + + monkeypatch.setattr("importlib.import_module", _raise) + with pytest.raises(ImportError, match="ml4t-diagnostic is required"): + backtest_result.compute_metrics() + + def test_compute_metrics_with_empty_inputs(self, monkeypatch): + def _sharpe(_arr, annualization_factor): + return 1.23 + (annualization_factor * 0.0) + + def _sortino(_arr, annualization_factor): + return 2.34 + (annualization_factor * 0.0) + + diag = SimpleNamespace( + sharpe_ratio=_sharpe, + sortino_ratio=_sortino, + ) + monkeypatch.setattr("importlib.import_module", lambda _name: diag) + + result = BacktestResult(trades=[], equity_curve=[], fills=[], metrics={}) + metrics = result.compute_metrics(calendar="NYSE") + + assert metrics["sharpe_ratio"] == 0.0 + assert metrics["sortino_ratio"] == 0.0 + assert metrics["max_drawdown"] == 0.0 + assert metrics["total_return"] == 0.0 + assert metrics["cagr"] == 0.0 + assert metrics["calmar_ratio"] == 0.0 + assert metrics["num_trades"] == 0 + + def test_compute_metrics_with_trade_analyzer( + self, backtest_result: BacktestResult, monkeypatch + ): + def _sharpe(_arr, annualization_factor): + return 1.11 + (annualization_factor * 0.0) + + def _sortino(_arr, annualization_factor): + return 1.22 + (annualization_factor * 0.0) + + diag = SimpleNamespace( + sharpe_ratio=_sharpe, + sortino_ratio=_sortino, + ) + monkeypatch.setattr("importlib.import_module", lambda _name: diag) + backtest_result.trade_analyzer = SimpleNamespace( + num_trades=7, + win_rate=0.57, + profit_factor=1.8, + expectancy=12.0, + avg_trade=9.0, + avg_win=21.0, + avg_loss=-8.0, + total_fees=34.0, + ) + + metrics = backtest_result.compute_metrics(calendar="NYSE") + assert metrics["num_trades"] == 7 + assert metrics["total_fees"] == 34.0 + class TestBacktestResultSchemas: """Tests for schema definitions.""" From 0d61a388a6390912fe5076b9b68f819abda8fb81 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 12:56:32 -0500 Subject: [PATCH 22/24] test: cover result config/yaml and tearsheet error branches --- tests/test_result.py | 58 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/test_result.py b/tests/test_result.py index e02a7992..64e42f09 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -400,6 +400,25 @@ def test_to_parquet_selective(self, backtest_result: BacktestResult): assert "metrics" in written assert "equity" not in written + def test_to_parquet_config_write_failure_is_non_fatal(self): + """Test config export failure is swallowed (ImportError/AttributeError path).""" + + class _BadConfig: + def to_dict(self): + raise AttributeError("no to_dict") + + result = BacktestResult( + trades=[], + equity_curve=[], + fills=[], + metrics={}, + config=_BadConfig(), + ) + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "test_backtest" + written = result.to_parquet(path, include=["config"]) + assert "config" not in written + def test_from_parquet_roundtrip(self, backtest_result: BacktestResult): """Test Parquet save and load roundtrip.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -427,6 +446,22 @@ def test_from_parquet_empty_dir(self): assert len(loaded.trades) == 0 assert len(loaded.equity_curve) == 0 + def test_from_parquet_invalid_config_is_non_fatal(self, monkeypatch): + """Test config load failures are swallowed and config remains None.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) + (path / "config.yaml").write_text("bad: [") + # Force yaml.safe_load failure branch + import yaml + + monkeypatch.setattr( + yaml, + "safe_load", + lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("bad yaml")), + ) + loaded = BacktestResult.from_parquet(path) + assert loaded.config is None + def test_metrics_json_serialization(self, backtest_result: BacktestResult): """Test metrics JSON contains only serializable values.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -705,3 +740,26 @@ def test_equity_schema(self): assert schema["equity"] == pl.Float64() assert schema["return"] == pl.Float64() assert schema["drawdown"] == pl.Float64() + + +class TestBacktestResultTearsheet: + """Tests for tearsheet import-error handling.""" + + def test_to_tearsheet_import_error(self, monkeypatch): + """Test to_tearsheet raises helpful ImportError when diagnostic is unavailable.""" + import builtins + + real_import = builtins.__import__ + + def _raising_import(name, *args, **kwargs): + if name == "ml4t.diagnostic.visualization.backtest": + raise ImportError("diagnostic missing") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _raising_import) + + result = BacktestResult(trades=[], equity_curve=[], fills=[], metrics={}) + with pytest.raises( + ImportError, match="ml4t-diagnostic is required for tearsheet generation" + ): + result.to_tearsheet() From 18b436c3ac87fb594f0a0a526392adfcc95d413b Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 14:06:53 -0500 Subject: [PATCH 23/24] test: normalize property test formatting for CI --- tests/property/test_accounting_invariants.py | 1 - tests/property/test_order_sequence_invariants.py | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/property/test_accounting_invariants.py b/tests/property/test_accounting_invariants.py index 2c6baea7..b7ec26eb 100644 --- a/tests/property/test_accounting_invariants.py +++ b/tests/property/test_accounting_invariants.py @@ -48,4 +48,3 @@ def test_round_trip_pnl_reconciles_cash(entry: float, exit_: float, qty: float) assert abs(trade.pnl - expected_pnl) < 1e-8 assert abs((initial_cash + expected_pnl) - broker.cash) < 1e-8 assert abs(broker.get_account_value() - broker.cash) < 1e-8 - diff --git a/tests/property/test_order_sequence_invariants.py b/tests/property/test_order_sequence_invariants.py index e6d52f8b..19ec3fc2 100644 --- a/tests/property/test_order_sequence_invariants.py +++ b/tests/property/test_order_sequence_invariants.py @@ -57,4 +57,3 @@ def test_exit_first_never_underfills_vs_fifo(price: float, qty: float) -> None: assert exit_first_qty >= fifo_qty assert abs(exit_first_value - fifo_value) < 1e-8 - From 41af582506e9f159ffa2ae0b0b2f4bf5f7f8e8f5 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Fri, 27 Feb 2026 14:09:52 -0500 Subject: [PATCH 24/24] test: skip cross-engine contract when framework deps missing --- tests/contracts/test_cross_engine_contracts.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/contracts/test_cross_engine_contracts.py b/tests/contracts/test_cross_engine_contracts.py index 7ad067dc..6951e6bd 100644 --- a/tests/contracts/test_cross_engine_contracts.py +++ b/tests/contracts/test_cross_engine_contracts.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib.util import os import subprocess import sys @@ -22,12 +23,26 @@ "zipline": "zipline", } +FRAMEWORK_IMPORTS = { + "vectorbt_oss": "vectorbt", + "backtrader": "backtrader", + "zipline": "zipline", +} + + +def _framework_available(framework: str) -> bool: + module_name = FRAMEWORK_IMPORTS[framework] + return importlib.util.find_spec(module_name) is not None + @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: + 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"