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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ Benchmark on 250 assets x 20 years daily data (1.26M bars):
## Development

```bash
git clone https://github.com/ml4t/ml4t-backtest.git
git clone https://github.com/ml4t/backtest.git
cd ml4t-backtest
uv sync
uv run pytest tests/ -q
Expand Down
32 changes: 31 additions & 1 deletion src/ml4t/backtest/accounting/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ def __init__(
long_maintenance_margin: float = 0.25,
short_maintenance_margin: float = 0.30,
fixed_margin_schedule: dict[str, tuple[float, float]] | None = None,
margin_pct_schedule: dict[str, tuple[float, float]] | None = None,
short_cash_policy: str = "credit",
) -> None:
"""Initialize unified account policy.
Expand All @@ -246,6 +247,10 @@ def __init__(
fixed_margin_schedule: Per-asset fixed dollar margin for futures.
- Dict mapping asset symbol to (initial, maintenance) tuple
- Example: {"ES": (12000, 6000)}
margin_pct_schedule: Per-asset percentage-of-notional margin schedule.
- Dict mapping asset symbol to (initial, maintenance) tuple
- 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"}.

Expand All @@ -258,13 +263,23 @@ def __init__(
self.long_maintenance_margin = long_maintenance_margin
self.short_maintenance_margin = short_maintenance_margin
self.fixed_margin_schedule = fixed_margin_schedule or {}
self.margin_pct_schedule = margin_pct_schedule or {}
Comment on lines 265 to +266
if short_cash_policy not in {"credit", "credit_proceeds", "lock_notional"}:
raise ValueError(
"short_cash_policy must be 'credit', 'credit_proceeds', or "
f"'lock_notional', got {short_cash_policy}"
)
self.short_cash_policy = short_cash_policy

overlapping_margin_assets = sorted(
set(self.fixed_margin_schedule) & set(self.margin_pct_schedule)
)
if overlapping_margin_assets:
raise ValueError(
"fixed_margin_schedule and margin_pct_schedule cannot both define: "
f"{overlapping_margin_assets}"
)

# Validate margin parameters if leverage is enabled
if allow_leverage:
if not 0.0 < initial_margin <= 1.0:
Expand Down Expand Up @@ -306,6 +321,7 @@ def from_config(cls, config: BacktestConfig) -> UnifiedAccountPolicy:
long_maintenance_margin=config.long_maintenance_margin,
short_maintenance_margin=config.short_maintenance_margin,
fixed_margin_schedule=config.fixed_margin_schedule,
margin_pct_schedule=config.margin_pct_schedule,
short_cash_policy=config.short_cash_policy.value,
)

Expand Down Expand Up @@ -384,7 +400,15 @@ def get_margin_requirement(
) -> float:
"""Calculate margin requirement for a position.

Uses fixed dollar margin for futures, percentage for equities.
Supports three margin models:

- ``margin_pct_schedule``: per-asset percentage-of-notional margin.
This is the preferred price-aware approximation for futures when only
a stable scan ratio is known.
- ``fixed_margin_schedule``: per-asset fixed dollar margin per contract.
This models a single historical SPAN snapshot.
- account-wide percentage margin: fallback for assets not covered by a
per-asset schedule.

Args:
asset: Asset symbol
Expand All @@ -395,6 +419,12 @@ def get_margin_requirement(
Returns:
Margin required in dollars
"""
# Price-aware per-asset percentage margin (preferred for futures)
if asset in self.margin_pct_schedule:
initial, maintenance = self.margin_pct_schedule[asset]
margin_rate = initial if for_initial else maintenance
return abs(quantity * price) * margin_rate
Comment on lines +422 to +426

# Check for fixed margin (futures)
if asset in self.fixed_margin_schedule:
initial, maintenance = self.fixed_margin_schedule[asset]
Expand Down
9 changes: 8 additions & 1 deletion src/ml4t/backtest/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def __init__(
long_maintenance_margin: float = 0.25,
short_maintenance_margin: float = 0.30,
fixed_margin_schedule: dict[str, tuple[float, float]] | None = None,
margin_pct_schedule: dict[str, tuple[float, float]] | None = None,
short_cash_policy: ShortCashPolicy = ShortCashPolicy.CREDIT,
execution_limits: ExecutionLimits | None = None,
market_impact_model: MarketImpactModel | None = None,
Expand Down Expand Up @@ -144,11 +145,14 @@ def __init__(
# This lets users specify margin once on ContractSpec rather than duplicating
# it in both ContractSpec and BacktestConfig.fixed_margin_schedule.
effective_margin_schedule = dict(fixed_margin_schedule or {})
effective_margin_pct_schedule = dict(margin_pct_schedule or {})
Comment on lines 147 to +148
if contract_specs:
for symbol, spec in contract_specs.items():
if spec.margin is not None and symbol not in effective_margin_schedule:
# Use spec.margin as initial margin, 50% as maintenance (industry standard)
effective_margin_schedule[symbol] = (spec.margin, spec.margin * 0.5)
if spec.margin_pct is not None and symbol not in effective_margin_pct_schedule:
effective_margin_pct_schedule[symbol] = spec.margin_pct

# Create AccountState with UnifiedAccountPolicy
policy: AccountPolicy = UnifiedAccountPolicy(
Expand All @@ -158,6 +162,7 @@ def __init__(
long_maintenance_margin=long_maintenance_margin,
short_maintenance_margin=short_maintenance_margin,
fixed_margin_schedule=effective_margin_schedule or None,
margin_pct_schedule=effective_margin_pct_schedule or None,
short_cash_policy=short_cash_policy.value,
)

Expand All @@ -174,7 +179,8 @@ def __init__(
self.initial_margin = initial_margin
self.long_maintenance_margin = long_maintenance_margin
self.short_maintenance_margin = short_maintenance_margin
self.fixed_margin_schedule = fixed_margin_schedule or {}
self.fixed_margin_schedule = effective_margin_schedule
self.margin_pct_schedule = effective_margin_pct_schedule
self.short_cash_policy = short_cash_policy

# Create Gatekeeper for order validation
Expand Down Expand Up @@ -360,6 +366,7 @@ def from_config(
long_maintenance_margin=config.long_maintenance_margin,
short_maintenance_margin=config.short_maintenance_margin,
fixed_margin_schedule=config.fixed_margin_schedule,
margin_pct_schedule=config.margin_pct_schedule,
short_cash_policy=config.short_cash_policy,
execution_limits=execution_limits,
market_impact_model=market_impact_model,
Expand Down
17 changes: 15 additions & 2 deletions src/ml4t/backtest/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from typing import Any

import yaml

from ml4t.specs.base import serialize_artifact_value
from ml4t.specs.market_data import FeedSpec, TimestampSemantics

Expand Down Expand Up @@ -402,6 +403,7 @@ class BacktestConfig:
long_maintenance_margin: float = 0.25 # Reg T standard for longs
short_maintenance_margin: float = 0.30 # Reg T standard for shorts (higher!)
fixed_margin_schedule: dict[str, tuple[float, float]] | None = None # For futures
margin_pct_schedule: dict[str, tuple[float, float]] | None = None # Price-aware futures margin
short_cash_policy: ShortCashPolicy = ShortCashPolicy.CREDIT

# === Execution Timing ===
Expand Down Expand Up @@ -511,6 +513,15 @@ def validate(self, warn: bool = True) -> list[str]:
f"initial_margin ({self.initial_margin})"
)

fixed_assets = set(self.fixed_margin_schedule or {})
pct_assets = set(self.margin_pct_schedule or {})
overlapping_margin_assets = sorted(fixed_assets & pct_assets)
if overlapping_margin_assets:
issues.append(
"fixed_margin_schedule and margin_pct_schedule cannot both define: "
f"{overlapping_margin_assets}"
)
Comment on lines +516 to +523

if self.settlement_delay < 0 or self.settlement_delay > 5:
issues.append(
f"settlement_delay ({self.settlement_delay}) should be 0-5. "
Expand Down Expand Up @@ -717,6 +728,7 @@ def to_dict(self) -> dict:
"long_maintenance_margin": self.long_maintenance_margin,
"short_maintenance_margin": self.short_maintenance_margin,
"fixed_margin_schedule": self.fixed_margin_schedule,
"margin_pct_schedule": self.margin_pct_schedule,
"short_cash_policy": self.short_cash_policy.value,
},
"execution": {
Expand Down Expand Up @@ -826,6 +838,7 @@ def from_dict(
"long_maintenance_margin",
"short_maintenance_margin",
"fixed_margin_schedule",
"margin_pct_schedule",
"short_cash_policy",
},
"execution": {"execution_price", "mark_price", "execution_mode"},
Expand Down Expand Up @@ -946,6 +959,7 @@ def from_dict(
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"),
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")),
Expand Down Expand Up @@ -1171,8 +1185,7 @@ def from_user_config(
if node:
if not isinstance(node, dict):
raise TypeError(
"Expected broker-specific override to be a mapping in "
f"{assumptions_path}"
f"Expected broker-specific override to be a mapping in {assumptions_path}"
)
merged_data = _deep_merge_dicts(merged_data, node)
else:
Expand Down
1 change: 1 addition & 0 deletions src/ml4t/backtest/datafeed.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Any

import polars as pl

from ml4t.specs.market_data import FeedSpec


Expand Down
4 changes: 3 additions & 1 deletion src/ml4t/backtest/execution/rebalancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,9 @@ def _quantize_shares(self, shares: float, broker: Broker) -> float:
"""
use_fractional = self.config.allow_fractional
if use_fractional is None:
use_fractional = getattr(broker, "share_type", ShareType.INTEGER) == ShareType.FRACTIONAL
use_fractional = (
getattr(broker, "share_type", ShareType.INTEGER) == ShareType.FRACTIONAL
)

if self.config.round_lots:
rounded_lots = round(shares / self.config.lot_size) * self.config.lot_size
Expand Down
1 change: 1 addition & 0 deletions src/ml4t/backtest/execution/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Any

import polars as pl

from ml4t.specs.market_data import FeedSpec, TimestampSemantics

from ..calendar import get_schedule
Expand Down
1 change: 1 addition & 0 deletions src/ml4t/backtest/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from typing import TYPE_CHECKING, Any, Literal

import polars as pl

from ml4t.specs.market_data import FeedSpec

try:
Expand Down
9 changes: 9 additions & 0 deletions src/ml4t/backtest/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ class ContractSpec:
margin=15000.0, # Initial margin per contract
)

# Price-aware margin approximation
nq_spec = ContractSpec(
symbol="NQ",
asset_class=AssetClass.FUTURE,
multiplier=20.0,
margin_pct=(0.05, 0.035), # 5.0% initial, 3.5% maintenance
)

# Apple stock
aapl_spec = ContractSpec(
symbol="AAPL",
Expand All @@ -98,6 +106,7 @@ class ContractSpec:
multiplier: float = 1.0 # Point value ($ per point move)
tick_size: float = 0.01 # Minimum price increment
margin: float | None = None # Initial margin per contract (overrides account default)
margin_pct: tuple[float, float] | None = None # (initial, maintenance) fractions of notional
currency: str = "USD"


Expand Down
68 changes: 68 additions & 0 deletions tests/accounting/test_margin_account_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,74 @@ def test_mixed_portfolio_buying_power(self):
assert bp > 0


class TestMarginAccountPolicyFuturesMarginPct:
"""Tests for price-aware percentage-of-notional futures margin."""

def test_futures_margin_pct_initial(self):
"""Initial margin should scale with notional."""
policy = UnifiedAccountPolicy(
allow_short_selling=True,
allow_leverage=True,
margin_pct_schedule={"ES": (0.05, 0.035)},
)
margin = policy.get_margin_requirement("ES", 2, 5000.0, for_initial=True)
assert margin == 500.0
Comment on lines +701 to +702

def test_futures_margin_pct_maintenance(self):
"""Maintenance margin should use maintenance schedule rate."""
policy = UnifiedAccountPolicy(
allow_short_selling=True,
allow_leverage=True,
margin_pct_schedule={"ES": (0.05, 0.035)},
)
margin = policy.get_margin_requirement("ES", 2, 5000.0, for_initial=False)
assert margin == pytest.approx(350.0)

def test_futures_margin_pct_tracks_price(self):
"""Percentage margin should move with price."""
policy = UnifiedAccountPolicy(
allow_short_selling=True,
allow_leverage=True,
margin_pct_schedule={"ES": (0.05, 0.035)},
)
margin_low = policy.get_margin_requirement("ES", 1, 4000.0, for_initial=True)
margin_high = policy.get_margin_requirement("ES", 1, 6000.0, for_initial=True)
assert margin_low == 200.0
assert margin_high == 300.0

def test_margin_pct_schedule_short_same_as_long(self):
"""Percentage-based futures margin should be direction-agnostic."""
policy = UnifiedAccountPolicy(
allow_short_selling=True,
allow_leverage=True,
margin_pct_schedule={"ES": (0.05, 0.035)},
)
long_margin = policy.get_margin_requirement("ES", 2, 5000.0, for_initial=True)
short_margin = policy.get_margin_requirement("ES", -2, 5000.0, for_initial=True)
assert long_margin == short_margin == 500.0

def test_margin_pct_schedule_takes_precedence_over_global_margin(self):
"""Per-asset percentage schedule should override account-wide margin."""
policy = UnifiedAccountPolicy(
allow_short_selling=True,
allow_leverage=True,
initial_margin=0.5,
margin_pct_schedule={"ES": (0.05, 0.035)},
)
margin = policy.get_margin_requirement("ES", 1, 5000.0, for_initial=True)
assert margin == 250.0

def test_reject_overlapping_fixed_and_percentage_margin(self):
"""A symbol must not define both fixed and percentage margin models."""
with pytest.raises(ValueError, match="cannot both define"):
UnifiedAccountPolicy(
allow_short_selling=True,
allow_leverage=True,
fixed_margin_schedule={"ES": (12_000.0, 6_000.0)},
margin_pct_schedule={"ES": (0.05, 0.035)},
)


class TestMarginAccountPolicyMarginCall:
"""Tests for margin call detection."""

Expand Down
2 changes: 1 addition & 1 deletion tests/contracts/test_execution_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import polars as pl
import pytest
from ml4t.specs.market_data import FeedSpec

from ml4t.backtest.config import (
BacktestConfig,
Expand All @@ -16,6 +15,7 @@
from ml4t.backtest.engine import run_backtest
from ml4t.backtest.strategy import Strategy
from ml4t.backtest.types import ExecutionMode
from ml4t.specs.market_data import FeedSpec


def _prices() -> pl.DataFrame:
Expand Down
2 changes: 1 addition & 1 deletion tests/execution/test_rebalancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from datetime import datetime

import pytest
from ml4t.specs.market_data import FeedSpec

from ml4t.backtest import (
Broker,
Expand All @@ -13,6 +12,7 @@
from ml4t.backtest.execution.rebalancer import RebalanceConfig, TargetWeightExecutor
from ml4t.backtest.execution.schedule import RebalanceSchedule
from ml4t.backtest.models import NoCommission, NoSlippage
from ml4t.specs.market_data import FeedSpec


class TestRebalanceConfig:
Expand Down
Loading