diff --git a/README.md b/README.md index 71147f28..8cb0b492 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,60 @@ backtests = [store.open(row["bundle_path"]) for row in top] BacktestReport(backtests=backtests).save("top20.html") ``` +#### Weighted multi-metric ranking with `BacktestEvaluationFocus` + +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: + +```python +from investing_algorithm_framework import ( + BacktestReport, BacktestEvaluationFocus, +) +from investing_algorithm_framework.cli.index_command import ( + build_index, rank_index, +) +from investing_algorithm_framework.services.backtest_store import ( + LocalDirStore, +) + +# 1. Build (or refresh) the Tier-1 SQLite index. +build_index("./my-backtests/") + +# 2. Rank with a built-in focus preset (BALANCED, PROFIT, FREQUENCY, RISK_ADJUSTED). +top = rank_index( + "./my-backtests/", + focus=BacktestEvaluationFocus.RISK_ADJUSTED, + where="summary_number_of_trades > 50", + limit=25, +) + +# 3. Or supply fully custom weights — positive favours higher, negative penalises. +top = rank_index( + "./my-backtests/", + weights={ + "sharpe_ratio": 3.0, + "sortino_ratio": 2.5, + "max_drawdown": -3.0, + "win_rate": 2.0, + "consistency_score": 1.5, + }, + limit=25, +) + +# 4. Materialise only the winners and render a focused dashboard. +store = LocalDirStore("./my-backtests/") +winners = [store.open(row["bundle_path"]) for row in top] +BacktestReport(backtests=winners).save("top25_risk_adjusted.html") +``` + +**Built-in focus presets:** + +| Preset | Prioritises | +|--------|------------| +| `BALANCED` | Equal mix of profit, risk-adjusted returns, drawdown penalties, and consistency | +| `PROFIT` | Absolute and relative gains (CAGR, net gain, win rate, profit factor) | +| `FREQUENCY` | High trade count, short durations, and per-trade efficiency | +| `RISK_ADJUSTED` | Sharpe, Sortino, Calmar with strong drawdown and volatility penalties | + Or from the shell: ```bash diff --git a/docusaurus/docs/Advanced Concepts/blotter.md b/docusaurus/docs/Advanced Concepts/blotter.md index 78679dc5..1f531301 100644 --- a/docusaurus/docs/Advanced Concepts/blotter.md +++ b/docusaurus/docs/Advanced Concepts/blotter.md @@ -75,6 +75,37 @@ app.set_blotter(SimulationBlotter( )) ``` +### VolumeShareSlippage + +Volume-aware model with quadratic price impact. Limits fills to a fraction of bar volume, producing partial fills for large orders relative to liquidity. + +```python +from investing_algorithm_framework import SimulationBlotter, VolumeShareSlippage + +app.set_blotter(SimulationBlotter( + slippage_model=VolumeShareSlippage( + volume_limit=0.025, # max 2.5% of bar volume + price_impact=0.1, # quadratic impact coefficient + ) +)) +``` + +### FixedBasisPointsSlippage + +Slippage expressed in basis points (1 bp = 0.01 % of price). + +```python +from investing_algorithm_framework import SimulationBlotter, FixedBasisPointsSlippage + +app.set_blotter(SimulationBlotter( + slippage_model=FixedBasisPointsSlippage(basis_points=5) # 5 bps = 0.05% +)) +``` + +:::tip TradingCost Integration +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. +::: + ### Custom Slippage Model Create your own by extending `SlippageModel`: diff --git a/docusaurus/docs/Risk Rules/overview.md b/docusaurus/docs/Risk Rules/overview.md index b6b507d0..28233795 100644 --- a/docusaurus/docs/Risk Rules/overview.md +++ b/docusaurus/docs/Risk Rules/overview.md @@ -66,7 +66,7 @@ class MyStrategy(TradingStrategy): | `take_profits` | [`TakeProfitRule`](./take-profit-rule.md) | Bar-end exit when price rises a fixed or trailing percentage from entry / peak. | | `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. | | `cooldowns` | [`CooldownRule`](./cooldown-rule.md) | Side-aware, per-symbol or portfolio-wide signal throttling after fills. | -| `trading_costs` | [`TradingCost`](./trading-cost.md) | Per-symbol fees and slippage applied during fill simulation. | +| `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). | ## Where Rules Are Enforced diff --git a/docusaurus/docs/Risk Rules/trading-cost.md b/docusaurus/docs/Risk Rules/trading-cost.md index 62194ce8..9f5f6641 100644 --- a/docusaurus/docs/Risk Rules/trading-cost.md +++ b/docusaurus/docs/Risk Rules/trading-cost.md @@ -18,6 +18,7 @@ TradingCost( fee_percentage: float = 0.0, slippage_percentage: float = 0.0, fee_fixed: float = 0.0, + slippage_model: SlippageModel | None = None, ) ``` @@ -25,8 +26,9 @@ TradingCost( |---|---|---|---| | `symbol` | `str \| None` | `None` | Target symbol (e.g. `"BTC"`). `None` means "market default" when used at the portfolio level. Symbol matching is case-insensitive. | | `fee_percentage` | `float` | `0.0` | Variable fee in **percent of trade value** (e.g. `0.1` = 0.1 %). | -| `slippage_percentage` | `float` | `0.0` | Slippage in **percent of price**. Buys fill higher, sells fill lower. | +| `slippage_percentage` | `float` | `0.0` | Slippage in **percent of price**. Buys fill higher, sells fill lower. Ignored when `slippage_model` is set. | | `fee_fixed` | `float` | `0.0` | Flat fee per trade in the trading currency, added on top of `fee_percentage`. | +| `slippage_model` | `SlippageModel \| None` | `None` | Pluggable slippage model. When set, **overrides** `slippage_percentage`. See [Slippage Models](#slippage-models) below. | ## How Costs Are Applied @@ -39,6 +41,8 @@ sell_fill_price = price * (1 - slippage_percentage / 100) fee = trade_value * fee_percentage / 100 + fee_fixed ``` +When a `slippage_model` is set, the model's `calculate_slippage()` method replaces the percentage formula above. The fee calculation stays the same. + `trade_value` is computed at the slippage-adjusted price, so fees compound on top of slippage — matching how real exchanges quote post-trade cost. ## Resolution Order @@ -118,8 +122,137 @@ PortfolioConfiguration( - **`StopLossRule` / `TakeProfitRule`** — slippage is applied to the exit fill (`sell` direction), so reported PnL already reflects realistic exits. - **`ScalingRule`** — every scale-in and scale-out is a separate fill and pays fees independently. +## Slippage Models + +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. + +```python +from investing_algorithm_framework import ( + TradingCost, + VolumeShareSlippage, + FixedSlippage, + FixedBasisPointsSlippage, +) +``` + +### VolumeShareSlippage + +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. + +```python +TradingCost( + symbol="BTC", + fee_percentage=0.1, + slippage_model=VolumeShareSlippage( + volume_limit=0.025, # max 2.5% of bar volume + price_impact=0.1, # price impact coefficient + ), +) +``` + +| Parameter | Default | Description | +|---|---|---| +| `volume_limit` | `0.025` | Max fraction of bar volume that can fill per bar (0.025 = 2.5 %). | +| `price_impact` | `0.1` | Coefficient for quadratic impact: `impact = price_impact × (amount / volume)²`. | + +**Impact formula:** + +```text +participation = amount / volume +impact = price_impact * participation² + +buy_fill_price = price * (1 + impact) +sell_fill_price = price * (1 - impact) +``` + +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. + +### FixedSlippage + +Adds or subtracts a fixed amount from the order price. Useful for markets with a known, relatively stable spread. + +```python +TradingCost( + symbol="ETH", + fee_percentage=0.1, + slippage_model=FixedSlippage(amount=0.50), # fixed $0.50 spread +) +``` + +| Parameter | Default | Description | +|---|---|---| +| `amount` | `0.01` | Fixed slippage in price units. | + +### FixedBasisPointsSlippage + +Slippage expressed in basis points (1 bp = 0.01 % of price). Convenient when you want a proportional slippage without thinking in decimals. + +```python +TradingCost( + symbol="BTC", + fee_percentage=0.1, + slippage_model=FixedBasisPointsSlippage(basis_points=5), # 5 bps = 0.05% +) +``` + +| Parameter | Default | Description | +|---|---|---| +| `basis_points` | `5` | Slippage in basis points. | + +### Custom Slippage Model + +Create your own by extending `SlippageModel`: + +```python +from investing_algorithm_framework import SlippageModel + +class MySlippageModel(SlippageModel): + def __init__(self, price_impact=0.1, volume_limit=0.025): + self.price_impact = price_impact + self.volume_limit = volume_limit + + def calculate_slippage(self, price, order_side, amount=None, volume=None): + """Return adjusted fill price.""" + if amount and volume and volume > 0: + impact = self.price_impact * (amount / volume) ** 2 + else: + impact = 0.0 + + if order_side == "BUY": + return price * (1 + impact) + return price * (1 - impact) + + def max_fill_amount(self, order_amount, volume=None): + """Return maximum fillable amount for this bar.""" + if volume and volume > 0: + return min(order_amount, volume * self.volume_limit) + return order_amount +``` + +The two methods you can override: + +| Method | Required | Description | +|---|---|---| +| `calculate_slippage(price, order_side, amount, volume)` | Yes | Return the adjusted fill price after slippage. | +| `max_fill_amount(order_amount, volume)` | No | Return the maximum fillable amount per bar. Default returns the full `order_amount` (no volume limit). | + +### Choosing a Slippage Approach + +| Approach | When to use | +|---|---| +| `slippage_percentage` | Quick approximation, don't need volume awareness. | +| `FixedSlippage` | Known fixed spread (e.g. a specific venue). | +| `FixedBasisPointsSlippage` | Proportional slippage in familiar units (basis points). | +| `VolumeShareSlippage` | Realistic simulation — large orders impact price, fills are volume-limited. | +| Custom `SlippageModel` | Any other behavior (e.g. time-of-day effects, asymmetric slippage). | + +:::tip Backward Compatibility +Setting `slippage_model` is fully optional. Existing strategies using `slippage_percentage` continue to work unchanged. +::: + ## See Also - [Execution Logic](../Advanced%20Concepts/execution-logic.md) +- [Blotter](../Advanced%20Concepts/blotter.md) - [Orders](../Getting%20Started/orders.md) - [Risk Rules Overview](./overview.md) diff --git a/examples/tutorial/notebooks/03_param_sweep.ipynb b/examples/tutorial/notebooks/03_param_sweep.ipynb index 0ac43013..8fdd3e78 100644 --- a/examples/tutorial/notebooks/03_param_sweep.ipynb +++ b/examples/tutorial/notebooks/03_param_sweep.ipynb @@ -484,7 +484,14 @@ "id": "9", "metadata": {}, "source": [ - "## Quick filter via SQLite index & open report" + "## Pruning & Ranking Backtests\n", + "\n", + "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", + "\n", + "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", + "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", + "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", + "4. **`LocalDirStore.open()`** — materialises only the winning bundles into full `Backtest` objects for the HTML dashboard." ] }, { @@ -494,7 +501,7 @@ "metadata": {}, "outputs": [], "source": [ - "from investing_algorithm_framework import BacktestReport\n", + "from investing_algorithm_framework import BacktestReport, BacktestEvaluationFocus\n", "from investing_algorithm_framework.cli.index_command import (\n", " build_index, rank_index, format_table, prune_backtests,\n", ")\n", @@ -510,14 +517,17 @@ ")\n", "print(f\"Index written to: {index_path}\")\n", "\n", - "# 2. Rank by Sharpe and keep the top 20 — pure SQL, instant.\n", + "# 2. Rank using weighted multi-metric scoring with a BALANCED focus.\n", + "# This scores every bundle across profit, risk-adjusted returns,\n", + "# drawdown penalties, and cross-window consistency — all from SQLite.\n", + "# Available presets: BALANCED, PROFIT, FREQUENCY, RISK_ADJUSTED.\n", "top = rank_index(\n", " str(backtest_results_dir),\n", - " by=\"sharpe_ratio\",\n", + " focus=BacktestEvaluationFocus.BALANCED,\n", " where=\"summary_number_of_trades_closed > 5\",\n", " limit=20,\n", ")\n", - "print(f\"\\nTop {len(top)} strategies by Sharpe ratio:\\n\")\n", + "print(f\"\\nTop {len(top)} strategies (BALANCED focus):\\n\")\n", "print(format_table(top))\n", "\n", "# 3. Prune: move everything outside the top 20 to an archive folder.\n", @@ -528,6 +538,7 @@ " keep=top,\n", " archive_dir=str(archive_path),\n", " show_progress=True,\n", + " flatten=True, # flat archive for easy browsing — no nested folders\n", ")\n", "print(f\"\\nKept {result['kept']} bundles, pruned {result['pruned']} → {result['archive_dir']}\")\n", "\n", diff --git a/investing_algorithm_framework/__init__.py b/investing_algorithm_framework/__init__.py index d28538db..46534368 100644 --- a/investing_algorithm_framework/__init__.py +++ b/investing_algorithm_framework/__init__.py @@ -29,7 +29,7 @@ retag_backtests, migrate_backtests, \ Blotter, DefaultBlotter, SimulationBlotter, Transaction, \ SlippageModel, NoSlippage, PercentageSlippage, FixedSlippage, \ - VolumeImpactSlippage, \ + VolumeImpactSlippage, VolumeShareSlippage, FixedBasisPointsSlippage, \ CommissionModel, NoCommission, PercentageCommission, FixedCommission, \ FillModel, FullFill, VolumeBasedFill, \ FXRateProvider, StaticFXRateProvider, \ @@ -258,6 +258,8 @@ "PercentageSlippage", "FixedSlippage", "VolumeImpactSlippage", + "VolumeShareSlippage", + "FixedBasisPointsSlippage", "CommissionModel", "NoCommission", "PercentageCommission", diff --git a/investing_algorithm_framework/cli/index_command.py b/investing_algorithm_framework/cli/index_command.py index faaf12f2..c91bc0c2 100644 --- a/investing_algorithm_framework/cli/index_command.py +++ b/investing_algorithm_framework/cli/index_command.py @@ -12,7 +12,10 @@ import logging from dataclasses import fields as dc_fields from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Sequence +from typing import Any, Dict, Iterable, List, Optional, Sequence, TYPE_CHECKING + +if TYPE_CHECKING: + from investing_algorithm_framework.domain import BacktestEvaluationFocus from investing_algorithm_framework.domain import ( Backtest, @@ -246,15 +249,61 @@ def list_index( def rank_index( index_path: str, - by: str, + by: Optional[str] = None, limit: int = 10, where: Optional[str] = None, columns: Optional[Sequence[str]] = None, ascending: bool = False, + focus: Optional["BacktestEvaluationFocus | str"] = None, + weights: Optional[Dict[str, float]] = None, ) -> List[Dict[str, Any]]: - """Rank bundles by a metric. Thin wrapper around :func:`list_index` - with a different default column set and a required ``by`` arg.""" + """Rank bundles by a single metric or a weighted combination. + + **Single-metric mode** (original behaviour): + Pass ``by="sharpe_ratio"`` to sort by one column. + + **Weighted-score mode**: + Pass ``focus`` (a :class:`BacktestEvaluationFocus` or its + string name, e.g. ``"balanced"``) and/or ``weights`` to rank + by a normalised weighted score across many metrics. + + When both ``focus`` and ``weights`` are given, ``weights`` + entries override those from the focus preset. A ``_score`` + column is added to each result dict. + + Args: + index_path: Path to ``index.sqlite`` or its parent directory. + by: Column to sort by (single-metric mode). Ignored when + *focus* or *weights* is set. + limit: Maximum rows to return. + where: Optional SQL ``WHERE`` fragment. + columns: Columns to project. + ascending: Sort direction (default best-first / descending). + focus: A :class:`BacktestEvaluationFocus` value or string + (e.g. ``"balanced"``). + weights: Custom ``{metric: weight}`` dict. Metric names + without a ``summary_`` prefix are accepted (they are + mapped automatically). + """ cols = list(columns) if columns else list(DEFAULT_RANK_COLUMNS) + + if focus is not None or weights is not None: + return _rank_index_weighted( + index_path, + focus=focus, + weights=weights, + limit=limit, + where=where, + columns=cols, + ascending=ascending, + ) + + if by is None: + raise ValueError( + "Either 'by' (single metric) or 'focus'/'weights' " + "(weighted score) must be provided." + ) + return list_index( index_path, sort_by=by, @@ -265,6 +314,93 @@ def rank_index( ) +def _rank_index_weighted( + index_path: str, + *, + focus=None, + weights: Optional[Dict[str, float]] = None, + limit: int = 10, + where: Optional[str] = None, + columns: Sequence[str] = (), + ascending: bool = False, +) -> List[Dict[str, Any]]: + """Score every row in the index using normalised weighted metrics. + + Mirrors the logic in :func:`rank_results` / + :func:`compute_score` but works directly on the flat SQLite + index — no Parquet decode, no :class:`Backtest` instantiation. + """ + import math + from investing_algorithm_framework.analysis.ranking import ( + create_weights, normalize, + ) + + effective_weights = create_weights( + focus=focus, custom_weights=weights, + ) + + # Map bare metric names → summary_ prefixed column names so we + # can read them from the flat row dicts. + col_weights: Dict[str, float] = {} + for metric, w in effective_weights.items(): + col = _resolve_metric_column(metric) + col_weights[col] = w + + # Ensure every weighted column is included in the projection so + # we can compute the score. + all_cols = list(dict.fromkeys(list(columns) + list(col_weights))) + + rows = list_index( + index_path, + sort_by=None, + limit=None, + where=where, + columns=all_cols, + ) + + if not rows: + return [] + + # Compute per-metric normalisation ranges. + ranges: Dict[str, tuple] = {} + for col in col_weights: + values = [ + r[col] for r in rows + if isinstance(r.get(col), (int, float)) + and r[col] is not None + and not math.isnan(r[col]) + and not math.isinf(r[col]) + ] + if values: + ranges[col] = (min(values), max(values)) + + # Score each row. + for row in rows: + score = 0.0 + for col, w in col_weights.items(): + v = row.get(col) + if not isinstance(v, (int, float)): + continue + if v is None or math.isnan(v) or math.isinf(v): + continue + if col in ranges: + v = normalize(v, ranges[col][0], ranges[col][1]) + score += w * v + row["_score"] = round(score, 6) + + rows.sort(key=lambda r: r["_score"], reverse=not ascending) + + if limit is not None: + rows = rows[:limit] + + # Project back to requested columns + _score. + out_cols = list(columns) + ["_score"] + return [ + {k: r.get(k) for k in out_cols} + for r in rows + ] + + def format_table( rows: List[Dict[str, Any]], columns: Optional[Sequence[str]] = None, @@ -307,6 +443,7 @@ def prune_backtests( archive_dir: Optional[str] = None, dry_run: bool = False, show_progress: bool = False, + flatten: bool = False, ) -> Dict[str, Any]: """Move or delete bundles that are **not** in *keep*. @@ -321,6 +458,9 @@ def prune_backtests( dry_run: When True, report what *would* happen without touching the file system. show_progress: Show a tqdm progress bar. + flatten: When True and *archive_dir* is set, place all + pruned bundles directly into *archive_dir* instead of + preserving the original sub-directory structure. Returns: ``{"kept": int, "pruned": int, "archive_dir": str | None}`` @@ -360,8 +500,11 @@ def prune_backtests( else: if not dry_run: if archive is not None: - dest = archive / rel - dest.parent.mkdir(parents=True, exist_ok=True) + if flatten: + dest = archive / bundle_path.name + else: + dest = archive / rel + dest.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(bundle_path), str(dest)) else: bundle_path.unlink() diff --git a/investing_algorithm_framework/domain/__init__.py b/investing_algorithm_framework/domain/__init__.py index 35ae5dd3..eb8580b7 100644 --- a/investing_algorithm_framework/domain/__init__.py +++ b/investing_algorithm_framework/domain/__init__.py @@ -30,7 +30,7 @@ from .portfolio_provider import PortfolioProvider from .blotter import Blotter, DefaultBlotter, SimulationBlotter, Transaction, \ SlippageModel, NoSlippage, PercentageSlippage, FixedSlippage, \ - VolumeImpactSlippage, \ + VolumeImpactSlippage, VolumeShareSlippage, FixedBasisPointsSlippage, \ CommissionModel, NoCommission, PercentageCommission, FixedCommission, \ FillModel, FullFill, VolumeBasedFill from .fx import FXRateProvider, StaticFXRateProvider @@ -176,6 +176,8 @@ "PercentageSlippage", "FixedSlippage", "VolumeImpactSlippage", + "VolumeShareSlippage", + "FixedBasisPointsSlippage", "CommissionModel", "NoCommission", "PercentageCommission", diff --git a/investing_algorithm_framework/domain/backtesting/backtest_evaluation_focuss.py b/investing_algorithm_framework/domain/backtesting/backtest_evaluation_focuss.py index e7f0bb51..f84d802a 100644 --- a/investing_algorithm_framework/domain/backtesting/backtest_evaluation_focuss.py +++ b/investing_algorithm_framework/domain/backtesting/backtest_evaluation_focuss.py @@ -73,6 +73,8 @@ class BacktestEvaluationFocus(Enum): - current_average_trade_return - current_average_trade_duration - current_average_trade_loss + - consistency_score + - stability_score """ BALANCED = "balanced" PROFIT = "profit" diff --git a/investing_algorithm_framework/domain/blotter.py b/investing_algorithm_framework/domain/blotter.py index f4fd4d68..eafc4c5d 100644 --- a/investing_algorithm_framework/domain/blotter.py +++ b/investing_algorithm_framework/domain/blotter.py @@ -33,6 +33,21 @@ def calculate_slippage( """ raise NotImplementedError + def max_fill_amount(self, order_amount, volume=None): + """ + Return the maximum fillable amount for this bar. + + Override in subclasses that enforce volume limits. + + Args: + order_amount: The remaining order amount. + volume: Available market volume for this bar. + + Returns: + float: The maximum amount that can be filled. + """ + return order_amount + class NoSlippage(SlippageModel): """No slippage — fills at the exact order price.""" @@ -153,6 +168,97 @@ def calculate_slippage( return price * (1 - impact) +class VolumeShareSlippage(SlippageModel): + """ + Volume-aware slippage model. + + Models slippage as a function of the order's share of + historical bar volume, with a quadratic price impact: + + impact = price_impact * (amount / volume) ** 2 + + Also enforces a volume limit — at most ``volume_limit`` + fraction of the bar's volume can be filled per bar. + + Usage:: + + VolumeShareSlippage( + volume_limit=0.025, # max 2.5% of bar volume + price_impact=0.1 # price impact coefficient + ) + """ + + def __init__(self, volume_limit=0.025, price_impact=0.1): + """ + Args: + volume_limit: Maximum fraction of bar volume that can + be filled (0.025 = 2.5%). + price_impact: Coefficient for quadratic price impact. + """ + self.volume_limit = volume_limit + self.price_impact = price_impact + + def calculate_slippage( + self, price, order_side, amount=None, volume=None + ): + from investing_algorithm_framework.domain.models.order.order_side \ + import OrderSide + + if ( + volume is not None + and volume > 0 + and amount is not None + and amount > 0 + ): + participation = amount / volume + impact = self.price_impact * (participation ** 2) + else: + impact = 0.0 + + if OrderSide.BUY.equals(order_side): + return price * (1 + impact) + else: + return price * (1 - impact) + + def max_fill_amount(self, order_amount, volume=None): + if volume is not None and volume > 0: + max_volume = volume * self.volume_limit + return min(order_amount, max_volume) + return order_amount + + +class FixedBasisPointsSlippage(SlippageModel): + """ + Slippage expressed in basis points of price. + + One basis point = 0.01% of price. + + Usage:: + + FixedBasisPointsSlippage(basis_points=5) # 5 bps = 0.05% + """ + + def __init__(self, basis_points=5): + """ + Args: + basis_points: Slippage in basis points (1 bp = 0.01%). + """ + self.basis_points = basis_points + + def calculate_slippage( + self, price, order_side, amount=None, volume=None + ): + from investing_algorithm_framework.domain.models.order.order_side \ + import OrderSide + + impact = self.basis_points / 10000 + + if OrderSide.BUY.equals(order_side): + return price * (1 + impact) + else: + return price * (1 - impact) + + class CommissionModel(ABC): """ Abstract base class for commission models. diff --git a/investing_algorithm_framework/domain/models/risk_rules/trading_cost.py b/investing_algorithm_framework/domain/models/risk_rules/trading_cost.py index b20fd93a..d210203b 100644 --- a/investing_algorithm_framework/domain/models/risk_rules/trading_cost.py +++ b/investing_algorithm_framework/domain/models/risk_rules/trading_cost.py @@ -7,6 +7,11 @@ class TradingCost: TradingStrategy (overrides market-level defaults) or set as market-level defaults on PortfolioConfiguration / app.add_market(). + Slippage can be specified either as a flat percentage + (``slippage_percentage``) or via a pluggable ``slippage_model`` + (e.g. ``VolumeShareSlippage``, ``FixedBasisPointsSlippage``). + When both are provided, ``slippage_model`` takes precedence. + Attributes: symbol (str): The target symbol this cost applies to (e.g. "BTC"). Use ``None`` for market-level defaults. @@ -16,6 +21,8 @@ class TradingCost: price. Default 0.0. Buy fills higher, sell fills lower. fee_fixed (float): Fixed fee per trade in the trading currency. Default 0.0. + slippage_model: Optional pluggable ``SlippageModel`` instance. + When set, overrides ``slippage_percentage``. """ def __init__( @@ -24,20 +31,43 @@ def __init__( fee_percentage=0.0, slippage_percentage=0.0, fee_fixed=0.0, + slippage_model=None, ): self.symbol = symbol.upper() if symbol else None self.fee_percentage = fee_percentage self.slippage_percentage = slippage_percentage self.fee_fixed = fee_fixed + self.slippage_model = slippage_model - def get_buy_fill_price(self, price): + def get_buy_fill_price(self, price, amount=None, volume=None): """Return the slippage-adjusted buy fill price.""" + if self.slippage_model is not None: + return self.slippage_model.calculate_slippage( + price, "BUY", amount=amount, volume=volume, + ) return price * (1 + self.slippage_percentage / 100) - def get_sell_fill_price(self, price): + def get_sell_fill_price(self, price, amount=None, volume=None): """Return the slippage-adjusted sell fill price.""" + if self.slippage_model is not None: + return self.slippage_model.calculate_slippage( + price, "SELL", amount=amount, volume=volume, + ) return price * (1 - self.slippage_percentage / 100) + def get_max_fill_amount(self, order_amount, volume=None): + """Return the maximum fillable amount for this bar. + + Delegates to the slippage model's ``max_fill_amount`` when + a model is configured; otherwise returns the full + ``order_amount`` (no volume limit). + """ + if self.slippage_model is not None: + return self.slippage_model.max_fill_amount( + order_amount, volume=volume, + ) + return order_amount + def get_fee(self, trade_value): """Return the fee for a given trade value.""" return trade_value * self.fee_percentage / 100 + self.fee_fixed diff --git a/investing_algorithm_framework/services/trade_order_evaluator/backtest_trade_oder_evaluator.py b/investing_algorithm_framework/services/trade_order_evaluator/backtest_trade_oder_evaluator.py index 50ed943c..ac52d8d6 100644 --- a/investing_algorithm_framework/services/trade_order_evaluator/backtest_trade_oder_evaluator.py +++ b/investing_algorithm_framework/services/trade_order_evaluator/backtest_trade_oder_evaluator.py @@ -251,13 +251,19 @@ def _apply_fill( fee_rate = self._blotter.get_commission_rate() else: tc = self._resolve_trading_cost(order.symbol) + fill_amount = min( + tc.get_max_fill_amount(remaining, volume), remaining, + ) if OrderSide.BUY.equals(order_side): - fill_price = tc.get_buy_fill_price(base_price) + fill_price = tc.get_buy_fill_price( + base_price, amount=fill_amount, volume=volume, + ) slippage = fill_price - base_price else: - fill_price = tc.get_sell_fill_price(base_price) + fill_price = tc.get_sell_fill_price( + base_price, amount=fill_amount, volume=volume, + ) slippage = base_price - fill_price - fill_amount = remaining fee = tc.get_fee(fill_price * fill_amount) fee_rate = ( tc.fee_percentage / 100 if tc.fee_percentage else None diff --git a/tests/domain/test_slippage_models.py b/tests/domain/test_slippage_models.py new file mode 100644 index 00000000..7e84cbf9 --- /dev/null +++ b/tests/domain/test_slippage_models.py @@ -0,0 +1,291 @@ +import unittest + +from investing_algorithm_framework import ( + SlippageModel, + NoSlippage, + PercentageSlippage, + FixedSlippage, + VolumeImpactSlippage, + VolumeShareSlippage, + FixedBasisPointsSlippage, + TradingCost, +) + + +class TestNoSlippage(unittest.TestCase): + + def test_buy_no_change(self): + model = NoSlippage() + self.assertEqual(model.calculate_slippage(100, "BUY"), 100) + + def test_sell_no_change(self): + model = NoSlippage() + self.assertEqual(model.calculate_slippage(100, "SELL"), 100) + + def test_max_fill_amount_returns_full(self): + model = NoSlippage() + self.assertEqual(model.max_fill_amount(50), 50) + + +class TestPercentageSlippage(unittest.TestCase): + + def setUp(self): + self.model = PercentageSlippage(percentage=0.001) + + def test_buy_increases_price(self): + # 0.1% slippage on buy + price = self.model.calculate_slippage(1000, "BUY") + self.assertAlmostEqual(price, 1001.0) + + def test_sell_decreases_price(self): + price = self.model.calculate_slippage(1000, "SELL") + self.assertAlmostEqual(price, 999.0) + + def test_custom_percentage(self): + model = PercentageSlippage(percentage=0.01) # 1% + self.assertAlmostEqual( + model.calculate_slippage(200, "BUY"), 202.0 + ) + self.assertAlmostEqual( + model.calculate_slippage(200, "SELL"), 198.0 + ) + + def test_max_fill_amount_returns_full(self): + self.assertEqual(self.model.max_fill_amount(100), 100) + + +class TestFixedSlippage(unittest.TestCase): + + def test_buy_adds_amount(self): + model = FixedSlippage(amount=0.50) + self.assertAlmostEqual( + model.calculate_slippage(100, "BUY"), 100.50 + ) + + def test_sell_subtracts_amount(self): + model = FixedSlippage(amount=0.50) + self.assertAlmostEqual( + model.calculate_slippage(100, "SELL"), 99.50 + ) + + def test_max_fill_amount_returns_full(self): + model = FixedSlippage(amount=0.50) + self.assertEqual(model.max_fill_amount(100), 100) + + +class TestVolumeImpactSlippage(unittest.TestCase): + + def setUp(self): + self.model = VolumeImpactSlippage( + base_percentage=0.001, impact_power=0.5 + ) + + def test_buy_increases_price(self): + price = self.model.calculate_slippage( + 1000, "BUY", amount=100, volume=10000 + ) + # participation = 100/10000 = 0.01 + # impact = 0.001 * 0.01^0.5 = 0.001 * 0.1 = 0.0001 + expected = 1000 * (1 + 0.001 * (0.01 ** 0.5)) + self.assertAlmostEqual(price, expected, places=6) + self.assertGreater(price, 1000) + + def test_sell_decreases_price(self): + price = self.model.calculate_slippage( + 1000, "SELL", amount=100, volume=10000 + ) + expected = 1000 * (1 - 0.001 * (0.01 ** 0.5)) + self.assertAlmostEqual(price, expected, places=6) + self.assertLess(price, 1000) + + def test_larger_order_more_impact(self): + small = self.model.calculate_slippage( + 1000, "BUY", amount=10, volume=10000 + ) + large = self.model.calculate_slippage( + 1000, "BUY", amount=1000, volume=10000 + ) + self.assertGreater(large, small) + + def test_no_volume_falls_back_to_base(self): + price = self.model.calculate_slippage(1000, "BUY") + # Falls back to base_percentage = 0.001 + self.assertAlmostEqual(price, 1000 * 1.001) + + def test_zero_volume_falls_back_to_base(self): + price = self.model.calculate_slippage( + 1000, "SELL", amount=10, volume=0 + ) + self.assertAlmostEqual(price, 1000 * (1 - 0.001)) + + def test_max_fill_amount_returns_full(self): + self.assertEqual(self.model.max_fill_amount(100), 100) + + +class TestVolumeShareSlippage(unittest.TestCase): + + def setUp(self): + self.model = VolumeShareSlippage( + volume_limit=0.025, price_impact=0.1 + ) + + def test_buy_increases_price(self): + price = self.model.calculate_slippage( + 100, "BUY", amount=100, volume=10000 + ) + # participation = 100/10000 = 0.01 + # impact = 0.1 * 0.01^2 = 0.00001 + expected = 100 * (1 + 0.1 * (0.01 ** 2)) + self.assertAlmostEqual(price, expected, places=6) + self.assertGreater(price, 100) + + def test_sell_decreases_price(self): + price = self.model.calculate_slippage( + 100, "SELL", amount=100, volume=10000 + ) + expected = 100 * (1 - 0.1 * (0.01 ** 2)) + self.assertAlmostEqual(price, expected, places=6) + self.assertLess(price, 100) + + def test_large_participation_larger_impact(self): + small = self.model.calculate_slippage( + 100, "BUY", amount=10, volume=10000 + ) + large = self.model.calculate_slippage( + 100, "BUY", amount=500, volume=10000 + ) + self.assertGreater(large, small) + + def test_no_volume_zero_impact(self): + price = self.model.calculate_slippage(100, "BUY", amount=10) + self.assertEqual(price, 100) + + def test_zero_volume_zero_impact(self): + price = self.model.calculate_slippage( + 100, "BUY", amount=10, volume=0 + ) + self.assertEqual(price, 100) + + def test_max_fill_amount_limits_to_volume(self): + # 2.5% of 10000 = 250 + result = self.model.max_fill_amount(500, volume=10000) + self.assertEqual(result, 250) + + def test_max_fill_amount_returns_order_when_small(self): + # 2.5% of 10000 = 250, order is only 100 + result = self.model.max_fill_amount(100, volume=10000) + self.assertEqual(result, 100) + + def test_max_fill_amount_no_volume(self): + result = self.model.max_fill_amount(500) + self.assertEqual(result, 500) + + +class TestFixedBasisPointsSlippage(unittest.TestCase): + + def setUp(self): + self.model = FixedBasisPointsSlippage(basis_points=10) + + def test_buy_increases_price(self): + # 10 bps = 0.1% + price = self.model.calculate_slippage(1000, "BUY") + self.assertAlmostEqual(price, 1001.0) + + def test_sell_decreases_price(self): + price = self.model.calculate_slippage(1000, "SELL") + self.assertAlmostEqual(price, 999.0) + + def test_one_basis_point(self): + model = FixedBasisPointsSlippage(basis_points=1) + price = model.calculate_slippage(10000, "BUY") + self.assertAlmostEqual(price, 10001.0) + + def test_max_fill_amount_returns_full(self): + self.assertEqual(self.model.max_fill_amount(100), 100) + + +class TestTradingCostWithSlippageModel(unittest.TestCase): + + def test_slippage_model_overrides_percentage_buy(self): + tc = TradingCost( + slippage_percentage=1.0, + slippage_model=FixedBasisPointsSlippage(basis_points=10), + ) + # Should use model (10 bps = 0.1%) not percentage (1%) + price = tc.get_buy_fill_price(1000) + self.assertAlmostEqual(price, 1001.0) + + def test_slippage_model_overrides_percentage_sell(self): + tc = TradingCost( + slippage_percentage=1.0, + slippage_model=FixedBasisPointsSlippage(basis_points=10), + ) + price = tc.get_sell_fill_price(1000) + self.assertAlmostEqual(price, 999.0) + + def test_volume_share_with_trading_cost(self): + tc = TradingCost( + symbol="BTC", + fee_percentage=0.1, + slippage_model=VolumeShareSlippage( + volume_limit=0.025, price_impact=0.1 + ), + ) + price = tc.get_buy_fill_price( + 50000, amount=100, volume=10000 + ) + self.assertGreater(price, 50000) + + def test_fixed_slippage_with_trading_cost(self): + tc = TradingCost( + symbol="ETH", + fee_percentage=0.1, + slippage_model=FixedSlippage(amount=0.50), + ) + price = tc.get_buy_fill_price(3000) + self.assertAlmostEqual(price, 3000.50) + + def test_get_max_fill_amount_with_model(self): + tc = TradingCost( + slippage_model=VolumeShareSlippage( + volume_limit=0.025, price_impact=0.1 + ), + ) + # 2.5% of 10000 = 250 + result = tc.get_max_fill_amount(500, volume=10000) + self.assertEqual(result, 250) + + def test_get_max_fill_amount_without_model(self): + tc = TradingCost(slippage_percentage=1.0) + result = tc.get_max_fill_amount(500, volume=10000) + self.assertEqual(result, 500) + + def test_backward_compat_no_model(self): + tc = TradingCost(slippage_percentage=1.0) + self.assertAlmostEqual(tc.get_buy_fill_price(100), 101.0) + self.assertAlmostEqual(tc.get_sell_fill_price(100), 99.0) + + def test_backward_compat_no_slippage(self): + tc = TradingCost() + self.assertAlmostEqual(tc.get_buy_fill_price(100), 100.0) + self.assertAlmostEqual(tc.get_sell_fill_price(100), 100.0) + + +class TestCustomSlippageModel(unittest.TestCase): + + def test_custom_model_works(self): + class MySlippage(SlippageModel): + def calculate_slippage( + self, price, order_side, amount=None, volume=None + ): + if order_side == "BUY": + return price * 1.05 + return price * 0.95 + + tc = TradingCost(slippage_model=MySlippage()) + self.assertAlmostEqual(tc.get_buy_fill_price(100), 105.0) + self.assertAlmostEqual(tc.get_sell_fill_price(100), 95.0) + + +if __name__ == "__main__": + unittest.main()