Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
- Polars
- PyYAML
- NumPy
- Pydantic

## Install from PyPI

Expand All @@ -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 .
```

Expand Down
1 change: 0 additions & 1 deletion docs/overrides/assets/javascripts/ml4t-docs-theme.js

This file was deleted.

12 changes: 6 additions & 6 deletions docs/overrides/assets/stylesheets/ml4t-docs-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 0 additions & 5 deletions docs/overrides/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -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() }}
<script src="{{ 'assets/javascripts/ml4t-docs-theme.js' | url }}"></script>
{% endblock %}
6 changes: 3 additions & 3 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/ml4t/backtest/accounting/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 16 additions & 4 deletions src/ml4t/backtest/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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")),
Expand Down Expand Up @@ -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")),
Expand Down
73 changes: 23 additions & 50 deletions src/ml4t/backtest/core/fill_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Comment thread
stefan-jansen marked this conversation as resolved.

def try_partial_fill(self, order, fill_price: float) -> bool:
max_shares = self.get_max_affordable_quantity(order, fill_price)
Expand Down
5 changes: 4 additions & 1 deletion src/ml4t/backtest/core/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -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
Expand Down
43 changes: 25 additions & 18 deletions src/ml4t/backtest/datafeed.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down
13 changes: 10 additions & 3 deletions src/ml4t/backtest/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
4 changes: 2 additions & 2 deletions src/ml4t/backtest/execution/fill_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down
Loading