From 49292cf28bb161efe2aeae8a428ebf826b724af2 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 11 Jul 2026 15:30:34 -0400 Subject: [PATCH] fix: address review backlog --- docs/getting-started/installation.md | 5 +- .../assets/javascripts/ml4t-docs-theme.js | 1 - .../assets/stylesheets/ml4t-docs-theme.css | 12 +-- docs/overrides/main.html | 5 -- mkdocs.yml | 6 +- src/ml4t/backtest/accounting/policy.py | 3 +- src/ml4t/backtest/config.py | 20 ++++- src/ml4t/backtest/core/fill_engine.py | 73 ++++++------------- src/ml4t/backtest/core/shared.py | 5 +- src/ml4t/backtest/datafeed.py | 43 ++++++----- src/ml4t/backtest/engine.py | 13 +++- src/ml4t/backtest/execution/fill_executor.py | 4 +- src/ml4t/backtest/execution/rebalancer.py | 11 ++- src/ml4t/backtest/result.py | 4 +- src/ml4t/backtest/risk/portfolio/manager.py | 18 ++++- src/ml4t/backtest/types.py | 2 +- tests/risk/test_portfolio_manager.py | 49 +++++++++++-- tests/test_broker.py | 46 +++++++++--- tests/test_config_wiring.py | 58 ++++++--------- tests/test_datafeed_memory.py | 56 ++++++++++++++ 20 files changed, 273 insertions(+), 161 deletions(-) delete mode 100644 docs/overrides/assets/javascripts/ml4t-docs-theme.js diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 76b47b9..3a3ba35 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -6,7 +6,6 @@ - Polars - PyYAML - NumPy -- Pydantic ## Install from PyPI @@ -23,8 +22,8 @@ uv add ml4t-backtest ## Install from Source ```bash -git clone https://github.com/ml4t/ml4t-backtest.git -cd ml4t-backtest +git clone https://github.com/ml4t/backtest.git +cd backtest pip install -e . ``` diff --git a/docs/overrides/assets/javascripts/ml4t-docs-theme.js b/docs/overrides/assets/javascripts/ml4t-docs-theme.js deleted file mode 100644 index 2fe74fe..0000000 --- a/docs/overrides/assets/javascripts/ml4t-docs-theme.js +++ /dev/null @@ -1 +0,0 @@ -document.documentElement.classList.add("ml4t-docs-theme"); diff --git a/docs/overrides/assets/stylesheets/ml4t-docs-theme.css b/docs/overrides/assets/stylesheets/ml4t-docs-theme.css index 01dca7d..d4fb70d 100644 --- a/docs/overrides/assets/stylesheets/ml4t-docs-theme.css +++ b/docs/overrides/assets/stylesheets/ml4t-docs-theme.css @@ -156,9 +156,9 @@ body { overflow: hidden; } -/* Tabs styled as integrated sub-navigation — same visual +/* Tabs styled as integrated sub-navigation - same visual language as the chapter page tabs on the main website. - Uses px units because MkDocs sets html font-size: 125%. */ + Uses px units to match the main website's navigation spacing. */ .md-tabs { background: white; border-bottom: 2px solid var(--ml4t-silver-muted); @@ -303,23 +303,23 @@ body { color: var(--ml4t-silver); } -.md-footer-nav__link { +.md-footer__link { opacity: 0.85; transition: opacity 0.15s; } -.md-footer-nav__link:hover { +.md-footer__link:hover { opacity: 1; } -.md-footer-nav__direction { +.md-footer__direction { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.6; } -.md-footer-nav__title { +.md-footer__title { font-family: "Source Serif 4", Georgia, serif; font-weight: 600; } diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 1fd85f1..5805592 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -13,8 +13,3 @@ href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=JetBrains+Mono:wght@400;600&family=Source+Serif+4:wght@500;600;700&display=swap" > {% endblock %} - -{% block scripts %} - {{ super() }} - -{% endblock %} diff --git a/mkdocs.yml b/mkdocs.yml index d324fe7..cc6087f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -128,9 +128,9 @@ nav: # Extra configuration extra: - ml4t_docs_hub_url: /docs/ - ml4t_libraries_url: /libraries/ - ml4t_chapters_url: /chapters/ + ml4t_docs_hub_url: https://ml4trading.io/docs/ + ml4t_libraries_url: https://ml4trading.io/libraries/ + ml4t_chapters_url: https://ml4trading.io/chapters/ social: - icon: fontawesome/brands/github link: https://github.com/stefan-jansen diff --git a/src/ml4t/backtest/accounting/policy.py b/src/ml4t/backtest/accounting/policy.py index 708a0ba..df1ed79 100644 --- a/src/ml4t/backtest/accounting/policy.py +++ b/src/ml4t/backtest/accounting/policy.py @@ -291,7 +291,8 @@ def __init__( - Percentages are fractions of notional, not whole percents - Example: {"ES": (0.05, 0.035)} short_cash_policy: How short proceeds affect spendable cash in - non-levered accounts. One of {"credit", "lock_notional"}. + non-levered accounts. One of {"credit", "credit_proceeds", + "lock_notional"}. Raises: ValueError: If margin parameters are invalid when leverage is enabled. diff --git a/src/ml4t/backtest/config.py b/src/ml4t/backtest/config.py index d7d110e..d168c4f 100644 --- a/src/ml4t/backtest/config.py +++ b/src/ml4t/backtest/config.py @@ -209,6 +209,12 @@ class CommissionType(str, Enum): PER_TRADE = "per_trade" # Fixed amount per trade TIERED = "tiered" # Volume-based tiers + @classmethod + def _missing_(cls, value: object) -> CommissionType | None: + if value == "per_contract": + return cls.PER_SHARE + return None + class SlippageType(str, Enum): """Slippage calculation method.""" @@ -1056,9 +1062,13 @@ def from_dict( margin_pct_schedule=acct_cfg.get("margin_pct_schedule"), short_cash_policy=ShortCashPolicy(acct_cfg.get("short_cash_policy", "credit")), # Execution - execution_price=ExecutionPrice(exec_cfg.get("execution_price", "open")), - mark_price=ExecutionPrice(exec_cfg.get("mark_price", "price")), - execution_mode=ExecutionMode(exec_cfg.get("execution_mode", "next_bar")), + execution_price=ExecutionPrice( + exec_cfg.get("execution_price", ExecutionPrice.OPEN.value) + ), + mark_price=ExecutionPrice(exec_cfg.get("mark_price", ExecutionPrice.PRICE.value)), + execution_mode=ExecutionMode( + exec_cfg.get("execution_mode", ExecutionMode.NEXT_BAR.value) + ), # Stops stop_fill_mode=StopFillMode(stops_cfg.get("stop_fill_mode", "stop_price")), stop_level_basis=StopLevelBasis(stops_cfg.get("stop_level_basis", "fill_price")), @@ -1106,7 +1116,9 @@ def from_dict( "next_bar_queue_shadow_validation", False ), immediate_fill=order_cfg.get("immediate_fill", False), - rebalance_mode=RebalanceMode(order_cfg.get("rebalance_mode", "incremental")), + rebalance_mode=RebalanceMode( + order_cfg.get("rebalance_mode", RebalanceMode.INCREMENTAL.value) + ), 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")), diff --git a/src/ml4t/backtest/core/fill_engine.py b/src/ml4t/backtest/core/fill_engine.py index 72df36a..60a7f64 100644 --- a/src/ml4t/backtest/core/fill_engine.py +++ b/src/ml4t/backtest/core/fill_engine.py @@ -2,8 +2,11 @@ from __future__ import annotations +from dataclasses import replace + from ..config import ExecutionPrice, ShareType from ..types import OrderSide, OrderType +from .shared import CASH_TOLERANCE class FillEngine: @@ -25,62 +28,32 @@ def apply_share_rounding(self, order) -> None: if self.broker.share_type == ShareType.INTEGER: order.quantity = float(int(order.quantity)) - def _commission_rate(self, order, fill_price: float) -> float: - if fill_price <= 0: - return 0.0 - test_commission = self.broker.commission_model.calculate(order.asset, 1.0, fill_price) - return test_commission / fill_price + def _can_afford_quantity(self, order, fill_price: float, quantity: float) -> bool: + if quantity <= 0: + return True + candidate = replace(order, quantity=quantity) + valid, _reason = self.broker.gatekeeper.validate_order(candidate, fill_price) + return valid def get_max_affordable_quantity(self, order, fill_price: float) -> float: """Return max fillable quantity under current cash constraints.""" - broker = self.broker - commission_rate = self._commission_rate(order, fill_price) - gross_per_share = fill_price * (1.0 + commission_rate) - if gross_per_share <= 0: + if fill_price <= 0 or order.quantity <= 0: return 0.0 - available = self.get_available_cash() - current_qty = broker.account.get_position_quantity(order.asset) - short_policy = getattr(broker.short_cash_policy, "value", "") - - if order.side == OrderSide.BUY and current_qty < 0 and short_policy == "lock_notional": - # VectorBT lock_cash-style cap for covering/reversing short positions. - position = broker.account.get_position(order.asset) - if position is None: - return max(0.0, available / gross_per_share) - - debt = abs(position.quantity) * position.entry_price * position.multiplier - cover_req_cash = abs(position.quantity) * gross_per_share - cover_free_cash = available + 2.0 * debt - cover_req_cash - - if cover_free_cash > 0: - cash_limit = available + 2.0 * debt - elif cover_free_cash < 0: - avg_entry_price = position.entry_price * position.multiplier - denom = gross_per_share - 2.0 * avg_entry_price - if denom <= 0: - cash_limit = 0.0 - else: - max_short_size = available / denom - cash_limit = max(0.0, max_short_size * gross_per_share) - else: - cash_limit = broker.account.cash - - return max(0.0, cash_limit / gross_per_share) + high = order.quantity + if self._can_afford_quantity(order, fill_price, high): + return high - if order.side == OrderSide.SELL and short_policy == "lock_notional": - # VectorBT lock_cash-style cap for short selling with locked free cash. - long_qty = max(current_qty, 0.0) - long_cash = long_qty * fill_price * max(0.0, 1.0 - commission_rate) - total_free_cash = available + long_cash - - if total_free_cash <= 0: - return max(0.0, long_qty) - - max_short_qty = total_free_cash / gross_per_share - return max(0.0, long_qty + max_short_qty) - - return max(0.0, available / gross_per_share) + low = 0.0 + for _ in range(64): + mid = (low + high) / 2.0 + if self._can_afford_quantity(order, fill_price, mid): + low = mid + else: + high = mid + if self.broker.share_type == ShareType.INTEGER: + return low + return max(0.0, low - CASH_TOLERANCE / fill_price) def try_partial_fill(self, order, fill_price: float) -> bool: max_shares = self.get_max_affordable_quantity(order, fill_price) diff --git a/src/ml4t/backtest/core/shared.py b/src/ml4t/backtest/core/shared.py index 6902676..9e825c7 100644 --- a/src/ml4t/backtest/core/shared.py +++ b/src/ml4t/backtest/core/shared.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from dataclasses import dataclass from typing import TYPE_CHECKING @@ -49,7 +50,9 @@ def reason_to_exit_reason(reason: str) -> ExitReason: return ExitReason.TRAILING_STOP elif "time" in reason_lower: return ExitReason.TIME_STOP - elif "risk_liquidation" in reason_lower or "liquidat" in reason_lower: + elif "risk_liquidation" in reason_lower or re.search( + r"\b(liquidation|liquidate)\b", reason_lower + ): return ExitReason.RISK_LIQUIDATION elif "end_of_data" in reason_lower: return ExitReason.END_OF_DATA diff --git a/src/ml4t/backtest/datafeed.py b/src/ml4t/backtest/datafeed.py index e290981..75ff7de 100644 --- a/src/ml4t/backtest/datafeed.py +++ b/src/ml4t/backtest/datafeed.py @@ -1,6 +1,6 @@ """Polars-based multi-asset data feed with O(1) timestamp lookups. -Memory-efficient implementation that stores the original DataFrames plus +Memory-efficient implementation that stores normalized DataFrames plus timestamp-to-slice indexes, then converts only the current bar to dicts at iteration time. """ @@ -51,9 +51,11 @@ class DataFeed: """Polars-based multi-asset data feed with signals and context. Pre-indexes data by timestamp at initialization for O(1) lookups during - iteration. DataFrames are kept in their native format and converted to - dicts only for the active bar, avoiding the large memory overhead of - materializing one child DataFrame per timestamp. + iteration. Public ``prices``, ``signals``, and ``context`` attributes are + normalized copies sorted by timestamp when needed. DataFrames are kept in + their native format and converted to dicts only for the active bar, + avoiding the large memory overhead of materializing one child DataFrame per + timestamp. Memory Efficiency: - 1M bars: ~100 MB (was ~1 GB with pre-converted dicts) @@ -171,7 +173,7 @@ def __init__( ) price_cols = self.prices.columns - self._price_asset_idx = price_cols.index(self._entity_col) + self._price_entity_idx = price_cols.index(self._entity_col) self._price_open_idx = ( price_cols.index(self._open_col) if self._open_col in price_cols else -1 ) @@ -206,10 +208,10 @@ def __init__( raise ValueError( f"timestamp_col={self._timestamp_col!r} not found in signal columns {signal_cols}" ) - self._signal_asset_idx = signal_cols.index(self._entity_col) + self._signal_entity_idx = signal_cols.index(self._entity_col) self._signal_col_indices = [signal_cols.index(c) for c in self._signal_columns] else: - self._signal_asset_idx = -1 + self._signal_entity_idx = -1 self._signal_col_indices = [] if self.context is not None: @@ -253,15 +255,20 @@ def _index_by_timestamp( if not df[self._timestamp_col].is_sorted(): df = df.sort(self._timestamp_col) - counts = df.group_by(self._timestamp_col, maintain_order=True).agg( - pl.len().alias("_row_count") - ) + timestamps = df.get_column(self._timestamp_col) result: dict[datetime, tuple[int, int]] = {} + if len(timestamps) == 0: + return df, result + offset = 0 - for ts, row_count in counts.iter_rows(named=False): - count = int(row_count) - result[ts] = (offset, count) - offset += count + current_ts = timestamps[0] + for idx in range(1, len(timestamps)): + ts = timestamps[idx] + if ts != current_ts: + result[current_ts] = (offset, idx - offset) + offset = idx + current_ts = ts + result[current_ts] = (offset, len(timestamps) - offset) return df, result @staticmethod @@ -312,7 +319,7 @@ def __next__(self) -> tuple[datetime, dict[str, dict], dict[str, Any]]: # O(1) lookup + lazy conversion to dicts (only for current bar) assets_data = _AssetsData() - price_asset_idx = self._price_asset_idx + price_entity_idx = self._price_entity_idx price_open_idx = self._price_open_idx price_high_idx = self._price_high_idx price_low_idx = self._price_low_idx @@ -329,7 +336,7 @@ def __next__(self) -> tuple[datetime, dict[str, dict], dict[str, Any]]: price_df = self._slice_for_timestamp(self.prices, self._price_ranges_by_ts, ts) if price_df is not None: for row in price_df.iter_rows(named=False): - asset = row[price_asset_idx] + asset = row[price_entity_idx] close = row[price_close_idx] if price_close_idx >= 0 else None price = row[price_price_idx] if price_price_idx >= 0 else close open_ = row[price_open_idx] if price_open_idx >= 0 else close @@ -396,11 +403,11 @@ def __next__(self) -> tuple[datetime, dict[str, dict], dict[str, Any]]: # Add signals for each asset - lazy conversion signal_df = self._slice_for_timestamp(self.signals, self._signal_ranges_by_ts, ts) if signal_df is not None: - signal_asset_idx = self._signal_asset_idx + signal_entity_idx = self._signal_entity_idx signal_col_indices = self._signal_col_indices signal_columns = self._signal_columns for row in signal_df.iter_rows(named=False): - asset = row[signal_asset_idx] + asset = row[signal_entity_idx] if asset in assets_data: asset_signals = assets_data._signals[asset] for i, col_idx in enumerate(signal_col_indices): diff --git a/src/ml4t/backtest/engine.py b/src/ml4t/backtest/engine.py index 8b5cbdb..439e681 100644 --- a/src/ml4t/backtest/engine.py +++ b/src/ml4t/backtest/engine.py @@ -171,14 +171,21 @@ def run(self) -> BacktestResult: for a, d in assets_data.items() if (price := d.get("price", d.get("close"))) is not None } - opens = {a: d.get("open", d.get("close")) for a, d in assets_data.items()} - highs = {a: d.get("high", d.get("close")) for a, d in assets_data.items()} - lows = {a: d.get("low", d.get("close")) for a, d in assets_data.items()} + opens = {} + highs = {} + lows = {} closes = { a: close for a, d in assets_data.items() if (close := d.get("close", d.get("price"))) is not None } + for asset, data in assets_data.items(): + base_price = data.get("close") + if base_price is None: + base_price = data.get("price") + opens[asset] = data.get("open") if data.get("open") is not None else base_price + highs[asset] = data.get("high") if data.get("high") is not None else base_price + lows[asset] = data.get("low") if data.get("low") is not None else base_price volumes = {a: d.get("volume", 0) for a, d in assets_data.items()} bids = {a: d["bid"] for a, d in assets_data.items() if d.get("bid") is not None} asks = {a: d["ask"] for a, d in assets_data.items() if d.get("ask") is not None} diff --git a/src/ml4t/backtest/execution/fill_executor.py b/src/ml4t/backtest/execution/fill_executor.py index d4afb34..1818c59 100644 --- a/src/ml4t/backtest/execution/fill_executor.py +++ b/src/ml4t/backtest/execution/fill_executor.py @@ -466,7 +466,7 @@ def _flip_position( pnl_percent=pnl_pct, bars_held=pos.bars_held, fees=total_close_commission, - exit_slippage=ctx.slippage * (close_qty / ctx.fill_quantity), + exit_slippage=ctx.slippage, exit_reason=_get_exit_reason(order), mfe=pos.max_favorable_excursion, mae=pos.max_adverse_excursion, @@ -501,7 +501,7 @@ def _flip_position( context=context, multiplier=broker.get_multiplier(order.asset), entry_commission=open_commission, - entry_slippage=ctx.slippage * (open_qty / ctx.fill_quantity), + entry_slippage=ctx.slippage, high_water_mark=initial_hwm, low_water_mark=initial_lwm, ) diff --git a/src/ml4t/backtest/execution/rebalancer.py b/src/ml4t/backtest/execution/rebalancer.py index 89f24a9..d13dba0 100644 --- a/src/ml4t/backtest/execution/rebalancer.py +++ b/src/ml4t/backtest/execution/rebalancer.py @@ -28,8 +28,9 @@ import polars as pl if TYPE_CHECKING: + from ml4t.specs.market_data import FeedSpec + from ..broker import Broker - from ..feed_spec import FeedSpec from ..types import Order from ..config import RebalanceMode, ShareType @@ -42,7 +43,7 @@ class WeightProvider(Protocol): """Protocol for anything that produces target weights.""" def get_weights(self, data: dict, broker: Broker) -> dict[str, float]: - """Return target weights (asset -> weight, should sum to <= 1.0).""" + """Return target weights (asset -> weight), including negative or levered weights.""" ... @@ -175,8 +176,10 @@ def execute( 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. + target_weights: Dict of asset -> target weight. Negative weights + express shorts. Gross exposure may exceed 1.0 when leverage is + enabled; ``max_gross_leverage`` and broker/account validation + control the allowed exposure. data: Current bar data (for prices). Format: {asset: {'close': price, ...}} broker: Broker instance for order submission. diff --git a/src/ml4t/backtest/result.py b/src/ml4t/backtest/result.py index 4454f12..438a22a 100644 --- a/src/ml4t/backtest/result.py +++ b/src/ml4t/backtest/result.py @@ -525,8 +525,8 @@ def to_parquet( Args: path: Directory path to write files include: Components to include. Default: all. - Options: ["trades", "fills", "predictions", "equity", "portfolio_state", "daily_pnl", - "metrics", "config"] + Options: ["trades", "fills", "predictions", "equity", "portfolio_state", + "daily_pnl", "metrics", "config", "spec"] compression: Parquet compression codec (default: "zstd") Returns: diff --git a/src/ml4t/backtest/risk/portfolio/manager.py b/src/ml4t/backtest/risk/portfolio/manager.py index a9fa2aa..bfefe27 100644 --- a/src/ml4t/backtest/risk/portfolio/manager.py +++ b/src/ml4t/backtest/risk/portfolio/manager.py @@ -1,12 +1,17 @@ """RiskManager for portfolio-level risk management.""" +from __future__ import annotations + import warnings from dataclasses import dataclass, field from datetime import date, datetime -from typing import Any +from typing import TYPE_CHECKING, Any from .limits import LimitResult, PortfolioLimit, PortfolioState +if TYPE_CHECKING: + from ...broker import Broker + @dataclass class RiskManager: @@ -27,9 +32,10 @@ class RiskManager: MaxDrawdownLimit(max_drawdown=0.20), MaxPositionsLimit(max_positions=10), ]) + manager.initialize(initial_equity=broker.get_account_value()) # In strategy or engine: - results = manager.update( + manager.update( equity=broker.get_account_value(), positions={asset: pos.market_value for asset, pos in broker.positions.items()}, timestamp=timestamp, @@ -50,6 +56,7 @@ class RiskManager: _halted: bool = False _halt_reason: str = "" _warnings: list[str] = field(default_factory=list) + _liquidation_applied: bool = False def initialize(self, initial_equity: float, timestamp: datetime | None = None) -> None: """Initialize the risk manager with starting equity. @@ -66,6 +73,7 @@ def initialize(self, initial_equity: float, timestamp: datetime | None = None) - self._halted = False self._halt_reason = "" self._warnings = [] + self._liquidation_applied = False def update( self, @@ -73,7 +81,7 @@ def update( positions: dict[str, float], timestamp: datetime | None = None, context: dict[str, Any] | None = None, - broker: Any | None = None, + broker: Broker | None = None, ) -> list[LimitResult]: """Update risk state and check all limits. @@ -124,10 +132,11 @@ def update( elif result.action == "warn": self._warnings.append(result.reason) - if liquidation_reasons: + if liquidation_reasons and not self._liquidation_applied: liquidation_reason = "; ".join(dict.fromkeys(liquidation_reasons)) if broker is not None: broker.flatten_all_positions(reason=liquidation_reason) + self._liquidation_applied = True else: warnings.warn( "RiskManager.update() produced action='liquidate' but no broker was " @@ -222,6 +231,7 @@ def reset_halt(self) -> None: """Manually reset halt state (use with caution).""" self._halted = False self._halt_reason = "" + self._liquidation_applied = False def get_state( self, diff --git a/src/ml4t/backtest/types.py b/src/ml4t/backtest/types.py index 7305e52..b4e9ae7 100644 --- a/src/ml4t/backtest/types.py +++ b/src/ml4t/backtest/types.py @@ -469,7 +469,7 @@ def total_slippage_cost(self) -> float: @property def cost_drag(self) -> float: - """Total cost as fraction of notional: (fees + slippage) / notional.""" + """Total cost as fraction of notional: (fees + total slippage cost) / notional.""" notional = self.entry_price * abs(self.quantity) * self.multiplier if notional == 0: return 0.0 diff --git a/tests/risk/test_portfolio_manager.py b/tests/risk/test_portfolio_manager.py index f7e28e0..80ef6d8 100644 --- a/tests/risk/test_portfolio_manager.py +++ b/tests/risk/test_portfolio_manager.py @@ -14,7 +14,26 @@ PortfolioState, ) from ml4t.backtest.risk.portfolio.manager import RiskManager -from ml4t.backtest.types import ExitReason, Position +from ml4t.backtest.types import ExitReason, OrderSide + + +def mark_prices(broker: Broker, prices: dict[str, float]) -> None: + broker._update_time( + timestamp=datetime(2024, 1, 1, 9, 30), + prices=prices, + opens=prices, + highs=prices, + lows=prices, + volumes=dict.fromkeys(prices, 1000000.0), + signals={}, + ) + + +def open_long_position(broker: Broker, asset: str, quantity: float, price: float) -> None: + mark_prices(broker, {asset: price}) + broker.submit_order(asset, quantity, OrderSide.BUY) + broker._process_orders() + assert broker.positions[asset].quantity == quantity class TestRiskManagerInitialization: @@ -141,12 +160,8 @@ def test_update_liquidate_action_flattens_with_broker(self): commission_model=NoCommission(), slippage_model=NoSlippage(), ) - broker.positions["AAPL"] = Position( - asset="AAPL", - quantity=100.0, - entry_price=150.0, - entry_time=datetime(2024, 1, 1, 9, 30), - ) + open_long_position(broker, "AAPL", 100.0, 150.0) + mark_prices(broker, {"AAPL": 150.0}) results = manager.update( equity=85000.0, @@ -160,6 +175,26 @@ def test_update_liquidate_action_flattens_with_broker(self): assert len(pending) == 1 assert pending[0]._exit_reason == ExitReason.RISK_LIQUIDATION + def test_update_liquidate_action_is_idempotent_with_broker(self): + """Persistent breaches should not queue duplicate liquidation orders.""" + limits = [MaxDrawdownLimit(max_drawdown=0.10)] + manager = RiskManager(limits=limits) + manager.initialize(initial_equity=100000.0) + broker = Broker( + initial_cash=100000.0, + commission_model=NoCommission(), + slippage_model=NoSlippage(), + ) + open_long_position(broker, "AAPL", 100.0, 150.0) + mark_prices(broker, {"AAPL": 150.0}) + + manager.update(equity=85000.0, positions={"AAPL": 15000.0}, broker=broker) + manager.update(equity=84000.0, positions={"AAPL": 15000.0}, broker=broker) + + pending = broker.get_pending_orders() + assert len(pending) == 1 + assert pending[0]._exit_reason == ExitReason.RISK_LIQUIDATION + def test_update_warn_action(self): """Test that warn action adds to warnings.""" limits = [MaxExposureLimit(max_exposure_pct=0.50, action="warn")] diff --git a/tests/test_broker.py b/tests/test_broker.py index 8180843..7138f01 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -43,6 +43,26 @@ def broker_with_position(broker): return broker +def mark_prices(broker: Broker, prices: dict[str, float]) -> None: + broker._update_time( + timestamp=datetime(2024, 1, 1, 9, 30), + prices=prices, + opens=prices, + highs=prices, + lows=prices, + volumes=dict.fromkeys(prices, 1000000.0), + signals={}, + ) + + +def open_position(broker: Broker, asset: str, quantity: float, price: float) -> None: + mark_prices(broker, {asset: price}) + side = OrderSide.BUY if quantity > 0 else OrderSide.SELL + broker.submit_order(asset, abs(quantity), side) + broker._process_orders() + assert broker.positions[asset].quantity == quantity + + class TestBrokerBasics: """Test basic broker methods.""" @@ -216,20 +236,17 @@ def test_close_short_position(self, broker): assert order.side == OrderSide.BUY # Buy to cover short assert order.quantity == 50.0 - def test_flatten_all_positions_cancels_pending_and_marks_exits(self, broker): + def test_flatten_all_positions_cancels_pending_and_marks_exits(self): """Test flatten_all_positions cancels pending orders and tags exits.""" - broker.positions["AAPL"] = Position( - asset="AAPL", - quantity=100.0, - entry_price=150.0, - entry_time=datetime(2024, 1, 1, 9, 30), - ) - broker.positions["MSFT"] = Position( - asset="MSFT", - quantity=-25.0, - entry_price=300.0, - entry_time=datetime(2024, 1, 1, 9, 30), + broker = Broker( + initial_cash=100000.0, + commission_model=NoCommission(), + slippage_model=NoSlippage(), + allow_short_selling=True, ) + open_position(broker, "AAPL", 100.0, 150.0) + open_position(broker, "MSFT", -25.0, 300.0) + mark_prices(broker, {"AAPL": 151.0, "MSFT": 299.0, "GOOG": 100.0}) pending_entry = broker.submit_order("GOOG", 10.0, OrderSide.BUY) orders = broker.flatten_all_positions("max drawdown breached") @@ -243,6 +260,11 @@ def test_flatten_all_positions_cancels_pending_and_marks_exits(self, broker): assert all(order._risk_exit_reason == "max drawdown breached" for order in orders) assert broker.get_pending_orders() == orders + def test_flatten_all_positions_noop_without_positions_or_orders(self, broker): + """Test flatten_all_positions is safe when there is nothing to flatten.""" + assert broker.flatten_all_positions("no positions") == [] + assert broker.get_pending_orders() == [] + class TestIsExitOrder: """Test ExecutionEngine._is_exit_order internal method.""" diff --git a/tests/test_config_wiring.py b/tests/test_config_wiring.py index 5b333fd..34ab491 100644 --- a/tests/test_config_wiring.py +++ b/tests/test_config_wiring.py @@ -498,6 +498,23 @@ def test_partial_fill_with_integer_shares(self): assert pos.quantity == int(pos.quantity) assert pos.quantity == 52.0 # floor(5250/100) + def test_partial_fill_accounts_for_commission_minimum(self): + broker = _make_broker( + initial_cash=5_005.0, + commission_model=PerShareCommission(per_share=0.01, minimum=10.0), + partial_fills_allowed=True, + share_type=ShareType.INTEGER, + ) + _set_prices(broker, {"AAPL": 100.0}) + + broker.submit_order("AAPL", 100, OrderSide.BUY) + broker._process_orders() + + pos = broker.get_position("AAPL") + assert pos is not None + assert pos.quantity == 49.0 + assert broker.cash == 95.0 + # --------------------------------------------------------------------------- # fill_ordering @@ -1087,40 +1104,9 @@ class TestFromDictDefaultParity: def test_empty_dict_matches_constructor_defaults(self): default = BacktestConfig() - from_empty = BacktestConfig.from_dict({}, strict=False) - - # Core execution fields that were previously mismatched - assert from_empty.execution_mode == default.execution_mode - assert from_empty.execution_price == default.execution_price - assert from_empty.mark_price == default.mark_price - assert from_empty.rebalance_mode == default.rebalance_mode - - # Verify all enum fields match - assert from_empty.stop_fill_mode == default.stop_fill_mode - assert from_empty.stop_level_basis == default.stop_level_basis - assert from_empty.trail_hwm_source == default.trail_hwm_source - assert from_empty.initial_hwm_source == default.initial_hwm_source - assert from_empty.trail_stop_timing == default.trail_stop_timing - assert from_empty.share_type == default.share_type - assert from_empty.commission_type == default.commission_type - assert from_empty.slippage_type == default.slippage_type - assert from_empty.slippage_spread == default.slippage_spread - assert from_empty.slippage_spread_by_asset == default.slippage_spread_by_asset - assert from_empty.slippage_spread_convention == default.slippage_spread_convention - assert from_empty.fill_ordering == default.fill_ordering - assert from_empty.entry_order_priority == default.entry_order_priority - assert from_empty.short_cash_policy == default.short_cash_policy - assert from_empty.data_frequency == default.data_frequency - assert from_empty.missing_price_policy == default.missing_price_policy - assert from_empty.late_asset_policy == default.late_asset_policy - - # Verify key numeric/bool fields match - assert from_empty.initial_cash == default.initial_cash - assert from_empty.commission_rate == default.commission_rate - assert from_empty.slippage_rate == default.slippage_rate - assert from_empty.allow_short_selling == default.allow_short_selling - assert from_empty.allow_leverage == default.allow_leverage - assert from_empty.settlement_delay == default.settlement_delay + from_empty = BacktestConfig.from_dict({}) + + assert from_empty == default def test_constructor_defaults_to_integer_shares(self): config = BacktestConfig() @@ -1131,6 +1117,10 @@ def test_constructor_defaults_to_integer_shares(self): assert config.slippage_type == SlippageType.NONE assert config.slippage_rate == 0.0 + def test_per_contract_commission_alias_loads_from_dict(self): + config = BacktestConfig.from_dict({"commission": {"model": "per_contract"}}) + assert config.commission_type == CommissionType.PER_SHARE + class TestFeedSpecConfigResolution: def test_constructor_canonicalizes_feed_spec_metadata(self): diff --git a/tests/test_datafeed_memory.py b/tests/test_datafeed_memory.py index 48273b9..e697751 100644 --- a/tests/test_datafeed_memory.py +++ b/tests/test_datafeed_memory.py @@ -133,6 +133,62 @@ def test_datafeed_with_context(self): assert ctx["vix"] == 20.5 assert ctx["spy_close"] == 300.0 + def test_datafeed_iterates_signal_timestamps_without_prices(self): + """Signals can add timestamps while price slices remain optional.""" + ts1 = datetime(2020, 1, 1) + ts2 = datetime(2020, 1, 2) + prices = pl.DataFrame( + { + "timestamp": [ts1], + "asset": ["AAPL"], + "open": [100.0], + "high": [101.0], + "low": [99.0], + "close": [100.5], + "volume": [1_000_000], + } + ) + signals = pl.DataFrame( + { + "timestamp": [ts1, ts2], + "asset": ["AAPL", "AAPL"], + "signal": [0.5, 0.9], + } + ) + + rows = list(DataFeed(prices_df=prices, signals_df=signals)) + + assert [ts for ts, *_ in rows] == [ts1, ts2] + assert rows[0][1]["AAPL"]["signals"]["signal"] == 0.5 + assert rows[1][1] == {} + + def test_datafeed_mixed_slice_lengths_from_unsorted_input(self): + """Offset indexes handle interleaved one-row and multi-row bars.""" + ts1 = datetime(2020, 1, 1) + ts2 = datetime(2020, 1, 2) + ts3 = datetime(2020, 1, 3) + prices = pl.DataFrame( + { + "timestamp": [ts2, ts1, ts3, ts1], + "asset": ["AAPL", "AAPL", "AAPL", "MSFT"], + "open": [200.0, 100.0, 300.0, 150.0], + "high": [201.0, 101.0, 301.0, 151.0], + "low": [199.0, 99.0, 299.0, 149.0], + "close": [200.5, 100.5, 300.5, 150.5], + "volume": [2_000_000, 1_000_000, 3_000_000, 1_500_000], + } + ) + + rows = list(DataFeed(prices_df=prices)) + + assert [ts for ts, *_ in rows] == [ts1, ts2, ts3] + assert set(rows[0][1]) == {"AAPL", "MSFT"} + assert rows[0][1]["MSFT"]["close"] == 150.5 + assert set(rows[1][1]) == {"AAPL"} + assert rows[1][1]["AAPL"]["close"] == 200.5 + assert set(rows[2][1]) == {"AAPL"} + assert rows[2][1]["AAPL"]["close"] == 300.5 + @pytest.mark.benchmark def test_datafeed_memory_benchmark(self): """Benchmark memory usage for medium-scale dataset.