From 905399053eae8ac136e70960530fc1e46e0989fa Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 24 Sep 2026 10:56:23 -0400 Subject: [PATCH 1/8] docs: correct stateful simulation claims --- docs/user-guide/execution-semantics.md | 2 +- docs/user-guide/stateful-strategies.md | 16 +++++++++------- examples/stateful_strategies.py | 20 ++++++++++---------- examples/test_stateful_strategies.py | 3 +-- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/user-guide/execution-semantics.md b/docs/user-guide/execution-semantics.md index 9ac9a7a8..16f7355c 100644 --- a/docs/user-guide/execution-semantics.md +++ b/docs/user-guide/execution-semantics.md @@ -236,7 +236,7 @@ Orders can fill in the bar where they are submitted, using the configured `execu Bar N: Strategy sees close=$100, submits buy order, fills at close=$100 ``` -This mode is useful for comparing against vectorized frameworks (VectorBT) where signals and fills happen simultaneously. It carries look-ahead risk for production strategies because the strategy can "see" the close before deciding to trade at the close. +Use this mode only when the comparison explicitly permits a decision and fill at the same close. It carries lookahead risk if the strategy observes that close before choosing an order that fills there. VectorBT supports several simulation modes, including [state-dependent order functions](https://vectorbt.dev/api/portfolio/base/); its timing cannot be summarized by this one setting. ```python from ml4t.backtest import BacktestConfig, ExecutionMode diff --git a/docs/user-guide/stateful-strategies.md b/docs/user-guide/stateful-strategies.md index 7de9c75e..c326d292 100644 --- a/docs/user-guide/stateful-strategies.md +++ b/docs/user-guide/stateful-strategies.md @@ -2,7 +2,7 @@ Use a stateful strategy when a later decision depends on earlier order outcomes, cash, positions, or equity. A signal array computed from price history alone does not capture those execution outcomes. The event loop passes updated broker state into each decision. -The strategy classes below illustrate individual state patterns. Run [the complete strategy examples](https://github.com/ml4t/backtest/blob/main/examples/stateful_strategies.py) or the [risk and state tutorial](../tutorials/risk-and-state.md) for a complete feed and result. +The strategy classes below illustrate individual state patterns. Run [the complete strategy examples](https://github.com/ml4t/backtest/blob/main/examples/stateful_strategies.py) or the [risk and state tutorial](../tutorials/risk-and-state.md) for a complete feed and result. The pairs example submits independent orders; it does not guarantee that both legs fill. ## When You Need Event-Driven @@ -12,12 +12,14 @@ Use vectorized backtesting when your signal is a pure function of price history: signal[t] = f(prices[0:t]) # No execution feedback ``` -Use event-driven backtesting when your trading decision depends on execution state: +Use a callback-based simulation when your trading decision depends on execution state: ``` action[t] = g(prices[0:t], fills[0:t], equity[0:t]) # Update execution state in order ``` +VectorBT also supports state-dependent order generation through [`Portfolio.from_order_func`](https://vectorbt.dev/api/portfolio/base/). The distinction here is between a precomputed order array and a simulation that updates state after orders execute. + Five categories of stateful patterns: | Pattern | State Dependency | Example | @@ -77,7 +79,7 @@ class AdaptiveKellySizingStrategy(Strategy): broker.close_position(asset) ``` -**Why vectorized fails**: The Kelly fraction at bar N depends on the win rate from trades 0..N-1, but each trade's P&L depends on its size, which was set by the Kelly fraction at entry time. This circular dependency requires sequential execution. +**Why precomputed sizes are insufficient:** The Kelly fraction at bar N uses realized P&L from earlier fills. Those fills depend on earlier sizes, so the simulation must update the trade history before computing the next size. ## Pattern 2: Conditional Chains (Pyramiding) @@ -131,7 +133,7 @@ class PyramidingStrategy(Strategy): self.pyramid_levels[asset] = level + 1 ``` -**Why vectorized fails**: Whether entry 2 happens depends on the unrealized P&L of entry 1, which depends on entry 1's fill price and size. The fill price includes slippage, which may depend on volume and order size. Each link in the chain is only knowable at execution time. +**Why precomputed entries are insufficient:** Whether entry 2 happens depends on the filled price and size of entry 1. Execution costs can change that fill, so the next decision must use the simulated position state. ## Pattern 3: Cross-Asset Coordination (Pairs Trading) @@ -206,7 +208,7 @@ class PairsTradingStrategy(Strategy): self.pair_status = "flat" ``` -**Why vectorized fails**: Position in A affects available capital for B. If A's order gets rejected (insufficient cash, margin limits), B shouldn't be entered either — the pair is meaningless as a single leg. Capital allocation across the two legs depends on execution outcomes. +**Why precomputed pair orders are insufficient:** The first leg's fill or rejection changes the capital available for the second. This example does not make the two orders atomic or cancel an unmatched leg. Inspect fills and rejected orders before treating the pair as established. ## Pattern 4: Path-Dependent Sizing (Drawdown Circuit Breaker) @@ -261,7 +263,7 @@ class DrawdownCircuitBreakerStrategy(Strategy): broker.close_position(asset) ``` -**Why vectorized fails**: The sizing multiplier at bar N depends on the drawdown from bars 0..N-1, but the equity at each prior bar depends on the sizing decisions made at those bars. The equity path and the sizing path are co-determined — you can't compute one without the other. +**Why precomputed sizes are insufficient:** The sizing multiplier at bar N depends on the preceding equity path, which depends on earlier sizing and fills. Compute each new size after updating equity. ## Pattern 5: Reactive Order Management (Grid Trading) @@ -339,7 +341,7 @@ class GridTradingStrategy(Strategy): self._place_grid(broker, price) ``` -**Why vectorized fails**: The entire order book is reactive — each fill changes the grid, which changes which orders exist, which changes future fills. The full state evolution requires sequential event processing. +**Why precomputed orders are insufficient:** Each fill changes which grid orders exist. A callback must update the outstanding orders after each observed fill. ## Combining Patterns diff --git a/examples/stateful_strategies.py b/examples/stateful_strategies.py index 12f6fa78..648d3f15 100644 --- a/examples/stateful_strategies.py +++ b/examples/stateful_strategies.py @@ -1,10 +1,10 @@ """Stateful strategy examples demonstrating why event-driven backtesting matters. -Each strategy here maintains state across bars — trading decisions feed back -into future decisions. This is fundamentally impossible in vectorized frameworks -like VectorBT, where all signals must be computed in advance. +Each strategy here maintains state across bars. A precomputed signal or order +array cannot respond to fills produced by the same simulation. Callback-based +simulators, including VectorBT order functions, can model this feedback. -Five reasons you need event-driven backtesting: +Five examples of state-dependent decisions: 1. **Feedback loops** — position size depends on realized P&L (AdaptiveKellySizing) 2. **Conditional chains** — entry N depends on P&L of entries 1..N-1 (Pyramiding) @@ -13,7 +13,7 @@ 5. **Reactive order management** — each fill triggers new orders (GridTrading) These are NOT part of the public API. They are importable demonstrations with -full test coverage in test_stateful_strategies.py. +behavioral tests in test_stateful_strategies.py. """ from __future__ import annotations @@ -38,8 +38,8 @@ class AdaptiveKellySizingStrategy(Strategy): """Position size adapts based on realized win rate and payoff ratio. The feedback loop: position_size → P&L → Kelly_fraction → next_position_size. - In a vectorized framework, you cannot compute the Kelly fraction because it - depends on future fills that depend on the fraction itself. + A precomputed size array cannot use realized P&L from fills produced later + in the same simulation. Kelly formula: f* = W - (1 - W) / R where W = win rate, R = avg_win / avg_loss @@ -229,8 +229,8 @@ class PairsTradingStrategy(Strategy): """Trade the spread between two correlated assets. Cross-asset coordination: the entry/exit of asset A is conditioned on the - price of asset B. You cannot vectorize this because position in A affects - available capital for B, and fills in A affect the timing of orders for B. + price of asset B. The first leg can affect capital available to the second. + Orders are submitted independently; this example does not ensure atomic fills. """ def __init__( @@ -341,7 +341,7 @@ class DrawdownCircuitBreakerStrategy(Strategy): Path-dependent feedback: equity_curve → drawdown → sizing_multiplier → future_equity_curve. The sizing multiplier at bar N depends on the entire equity path from bar 0 to bar N-1, which depends on all prior sizing - decisions. Impossible to vectorize. + decisions. Sizes must be updated after each simulated equity change. """ def __init__( diff --git a/examples/test_stateful_strategies.py b/examples/test_stateful_strategies.py index cc8d483d..7d801f35 100644 --- a/examples/test_stateful_strategies.py +++ b/examples/test_stateful_strategies.py @@ -1,7 +1,6 @@ """Tests for stateful strategy examples. -Each test group verifies the key stateful behavior that makes the strategy -impossible to implement in a vectorized framework. +Each test group verifies a state-dependent behavior of the example strategies. """ from __future__ import annotations From e7f341a6b3a67c76581388a01bddde0eeb78c40b Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 24 Sep 2026 11:08:10 -0400 Subject: [PATCH 2/8] docs: run account configuration example in documentation checks --- docs/user-guide/accounts.md | 44 +++++++++++++++++++--- validation/check_documentation_examples.py | 2 + 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/docs/user-guide/accounts.md b/docs/user-guide/accounts.md index 062d7488..3f2a48a3 100644 --- a/docs/user-guide/accounts.md +++ b/docs/user-guide/accounts.md @@ -111,19 +111,53 @@ Common margin configurations: ## Using Engine Directly -You can also pass account policy directly to `Engine`: +Pass the account policy to `Engine` through `BacktestConfig`. This complete example +submits a short sale on the first bar and checks its next-bar fill: + ```python -from ml4t.backtest import Engine, DataFeed +from datetime import datetime -engine = Engine( - feed=feed, - strategy=strategy, +import polars as pl +from ml4t.backtest import BacktestConfig, DataFeed, Engine, OrderSide, Strategy + + +class SellOnce(Strategy): + def __init__(self): + self.submitted = False + + def on_data(self, timestamp, data, context, broker): + if not self.submitted: + broker.submit_order("AAPL", 10, OrderSide.SELL) + self.submitted = True + + +prices = pl.DataFrame({ + "timestamp": [datetime(2024, 1, 2), datetime(2024, 1, 3)], + "asset": ["AAPL", "AAPL"], + "open": [100.0, 100.0], + "high": [100.0, 100.0], + "low": [100.0, 100.0], + "close": [100.0, 100.0], + "volume": [10_000.0, 10_000.0], +}) + +config = BacktestConfig( initial_cash=100_000, allow_short_selling=True, allow_leverage=True, initial_margin=0.5, ) +engine = Engine(feed=DataFeed(prices_df=prices), strategy=SellOnce(), config=config) +result = engine.run() + +assert [(fill.side.value, fill.quantity) for fill in result.fills] == [("sell", 10.0)] +print("short sale filled: 10 AAPL") +``` + + +```text +short sale filled: 10 AAPL ``` ## Using Broker.from_config() diff --git a/validation/check_documentation_examples.py b/validation/check_documentation_examples.py index 4f9f3ead..25333d6f 100644 --- a/validation/check_documentation_examples.py +++ b/validation/check_documentation_examples.py @@ -27,10 +27,12 @@ _ROOT / "docs" / "tutorials" / "risk-and-state.md", _ROOT / "docs" / "tutorials" / "profiles-and-parity.md", _ROOT / "docs" / "tutorials" / "results-and-analysis.md", + _ROOT / "docs" / "user-guide" / "accounts.md", _ROOT / "docs" / "user-guide" / "execution-semantics.md", ) _REQUIRED_EXAMPLES = frozenset( { + "account-engine-direct", "installation-import", "preopen-mixed-rules", "readme-quickstart", From 5e19cc3faf1eb576448af879c9ebcf590b7b6a27 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 24 Sep 2026 11:10:27 -0400 Subject: [PATCH 3/8] docs: validate gatekeeper example against public API --- docs/user-guide/accounts.md | 27 +++++++++++++++------- validation/check_documentation_examples.py | 1 + 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/user-guide/accounts.md b/docs/user-guide/accounts.md index 3f2a48a3..de06e5d1 100644 --- a/docs/user-guide/accounts.md +++ b/docs/user-guide/accounts.md @@ -12,7 +12,7 @@ The configuration is intentionally simple: instead of switching between account "types", you set the policy flags directly and let the broker enforce the resulting buying-power rules. -The short `Engine` and `Broker` snippets below assume a prepared feed and strategy. Run the linked accounts tutorial for complete inputs, orders, and result records. +Run the linked accounts tutorial for complete order and portfolio-state comparisons under each policy. ## Quick Example @@ -232,16 +232,27 @@ the retained comparison commands. ## Insufficient Funds -Orders that exceed available buying power are rejected by the gatekeeper. Use this -directly when you want to inspect why an order would fail: +Use `Gatekeeper` directly when you need to validate an order before execution. +It requires an account state, a commission model, and the expected fill price: + ```python -from ml4t.backtest.accounting import Gatekeeper +from ml4t.backtest import Order, OrderSide +from ml4t.backtest.accounting import AccountState, Gatekeeper, UnifiedAccountPolicy +from ml4t.backtest.models import NoCommission -gatekeeper = Gatekeeper(account_state, policy) -is_valid, reason = gatekeeper.validate_order(order) -if not is_valid: - print(f"Order rejected: {reason}") +account = AccountState(initial_cash=100_000, policy=UnifiedAccountPolicy()) +gatekeeper = Gatekeeper(account, NoCommission()) +order = Order(asset="AAPL", side=OrderSide.BUY, quantity=1_500) +is_valid, reason = gatekeeper.validate_order(order, price=100.0) + +assert not is_valid +print(reason) +``` + + +```text +Insufficient cash: need $150000.00, have $100000.00 ``` ## Migration from the beta `account_type` keyword diff --git a/validation/check_documentation_examples.py b/validation/check_documentation_examples.py index 25333d6f..cc4eceec 100644 --- a/validation/check_documentation_examples.py +++ b/validation/check_documentation_examples.py @@ -33,6 +33,7 @@ _REQUIRED_EXAMPLES = frozenset( { "account-engine-direct", + "account-gatekeeper", "installation-import", "preopen-mixed-rules", "readme-quickstart", From f6aa2b5ff6d6850e15041d793ac98afc0603d8a3 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 24 Sep 2026 11:14:50 -0400 Subject: [PATCH 4/8] docs: correct risk rule and limit API examples --- docs/user-guide/risk-management.md | 59 +++++++++++++++------- validation/check_documentation_examples.py | 9 ++++ 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/docs/user-guide/risk-management.md b/docs/user-guide/risk-management.md index 312aaac6..33522ffe 100644 --- a/docs/user-guide/risk-management.md +++ b/docs/user-guide/risk-management.md @@ -21,7 +21,7 @@ rule = StopLoss(pct=0.05) # Exit at -5% from entry - **Long positions**: triggers if `bar_low <= entry_price * (1 - pct)` - **Short positions**: triggers if `bar_high >= entry_price * (1 + pct)` -- Gap handling: if bar opens beyond stop, fills at open price +- With the default `STOP_PRICE` fill mode, a gap through the stop fills at the bar open; other modes use their configured fill rule ### TakeProfit @@ -64,14 +64,16 @@ rule = TimeExit(max_bars=20) # Exit after 20 bars ### VolatilityStop -Exit when loss exceeds N standard deviations of recent volatility: +Exit when price moves against a position by a multiple of Average True Range (ATR). +After a position exists, supply a positive `atr` value through +`broker.update_position_context(asset, {"atr": current_atr})`. Without it, the rule holds: + ```python from ml4t.backtest.risk.position import VolatilityStop rule = VolatilityStop( - n_std=2.0, # 2 standard deviations - lookback=20, # 20-bar window for volatility + multiplier=2.0, # Stop distance is twice the supplied ATR ) ``` @@ -79,11 +81,13 @@ rule = VolatilityStop( Trail that tightens as profit increases: + ```python from ml4t.backtest.risk.position import TighteningTrailingStop rule = TighteningTrailingStop( - thresholds=[ + schedule=[ + (0.00, 0.05), # Before +5% profit, trail 5% (0.05, 0.03), # At +5% profit, trail 3% (0.10, 0.02), # At +10% profit, trail 2% (0.20, 0.01), # At +20% profit, trail 1% @@ -95,17 +99,21 @@ rule = TighteningTrailingStop( Take partial profits at predefined levels: + ```python from ml4t.backtest.risk.position import ScaledExit rule = ScaledExit( - levels=[ - (0.10, 0.5), # At +10%, exit 50% of position - (0.20, 0.5), # At +20%, exit remaining 50% + targets=[ + (0.10, 0.5), # At +10%, exit 50% of current position + (0.20, 1.0), # At +20%, exit the remaining position ], ) ``` +`ScaledExit` tracks triggered targets on the rule instance. Use a separate instance for each +position or reset it when a position closes. + ### SignalExit Exit based on a signal value in the position's context: @@ -139,9 +147,9 @@ Exit only when multiple conditions are true simultaneously: ```python from ml4t.backtest.risk.position import AllOf, TakeProfit, TimeExit -# Only exit if profitable AND held long enough +# Exit at or above breakeven after at least five bars rule = AllOf([ - TakeProfit(pct=0.0), # Must be profitable + TakeProfit(pct=0.0), # At or above breakeven TimeExit(max_bars=5), # Must have held 5+ bars ]) ``` @@ -166,7 +174,7 @@ Combine composition patterns for complex logic: ```python rules = RuleChain([ StopLoss(pct=0.08), # Hard stop always applies - AllOf([TakeProfit(pct=0.0), TimeExit(max_bars=5)]), # Profitable + held 5 bars + AllOf([TakeProfit(pct=0.0), TimeExit(max_bars=5)]), # Breakeven + held 5 bars TrailingStop(pct=0.03), # Trail from peak TimeExit(max_bars=60), # Max hold 60 bars ]) @@ -232,36 +240,43 @@ limit = MaxPositionsLimit(max_positions=10) ### MaxExposureLimit + ```python from ml4t.backtest.risk.portfolio.limits import MaxExposureLimit -limit = MaxExposureLimit(max_exposure=2.0) # Max 200% gross exposure +limit = MaxExposureLimit(max_exposure_pct=0.10) # Warn above 10% in one asset ``` ### DailyLossLimit + ```python from ml4t.backtest.risk.portfolio.limits import DailyLossLimit -limit = DailyLossLimit(max_daily_loss=0.03) # Liquidate at -3% daily loss +limit = DailyLossLimit(max_daily_loss_pct=0.03) # Liquidate above 3% daily loss ``` ### GrossExposureLimit / NetExposureLimit + ```python from ml4t.backtest.risk.portfolio.limits import GrossExposureLimit, NetExposureLimit -gross = GrossExposureLimit(max_gross=1.5) # Max 150% gross -net = NetExposureLimit(min_net=-0.2, max_net=1.2) # Net between -20% and 120% +gross = GrossExposureLimit(max_gross_exposure=1.5) # Halt above 150% gross +net = NetExposureLimit(min_net_exposure=-0.2, max_net_exposure=1.2) # Warn outside range ``` ### VaRLimit / CVaRLimit +These checks require at least `lookback_days` of portfolio returns in the risk manager's +`context["historical_returns"]`. Without that input they report no breach. + + ```python from ml4t.backtest.risk.portfolio.limits import VaRLimit, CVaRLimit -var_limit = VaRLimit(max_var=0.05, confidence=0.95) -cvar_limit = CVaRLimit(max_cvar=0.08, confidence=0.95) +var_limit = VaRLimit(threshold=0.05, confidence_level=0.95) +cvar_limit = CVaRLimit(threshold=0.08, confidence_level=0.95) ``` ### BetaLimit @@ -274,11 +289,17 @@ beta_limit = BetaLimit(max_beta=1.5) ### SectorExposureLimit / FactorExposureLimit +Sector checks require an asset-to-sector mapping in `context["asset_sectors"]`. +Factor checks require an asset-to-loading mapping in `context["factor_loadings"]`; +the example names that factor momentum. Pass these mappings as the `context` argument to +`RiskManager.update(...)`. Without them the checks report no breach. + + ```python from ml4t.backtest.risk.portfolio.limits import SectorExposureLimit, FactorExposureLimit -sector = SectorExposureLimit(max_sector_weight=0.30) -factor = FactorExposureLimit(max_factor_exposure=0.50) +sector = SectorExposureLimit(max_sector_exposure=0.30) +factor = FactorExposureLimit(factor_name="momentum", max_exposure=0.50) ``` ## Limit Actions diff --git a/validation/check_documentation_examples.py b/validation/check_documentation_examples.py index cc4eceec..6aefb414 100644 --- a/validation/check_documentation_examples.py +++ b/validation/check_documentation_examples.py @@ -29,6 +29,7 @@ _ROOT / "docs" / "tutorials" / "results-and-analysis.md", _ROOT / "docs" / "user-guide" / "accounts.md", _ROOT / "docs" / "user-guide" / "execution-semantics.md", + _ROOT / "docs" / "user-guide" / "risk-management.md", ) _REQUIRED_EXAMPLES = frozenset( { @@ -37,6 +38,14 @@ "installation-import", "preopen-mixed-rules", "readme-quickstart", + "risk-volatility-stop", + "risk-tightening-trailing-stop", + "risk-scaled-exit", + "risk-max-exposure", + "risk-daily-loss", + "risk-gross-net", + "risk-var-cvar", + "risk-sector-factor", "home-example", "home-convenience", "quickstart-minimal", From b2e371828b23c14abc69db9fd19403aa5db9315c Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 24 Sep 2026 11:17:31 -0400 Subject: [PATCH 5/8] ci: check guide API calls against installed package signatures --- .../contracts/test_documentation_examples.py | 12 +++++ validation/check_documentation_examples.py | 54 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/tests/contracts/test_documentation_examples.py b/tests/contracts/test_documentation_examples.py index 8f35e931..d8d23712 100644 --- a/tests/contracts/test_documentation_examples.py +++ b/tests/contracts/test_documentation_examples.py @@ -82,3 +82,15 @@ def test_missing_tutorial_input_fails_real_example(tmp_path: Path) -> None: checker = _load_checker() with pytest.raises(RuntimeError, match="Unknown example category"): checker.run_examples(checker.collect_examples([page])) + + +def test_invalid_user_guide_api_keyword_fails(tmp_path: Path) -> None: + page = tmp_path / "invalid-guide.md" + page.write_text( + "```python\nfrom ml4t.backtest import Engine\nEngine(initial_cash=100_000)\n```\n", + encoding="utf-8", + ) + checker = _load_checker() + + with pytest.raises(ValueError, match="unexpected keyword argument 'initial_cash'"): + checker.check_public_api_calls([page]) diff --git a/validation/check_documentation_examples.py b/validation/check_documentation_examples.py index 6aefb414..e321e8b4 100644 --- a/validation/check_documentation_examples.py +++ b/validation/check_documentation_examples.py @@ -3,7 +3,10 @@ from __future__ import annotations import argparse +import ast import difflib +import importlib +import inspect import os import re import subprocess @@ -31,6 +34,10 @@ _ROOT / "docs" / "user-guide" / "execution-semantics.md", _ROOT / "docs" / "user-guide" / "risk-management.md", ) +_API_AUDIT_PATHS = ( + _ROOT / "docs" / "index.md", + *sorted((_ROOT / "docs" / "user-guide").glob("*.md")), +) _REQUIRED_EXAMPLES = frozenset( { "account-engine-direct", @@ -73,6 +80,7 @@ r"```(?Ppython|bash)\n(?P.*?)\n```", flags=re.DOTALL, ) +_PYTHON_BLOCK = re.compile(r"^```python\n(?P.*?)^```", flags=re.MULTILINE | re.DOTALL) _OUTPUT = re.compile( r"\s*" r"```text\n(?P.*?)\n```", @@ -131,6 +139,49 @@ def collect_examples( return examples +def check_public_api_calls(paths: list[Path] | tuple[Path, ...]) -> int: + """Bind direct calls imported in each Python block to the installed package signatures.""" + checked = 0 + for path in paths: + content = path.read_text(encoding="utf-8") + for block in _PYTHON_BLOCK.finditer(content): + line = content.count("\n", 0, block.start()) + 1 + tree = ast.parse(block["code"], filename=f"{path}:{line}") + imports: dict[str, object] = {} + for node in tree.body: + if not isinstance(node, ast.ImportFrom): + continue + if node.module is None or not node.module.startswith("ml4t.backtest"): + continue + module = importlib.import_module(node.module) + for alias in node.names: + imports[alias.asname or alias.name] = getattr(module, alias.name) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + continue + target = imports.get(node.func.id) + if not callable(target): + continue + if any(isinstance(arg, ast.Starred) for arg in node.args): + continue + if any(keyword.arg is None for keyword in node.keywords): + continue + try: + signature = inspect.signature(target) + except (TypeError, ValueError): + continue + args = [object() for _ in node.args] + kwargs = { + keyword.arg: object() for keyword in node.keywords if keyword.arg is not None + } + try: + signature.bind_partial(*args, **kwargs) + except TypeError as exc: + raise ValueError(f"{path}:{line + node.lineno}: {node.func.id}: {exc}") from exc + checked += 1 + return checked + + def run_examples(examples: list[Example]) -> None: """Run each example outside the source checkout import path.""" with tempfile.TemporaryDirectory(prefix="ml4t-backtest-docs-") as directory: @@ -193,6 +244,9 @@ def main() -> int: parser.add_argument("paths", nargs="*", type=Path) arguments = parser.parse_args() paths = arguments.paths or list(_DEFAULT_PATHS) + audit_paths = arguments.paths or list(_API_AUDIT_PATHS) + checked = check_public_api_calls(audit_paths) + print(f"Checked {checked} direct public API calls in documentation") run_examples(collect_examples(paths, require_all=not arguments.paths)) return 0 From fb8addf6b84c306c51fdbec9d6564e7c11c38af1 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 24 Sep 2026 11:20:02 -0400 Subject: [PATCH 6/8] ci: audit guide imports across consecutive code blocks --- tests/contracts/test_documentation_examples.py | 3 ++- validation/check_documentation_examples.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/contracts/test_documentation_examples.py b/tests/contracts/test_documentation_examples.py index d8d23712..71bdc3af 100644 --- a/tests/contracts/test_documentation_examples.py +++ b/tests/contracts/test_documentation_examples.py @@ -87,7 +87,8 @@ def test_missing_tutorial_input_fails_real_example(tmp_path: Path) -> None: def test_invalid_user_guide_api_keyword_fails(tmp_path: Path) -> None: page = tmp_path / "invalid-guide.md" page.write_text( - "```python\nfrom ml4t.backtest import Engine\nEngine(initial_cash=100_000)\n```\n", + "```python\nfrom ml4t.backtest import Engine\n```\n\n" + "```python\nEngine(initial_cash=100_000)\n```\n", encoding="utf-8", ) checker = _load_checker() diff --git a/validation/check_documentation_examples.py b/validation/check_documentation_examples.py index e321e8b4..96eff290 100644 --- a/validation/check_documentation_examples.py +++ b/validation/check_documentation_examples.py @@ -144,10 +144,10 @@ def check_public_api_calls(paths: list[Path] | tuple[Path, ...]) -> int: checked = 0 for path in paths: content = path.read_text(encoding="utf-8") + imports: dict[str, object] = {} for block in _PYTHON_BLOCK.finditer(content): line = content.count("\n", 0, block.start()) + 1 tree = ast.parse(block["code"], filename=f"{path}:{line}") - imports: dict[str, object] = {} for node in tree.body: if not isinstance(node, ast.ImportFrom): continue From eb9ae79d8637e9d307aa25275235987e6e090245 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 24 Sep 2026 11:24:46 -0400 Subject: [PATCH 7/8] chore: record documented accounting API in compatibility snapshot --- tests/compatibility/snapshots/v0.1.json | 109 +++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/tests/compatibility/snapshots/v0.1.json b/tests/compatibility/snapshots/v0.1.json index ec651837..a9060759 100644 --- a/tests/compatibility/snapshots/v0.1.json +++ b/tests/compatibility/snapshots/v0.1.json @@ -596,10 +596,10 @@ "from ml4t.backtest import Broker, BacktestConfig", "from ml4t.backtest import DataFeed", "from ml4t.backtest import Engine", - "from ml4t.backtest import Engine, DataFeed", "from ml4t.backtest import Engine, Strategy, BacktestConfig, DataFeed", "from ml4t.backtest import Engine, Strategy, DataFeed, BacktestConfig, run_backtest", "from ml4t.backtest import ExecutionMode, StopFillMode", + "from ml4t.backtest import Order, OrderSide", "from ml4t.backtest import RebalanceConfig, RebalanceSchedule, Strategy, TargetWeightExecutor", "from ml4t.backtest import RebalanceConfig, TargetWeightExecutor", "from ml4t.backtest import RebalanceSchedule", @@ -613,7 +613,7 @@ "from ml4t.backtest import TakeProfit", "from ml4t.backtest import TrailingStop", "from ml4t.backtest import run_backtest", - "from ml4t.backtest.accounting import Gatekeeper", + "from ml4t.backtest.accounting import AccountState, Gatekeeper, UnifiedAccountPolicy", "from ml4t.backtest.analytics.bridge import to_trade_records", "from ml4t.backtest.config import BacktestConfig", "from ml4t.backtest.config import CommissionType", @@ -634,6 +634,7 @@ "from ml4t.backtest.execution import SquareRootImpact", "from ml4t.backtest.execution import VolumeParticipationLimit", "from ml4t.backtest.execution.rebalancer import TargetWeightExecutor, RebalanceConfig", + "from ml4t.backtest.models import NoCommission", "from ml4t.backtest.models import TieredCommission, CombinedCommission", "from ml4t.backtest.profiles import list_profiles", "from ml4t.backtest.result import BacktestResult", @@ -2050,6 +2051,54 @@ ], "snapshot_schema_version": 1, "symbols": { + "ml4t.backtest.accounting:AccountState": { + "kind": "class", + "members": { + "__repr__": { + "kind": "method", + "signature": "(self) -> str" + }, + "add_settlement_hold": { + "kind": "method", + "signature": "(self, bar_index: int, delay: int, amount: float) -> None" + }, + "allows_short_selling": { + "kind": "method", + "signature": "(self) -> bool" + }, + "buying_power": { + "kind": "property", + "signature": "(self) -> float" + }, + "get_position": { + "kind": "method", + "signature": "(self, asset: str) -> ml4t.backtest.types.Position | None" + }, + "get_position_quantity": { + "kind": "method", + "signature": "(self, asset: str) -> float" + }, + "mark_to_market": { + "kind": "method", + "signature": "(self, current_prices: dict[str, float]) -> None" + }, + "release_settled": { + "kind": "method", + "signature": "(self, current_bar: int) -> None" + }, + "total_equity": { + "kind": "property", + "signature": "(self) -> float" + }, + "unsettled_cash": { + "kind": "property", + "signature": "(self) -> float" + } + }, + "module": "ml4t.backtest.accounting.account", + "qualname": "AccountState", + "signature": "(initial_cash: float, policy: ml4t.backtest.accounting.policy.AccountPolicy)" + }, "ml4t.backtest.accounting:Gatekeeper": { "kind": "class", "members": { @@ -2070,6 +2119,50 @@ "qualname": "Gatekeeper", "signature": "(account: ml4t.backtest.accounting.account.AccountState, commission_model: ml4t.backtest.models.CommissionModel, cash_buffer_pct: float = 0.0, settlement_reduces_buying_power: bool = True, multiplier_resolver: collections.abc.Callable[[str], float] | None = None)" }, + "ml4t.backtest.accounting:UnifiedAccountPolicy": { + "kind": "class", + "members": { + "allows_short_selling": { + "kind": "method", + "signature": "(self) -> 'bool'" + }, + "calculate_buying_power": { + "kind": "method", + "signature": "(self, cash: 'float', positions: 'dict[str, Position]') -> 'float'" + }, + "from_config": { + "kind": "method", + "signature": "(cls, config: 'BacktestConfig') -> 'UnifiedAccountPolicy'" + }, + "get_margin_requirement": { + "kind": "method", + "signature": "(self, asset: 'str', quantity: 'float', price: 'float', for_initial: 'bool' = True, *, multiplier: 'float' = 1.0) -> 'float'" + }, + "get_spendable_cash": { + "kind": "method", + "signature": "(self, cash: 'float', positions: 'dict[str, Position]') -> 'float'" + }, + "handle_reversal": { + "kind": "method", + "signature": "(self, asset: 'str', current_quantity: 'float', order_quantity_delta: 'float', price: 'float', current_positions: 'dict[str, Position]', cash: 'float', commission: 'float', *, multiplier: 'float' = 1.0) -> 'tuple[bool, str]'" + }, + "is_margin_call": { + "kind": "method", + "signature": "(self, cash: 'float', positions: 'dict[str, Position]') -> 'bool'" + }, + "validate_new_position": { + "kind": "method", + "signature": "(self, asset: 'str', quantity: 'float', price: 'float', current_positions: 'dict[str, Position]', cash: 'float', *, multiplier: 'float' = 1.0) -> 'tuple[bool, str]'" + }, + "validate_position_change": { + "kind": "method", + "signature": "(self, asset: 'str', current_quantity: 'float', quantity_delta: 'float', price: 'float', current_positions: 'dict[str, Position]', cash: 'float', *, multiplier: 'float' = 1.0) -> 'tuple[bool, str]'" + } + }, + "module": "ml4t.backtest.accounting.policy", + "qualname": "UnifiedAccountPolicy", + "signature": "(allow_short_selling: 'bool' = False, allow_leverage: 'bool' = False, initial_margin: 'float' = 0.5, long_maintenance_margin: 'float' = 0.25, short_maintenance_margin: 'float' = 0.3, 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'" + }, "ml4t.backtest.analytics.bridge:to_trade_records": { "kind": "function", "module": "ml4t.backtest.analytics.bridge", @@ -2806,6 +2899,18 @@ "qualname": "CombinedCommission", "signature": "(percentage: float = 0.0, fixed: float = 0.0)" }, + "ml4t.backtest.models:NoCommission": { + "kind": "class", + "members": { + "calculate": { + "kind": "method", + "signature": "(self, asset: str, quantity: float, price: float) -> float" + } + }, + "module": "ml4t.backtest.models", + "qualname": "NoCommission", + "signature": "()" + }, "ml4t.backtest.models:TieredCommission": { "kind": "class", "members": { From 679465bf9fa12ad1aefcd3ca80e94e2162dfcfb6 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 24 Sep 2026 11:33:34 -0400 Subject: [PATCH 8/8] docs: state market impact formulas in price units --- docs/user-guide/market-impact.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/user-guide/market-impact.md b/docs/user-guide/market-impact.md index 147861a3..be7a278c 100644 --- a/docs/user-guide/market-impact.md +++ b/docs/user-guide/market-impact.md @@ -49,7 +49,7 @@ config = BacktestConfig( ) ``` -`PER_CONTRACT` is an alias for `PER_SHARE` — same math, clearer intent for futures. +`PER_CONTRACT` is an alias for `PER_SHARE` - same math, clearer intent for futures. ### Custom Models @@ -86,7 +86,7 @@ charged separately when they execute. ## Slippage Models -Slippage models the bid-ask spread you cross when executing. A buy order fills slightly above the mid-price; a sell order fills slightly below. +Slippage adjusts the configured execution price in the adverse direction. Use the spread model for a bar-only estimate of bid-ask crossing, or a percentage or fixed amount for other execution drag. ### Percentage (Default) @@ -145,7 +145,7 @@ synthetic spread slippage disabled unless you explicitly want additional impact. ## Market Impact Models -Market impact captures the price movement caused by your order itself — large orders move the market. This is the most important cost for institutional-size strategies. +Market impact models add an adverse price adjustment that depends on order size relative to reported volume. Calibrate the model parameters for the market and bar frequency you simulate. Import from `ml4t.backtest.execution`: @@ -164,9 +164,10 @@ engine = Engine(feed, strategy, config) Price impact proportional to order size relative to bar volume: -$$\text{impact} = \eta \times \frac{Q}{V}$$ +$$\Delta P = P \times \eta \times \frac{Q}{V}$$ -where $Q$ = order quantity, $V$ = bar volume, $\eta$ = impact coefficient. +Here $P$ is the reference price, $Q$ is order quantity, $V$ is the bar volume, and +$\eta$ is the configured coefficient. $\Delta P$ is added for buys and subtracted for sells. ```python from ml4t.backtest.execution import LinearImpact @@ -188,11 +189,14 @@ answered wrongly. Model persistence outside the engine if you need it. ### Square-Root Impact -The standard institutional model — impact scales with the square root of participation rate: +This model scales the price adjustment with the square root of estimated daily-volume participation: -$$\text{impact} = \eta \times \sigma \times \sqrt{\frac{Q}{V}}$$ +$$\Delta P = P \times \eta \times \sigma \times \sqrt{\frac{Q}{V \times a}}$$ -where $\sigma$ = daily volatility, $\eta$ = impact coefficient. +Here $\sigma$ is the model's configured daily volatility and $a$ is +`adv_factor`, the configured multiplier that converts bar volume into an +estimated average daily volume. The defaults are $\sigma=0.02$ and $a=1.0$. +The model does not estimate either value from the feed. ```python from ml4t.backtest.execution import SquareRootImpact @@ -203,7 +207,9 @@ engine = Engine( ) ``` -Square-root impact is the empirical consensus for equity markets (Almgren-Chriss, Barra). +Both impact models return zero adjustment when bar volume is missing or zero. +Calibrate their coefficients against observed execution costs before using them +for performance estimates. ### Volume Participation Limits @@ -293,6 +299,6 @@ Chapter 18, Section 18.4, [Market impact calibration](https://github.com/stefan- ## Next Steps - [Book Guide](../book-guide/index.md) -- where cost realism and quote-aware execution appear in the book -- [Execution Semantics](execution-semantics.md) — fill timing, ordering, and stop modes -- [Configuration](configuration.md) — all commission and slippage parameters -- [Rebalancing](rebalancing.md) — how costs interact with weight-based rebalancing +- [Execution Semantics](execution-semantics.md) - fill timing, ordering, and stop modes +- [Configuration](configuration.md) - all commission and slippage parameters +- [Rebalancing](rebalancing.md) - how costs interact with weight-based rebalancing