Skip to content
71 changes: 58 additions & 13 deletions docs/user-guide/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:

<!-- ml4t-doc-test: account-engine-direct -->
```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")
```

<!-- ml4t-doc-output: account-engine-direct -->
```text
short sale filled: 10 AAPL
```

## Using Broker.from_config()
Expand Down Expand Up @@ -198,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:

<!-- ml4t-doc-test: account-gatekeeper -->
```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

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)
```

gatekeeper = Gatekeeper(account_state, policy)
is_valid, reason = gatekeeper.validate_order(order)
if not is_valid:
print(f"Order rejected: {reason}")
<!-- ml4t-doc-output: account-gatekeeper -->
```text
Insufficient cash: need $150000.00, have $100000.00
```

## Migration from the beta `account_type` keyword
Expand Down
2 changes: 1 addition & 1 deletion docs/user-guide/execution-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 18 additions & 12 deletions docs/user-guide/market-impact.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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`:

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

Expand Down Expand Up @@ -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
59 changes: 40 additions & 19 deletions docs/user-guide/risk-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -64,26 +64,30 @@ 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:

<!-- ml4t-doc-test: risk-volatility-stop -->
```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
)
```

### TighteningTrailingStop

Trail that tightens as profit increases:

<!-- ml4t-doc-test: risk-tightening-trailing-stop -->
```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%
Expand All @@ -95,17 +99,21 @@ rule = TighteningTrailingStop(

Take partial profits at predefined levels:

<!-- ml4t-doc-test: risk-scaled-exit -->
```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:
Expand Down Expand Up @@ -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
])
```
Expand All @@ -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
])
Expand Down Expand Up @@ -232,36 +240,43 @@ limit = MaxPositionsLimit(max_positions=10)

### MaxExposureLimit

<!-- ml4t-doc-test: risk-max-exposure -->
```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

<!-- ml4t-doc-test: risk-daily-loss -->
```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

<!-- ml4t-doc-test: risk-gross-net -->
```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.

<!-- ml4t-doc-test: risk-var-cvar -->
```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
Expand All @@ -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.

<!-- ml4t-doc-test: risk-sector-factor -->
```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
Expand Down
Loading
Loading