Skip to content

Commit eb59e7c

Browse files
authored
Merge pull request #560 from coding-kitties/dev
Release: pluggable slippage models, index enhancements & evaluation focus
2 parents 1664827 + 74530a2 commit eb59e7c

13 files changed

Lines changed: 831 additions & 20 deletions

File tree

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,60 @@ backtests = [store.open(row["bundle_path"]) for row in top]
343343
BacktestReport(backtests=backtests).save("top20.html")
344344
```
345345

346+
#### Weighted multi-metric ranking with `BacktestEvaluationFocus`
347+
348+
Instead of sorting by a single column, use a **focus preset** to score every bundle across multiple metrics at once — profit, risk, consistency, win rate — weighted by what matters most to your workflow:
349+
350+
```python
351+
from investing_algorithm_framework import (
352+
BacktestReport, BacktestEvaluationFocus,
353+
)
354+
from investing_algorithm_framework.cli.index_command import (
355+
build_index, rank_index,
356+
)
357+
from investing_algorithm_framework.services.backtest_store import (
358+
LocalDirStore,
359+
)
360+
361+
# 1. Build (or refresh) the Tier-1 SQLite index.
362+
build_index("./my-backtests/")
363+
364+
# 2. Rank with a built-in focus preset (BALANCED, PROFIT, FREQUENCY, RISK_ADJUSTED).
365+
top = rank_index(
366+
"./my-backtests/",
367+
focus=BacktestEvaluationFocus.RISK_ADJUSTED,
368+
where="summary_number_of_trades > 50",
369+
limit=25,
370+
)
371+
372+
# 3. Or supply fully custom weights — positive favours higher, negative penalises.
373+
top = rank_index(
374+
"./my-backtests/",
375+
weights={
376+
"sharpe_ratio": 3.0,
377+
"sortino_ratio": 2.5,
378+
"max_drawdown": -3.0,
379+
"win_rate": 2.0,
380+
"consistency_score": 1.5,
381+
},
382+
limit=25,
383+
)
384+
385+
# 4. Materialise only the winners and render a focused dashboard.
386+
store = LocalDirStore("./my-backtests/")
387+
winners = [store.open(row["bundle_path"]) for row in top]
388+
BacktestReport(backtests=winners).save("top25_risk_adjusted.html")
389+
```
390+
391+
**Built-in focus presets:**
392+
393+
| Preset | Prioritises |
394+
|--------|------------|
395+
| `BALANCED` | Equal mix of profit, risk-adjusted returns, drawdown penalties, and consistency |
396+
| `PROFIT` | Absolute and relative gains (CAGR, net gain, win rate, profit factor) |
397+
| `FREQUENCY` | High trade count, short durations, and per-trade efficiency |
398+
| `RISK_ADJUSTED` | Sharpe, Sortino, Calmar with strong drawdown and volatility penalties |
399+
346400
Or from the shell:
347401

348402
```bash

docusaurus/docs/Advanced Concepts/blotter.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,37 @@ app.set_blotter(SimulationBlotter(
7575
))
7676
```
7777

78+
### VolumeShareSlippage
79+
80+
Volume-aware model with quadratic price impact. Limits fills to a fraction of bar volume, producing partial fills for large orders relative to liquidity.
81+
82+
```python
83+
from investing_algorithm_framework import SimulationBlotter, VolumeShareSlippage
84+
85+
app.set_blotter(SimulationBlotter(
86+
slippage_model=VolumeShareSlippage(
87+
volume_limit=0.025, # max 2.5% of bar volume
88+
price_impact=0.1, # quadratic impact coefficient
89+
)
90+
))
91+
```
92+
93+
### FixedBasisPointsSlippage
94+
95+
Slippage expressed in basis points (1 bp = 0.01 % of price).
96+
97+
```python
98+
from investing_algorithm_framework import SimulationBlotter, FixedBasisPointsSlippage
99+
100+
app.set_blotter(SimulationBlotter(
101+
slippage_model=FixedBasisPointsSlippage(basis_points=5) # 5 bps = 0.05%
102+
))
103+
```
104+
105+
:::tip TradingCost Integration
106+
Slippage models can also be attached directly to a `TradingCost` via the `slippage_model` parameter, without needing a custom blotter. See [TradingCost — Slippage Models](../Risk%20Rules/trading-cost.md#slippage-models) for details.
107+
:::
108+
78109
### Custom Slippage Model
79110

80111
Create your own by extending `SlippageModel`:

docusaurus/docs/Risk Rules/overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class MyStrategy(TradingStrategy):
6666
| `take_profits` | [`TakeProfitRule`](./take-profit-rule.md) | Bar-end exit when price rises a fixed or trailing percentage from entry / peak. |
6767
| `scaling_rules` | [`ScalingRule`](./scaling-rule.md) | Pyramid into winners and partially close — `max_entries`, `scale_in_percentage`, `scale_out_percentage`, optional `max_position_percentage` cap. |
6868
| `cooldowns` | [`CooldownRule`](./cooldown-rule.md) | Side-aware, per-symbol or portfolio-wide signal throttling after fills. |
69-
| `trading_costs` | [`TradingCost`](./trading-cost.md) | Per-symbol fees and slippage applied during fill simulation. |
69+
| `trading_costs` | [`TradingCost`](./trading-cost.md) | Per-symbol fees and slippage applied during fill simulation. Supports [pluggable slippage models](./trading-cost.md#slippage-models) (volume-based, fixed spread, basis points). |
7070

7171
## Where Rules Are Enforced
7272

docusaurus/docs/Risk Rules/trading-cost.md

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,17 @@ TradingCost(
1818
fee_percentage: float = 0.0,
1919
slippage_percentage: float = 0.0,
2020
fee_fixed: float = 0.0,
21+
slippage_model: SlippageModel | None = None,
2122
)
2223
```
2324

2425
| Parameter | Type | Default | Description |
2526
|---|---|---|---|
2627
| `symbol` | `str \| None` | `None` | Target symbol (e.g. `"BTC"`). `None` means "market default" when used at the portfolio level. Symbol matching is case-insensitive. |
2728
| `fee_percentage` | `float` | `0.0` | Variable fee in **percent of trade value** (e.g. `0.1` = 0.1 %). |
28-
| `slippage_percentage` | `float` | `0.0` | Slippage in **percent of price**. Buys fill higher, sells fill lower. |
29+
| `slippage_percentage` | `float` | `0.0` | Slippage in **percent of price**. Buys fill higher, sells fill lower. Ignored when `slippage_model` is set. |
2930
| `fee_fixed` | `float` | `0.0` | Flat fee per trade in the trading currency, added on top of `fee_percentage`. |
31+
| `slippage_model` | `SlippageModel \| None` | `None` | Pluggable slippage model. When set, **overrides** `slippage_percentage`. See [Slippage Models](#slippage-models) below. |
3032

3133
## How Costs Are Applied
3234

@@ -39,6 +41,8 @@ sell_fill_price = price * (1 - slippage_percentage / 100)
3941
fee = trade_value * fee_percentage / 100 + fee_fixed
4042
```
4143

44+
When a `slippage_model` is set, the model's `calculate_slippage()` method replaces the percentage formula above. The fee calculation stays the same.
45+
4246
`trade_value` is computed at the slippage-adjusted price, so fees compound on top of slippage — matching how real exchanges quote post-trade cost.
4347

4448
## Resolution Order
@@ -118,8 +122,137 @@ PortfolioConfiguration(
118122
- **`StopLossRule` / `TakeProfitRule`** — slippage is applied to the exit fill (`sell` direction), so reported PnL already reflects realistic exits.
119123
- **`ScalingRule`** — every scale-in and scale-out is a separate fill and pays fees independently.
120124

125+
## Slippage Models
126+
127+
The `slippage_model` parameter lets you plug in sophisticated slippage behavior that goes beyond a flat percentage. When set, it **overrides** the `slippage_percentage` field.
128+
129+
```python
130+
from investing_algorithm_framework import (
131+
TradingCost,
132+
VolumeShareSlippage,
133+
FixedSlippage,
134+
FixedBasisPointsSlippage,
135+
)
136+
```
137+
138+
### VolumeShareSlippage
139+
140+
Models slippage as a function of the order's share of bar volume with a **quadratic** price impact. Also enforces a volume limit — at most `volume_limit` fraction of a bar's volume can be filled per bar. Orders exceeding this limit are partially filled and re-evaluated on subsequent bars.
141+
142+
```python
143+
TradingCost(
144+
symbol="BTC",
145+
fee_percentage=0.1,
146+
slippage_model=VolumeShareSlippage(
147+
volume_limit=0.025, # max 2.5% of bar volume
148+
price_impact=0.1, # price impact coefficient
149+
),
150+
)
151+
```
152+
153+
| Parameter | Default | Description |
154+
|---|---|---|
155+
| `volume_limit` | `0.025` | Max fraction of bar volume that can fill per bar (0.025 = 2.5 %). |
156+
| `price_impact` | `0.1` | Coefficient for quadratic impact: `impact = price_impact × (amount / volume)²`. |
157+
158+
**Impact formula:**
159+
160+
```text
161+
participation = amount / volume
162+
impact = price_impact * participation²
163+
164+
buy_fill_price = price * (1 + impact)
165+
sell_fill_price = price * (1 - impact)
166+
```
167+
168+
This is the most realistic built-in model — strategies that trade illiquid assets or large positions relative to volume will see significant market impact, and orders larger than the volume limit will be partially filled.
169+
170+
### FixedSlippage
171+
172+
Adds or subtracts a fixed amount from the order price. Useful for markets with a known, relatively stable spread.
173+
174+
```python
175+
TradingCost(
176+
symbol="ETH",
177+
fee_percentage=0.1,
178+
slippage_model=FixedSlippage(amount=0.50), # fixed $0.50 spread
179+
)
180+
```
181+
182+
| Parameter | Default | Description |
183+
|---|---|---|
184+
| `amount` | `0.01` | Fixed slippage in price units. |
185+
186+
### FixedBasisPointsSlippage
187+
188+
Slippage expressed in basis points (1 bp = 0.01 % of price). Convenient when you want a proportional slippage without thinking in decimals.
189+
190+
```python
191+
TradingCost(
192+
symbol="BTC",
193+
fee_percentage=0.1,
194+
slippage_model=FixedBasisPointsSlippage(basis_points=5), # 5 bps = 0.05%
195+
)
196+
```
197+
198+
| Parameter | Default | Description |
199+
|---|---|---|
200+
| `basis_points` | `5` | Slippage in basis points. |
201+
202+
### Custom Slippage Model
203+
204+
Create your own by extending `SlippageModel`:
205+
206+
```python
207+
from investing_algorithm_framework import SlippageModel
208+
209+
class MySlippageModel(SlippageModel):
210+
def __init__(self, price_impact=0.1, volume_limit=0.025):
211+
self.price_impact = price_impact
212+
self.volume_limit = volume_limit
213+
214+
def calculate_slippage(self, price, order_side, amount=None, volume=None):
215+
"""Return adjusted fill price."""
216+
if amount and volume and volume > 0:
217+
impact = self.price_impact * (amount / volume) ** 2
218+
else:
219+
impact = 0.0
220+
221+
if order_side == "BUY":
222+
return price * (1 + impact)
223+
return price * (1 - impact)
224+
225+
def max_fill_amount(self, order_amount, volume=None):
226+
"""Return maximum fillable amount for this bar."""
227+
if volume and volume > 0:
228+
return min(order_amount, volume * self.volume_limit)
229+
return order_amount
230+
```
231+
232+
The two methods you can override:
233+
234+
| Method | Required | Description |
235+
|---|---|---|
236+
| `calculate_slippage(price, order_side, amount, volume)` | Yes | Return the adjusted fill price after slippage. |
237+
| `max_fill_amount(order_amount, volume)` | No | Return the maximum fillable amount per bar. Default returns the full `order_amount` (no volume limit). |
238+
239+
### Choosing a Slippage Approach
240+
241+
| Approach | When to use |
242+
|---|---|
243+
| `slippage_percentage` | Quick approximation, don't need volume awareness. |
244+
| `FixedSlippage` | Known fixed spread (e.g. a specific venue). |
245+
| `FixedBasisPointsSlippage` | Proportional slippage in familiar units (basis points). |
246+
| `VolumeShareSlippage` | Realistic simulation — large orders impact price, fills are volume-limited. |
247+
| Custom `SlippageModel` | Any other behavior (e.g. time-of-day effects, asymmetric slippage). |
248+
249+
:::tip Backward Compatibility
250+
Setting `slippage_model` is fully optional. Existing strategies using `slippage_percentage` continue to work unchanged.
251+
:::
252+
121253
## See Also
122254

123255
- [Execution Logic](../Advanced%20Concepts/execution-logic.md)
256+
- [Blotter](../Advanced%20Concepts/blotter.md)
124257
- [Orders](../Getting%20Started/orders.md)
125258
- [Risk Rules Overview](./overview.md)

examples/tutorial/notebooks/03_param_sweep.ipynb

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,14 @@
484484
"id": "9",
485485
"metadata": {},
486486
"source": [
487-
"## Quick filter via SQLite index & open report"
487+
"## Pruning & Ranking Backtests\n",
488+
"\n",
489+
"After the parameter sweep completes, we often have dozens (or hundreds) of backtest bundles on disk. Rather than decoding every `.iafbt` file to compare them, we use the **Tier-1 SQLite index** to rank and prune in milliseconds:\n",
490+
"\n",
491+
"1. **`build_index()`** — scans the results directory and promotes every scalar metric from each bundle into a single SQLite table (one row per backtest). This only touches the lightweight Parquet header, so it scales to 10k+ bundles.\n",
492+
"2. **`rank_index()`** — runs a pure-SQL sort/filter on that index. Here we rank by Sharpe ratio and require at least 5 closed trades, returning the top 20 instantly — no full bundle decode needed.\n",
493+
"3. **`prune_backtests()`** — moves every bundle *not* in the top-N list to an archive folder (`_pruned/`). With `flatten=True` the archive is a flat directory for easy browsing. Bundles are never deleted, so you can always recover them.\n",
494+
"4. **`LocalDirStore.open()`** — materialises only the winning bundles into full `Backtest` objects for the HTML dashboard."
488495
]
489496
},
490497
{
@@ -494,7 +501,7 @@
494501
"metadata": {},
495502
"outputs": [],
496503
"source": [
497-
"from investing_algorithm_framework import BacktestReport\n",
504+
"from investing_algorithm_framework import BacktestReport, BacktestEvaluationFocus\n",
498505
"from investing_algorithm_framework.cli.index_command import (\n",
499506
" build_index, rank_index, format_table, prune_backtests,\n",
500507
")\n",
@@ -510,14 +517,17 @@
510517
")\n",
511518
"print(f\"Index written to: {index_path}\")\n",
512519
"\n",
513-
"# 2. Rank by Sharpe and keep the top 20 — pure SQL, instant.\n",
520+
"# 2. Rank using weighted multi-metric scoring with a BALANCED focus.\n",
521+
"# This scores every bundle across profit, risk-adjusted returns,\n",
522+
"# drawdown penalties, and cross-window consistency — all from SQLite.\n",
523+
"# Available presets: BALANCED, PROFIT, FREQUENCY, RISK_ADJUSTED.\n",
514524
"top = rank_index(\n",
515525
" str(backtest_results_dir),\n",
516-
" by=\"sharpe_ratio\",\n",
526+
" focus=BacktestEvaluationFocus.BALANCED,\n",
517527
" where=\"summary_number_of_trades_closed > 5\",\n",
518528
" limit=20,\n",
519529
")\n",
520-
"print(f\"\\nTop {len(top)} strategies by Sharpe ratio:\\n\")\n",
530+
"print(f\"\\nTop {len(top)} strategies (BALANCED focus):\\n\")\n",
521531
"print(format_table(top))\n",
522532
"\n",
523533
"# 3. Prune: move everything outside the top 20 to an archive folder.\n",
@@ -528,6 +538,7 @@
528538
" keep=top,\n",
529539
" archive_dir=str(archive_path),\n",
530540
" show_progress=True,\n",
541+
" flatten=True, # flat archive for easy browsing — no nested folders\n",
531542
")\n",
532543
"print(f\"\\nKept {result['kept']} bundles, pruned {result['pruned']} → {result['archive_dir']}\")\n",
533544
"\n",

investing_algorithm_framework/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
retag_backtests, migrate_backtests, \
3030
Blotter, DefaultBlotter, SimulationBlotter, Transaction, \
3131
SlippageModel, NoSlippage, PercentageSlippage, FixedSlippage, \
32-
VolumeImpactSlippage, \
32+
VolumeImpactSlippage, VolumeShareSlippage, FixedBasisPointsSlippage, \
3333
CommissionModel, NoCommission, PercentageCommission, FixedCommission, \
3434
FillModel, FullFill, VolumeBasedFill, \
3535
FXRateProvider, StaticFXRateProvider, \
@@ -258,6 +258,8 @@
258258
"PercentageSlippage",
259259
"FixedSlippage",
260260
"VolumeImpactSlippage",
261+
"VolumeShareSlippage",
262+
"FixedBasisPointsSlippage",
261263
"CommissionModel",
262264
"NoCommission",
263265
"PercentageCommission",

0 commit comments

Comments
 (0)