Skip to content
Closed
45 changes: 42 additions & 3 deletions src/ml4t/backtest/execution/impact.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"""Market impact models for realistic execution costs."""

import math
import warnings
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any

PERMANENT_FRACTION_DEFAULT = 0.5


class MarketImpactModel(ABC):
Expand Down Expand Up @@ -64,19 +68,54 @@ class LinearImpact(MarketImpactModel):
Simple model where impact scales linearly with participation rate.
Appropriate for liquid markets with moderate order sizes.

Like every model here it is a single-order concession model: `calculate` sees one
order, and the impact it returns is charged to that order's fill price only. Nothing
is carried into the price for later orders, so a parent order worked in slices is
charged the same concession on every slice. A caller who needs permanent impact -
the part of the move that does not revert and is paid again by every later slice -
accumulates it outside the model.

Args:
coefficient: Impact scaling factor (default 0.1)
Higher values = more impact per unit participation
permanent_fraction: Fraction of impact that is permanent (0-1)
Remainder is temporary and reverts
permanent_fraction: Deprecated and inert; scheduled for removal in 0.2.0.
`calculate` has never read it, so every value charges the same
price. Setting it to anything but its default warns.

Example:
model = LinearImpact(coefficient=0.1)
# 10% participation at $100 price = $1.00 impact
"""

coefficient: float = 0.1
permanent_fraction: float = 0.5
permanent_fraction: float = PERMANENT_FRACTION_DEFAULT

def __setattr__(self, name: str, value: Any) -> None:
"""Warn once per assignment that sets the inert persistence parameter.

The warning goes here rather than in `__post_init__` because the field is
settable after construction as well as through it, and a model assembled and
then adjusted is the case that most looks like it is configuring something.
A non-frozen dataclass routes `__init__` through `__setattr__` too, so one
guard covers both and fires once each time a value is actually set.

The default is the one value that does not warn, because a dataclass cannot
tell a caller who passed 0.5 from one who passed nothing. Leaving it alone is
also the case that loses nothing when the field goes: the model charges the
same price either way.
"""
if name == "permanent_fraction" and value != PERMANENT_FRACTION_DEFAULT:
warnings.warn(
"LinearImpact.permanent_fraction is inert and will be removed in "
"ml4t-backtest 0.2.0. calculate() has never read it, so this model "
"charges the same impact at every value; the engine applies an impact "
"model to one order at a time and has no state in which a permanent "
"component could persist into later fills. Accumulate permanent impact "
"in the caller instead.",
DeprecationWarning,
stacklevel=2,
)
super().__setattr__(name, value)

def calculate(
self,
Expand Down
60 changes: 60 additions & 0 deletions tests/execution/test_impact.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Tests for market impact models."""

import math
import warnings

import pytest

from ml4t.backtest.execution.impact import (
LinearImpact,
Expand Down Expand Up @@ -245,3 +248,60 @@ def test_concave_exponent(self):

# With exponent=0.25, 16x quantity = 2x impact (16^0.25 = 2)
assert abs(impact_16000 / impact_1000 - 2.0) < 0.1


class TestImpactModelsAreSingleOrderConcessions:
"""No model here carries impact into the price for the orders that follow it."""

MODELS = (
NoImpact(),
LinearImpact(coefficient=0.1),
SquareRootImpact(coefficient=0.5, volatility=0.02),
PowerLawImpact(coefficient=0.1, exponent=0.5),
)

def test_repeated_slices_are_charged_the_same_concession(self):
"""A parent order worked in ten equal slices pays the first slice's price ten times.

This is the property that a permanent-impact parameter would have to break. It
holds for every model, which is why `LinearImpact.permanent_fraction` is
deprecated rather than implemented.
"""
for model in self.MODELS:
charges = [
model.calculate(quantity=1000.0, price=100.0, volume=100_000.0, is_buy=True)
for _ in range(10)
]
assert len(set(charges)) == 1, f"{type(model).__name__} is not stateless"

def test_setting_permanent_fraction_warns_and_changes_nothing(self):
"""The parameter was settable, silent and never read; it must now say so.

Both halves are asserted because either alone would pass a broken
implementation: a warning that also changed the charge would be a behaviour
change nobody asked for, and an unchanged charge with no warning is the defect.
"""
with pytest.warns(DeprecationWarning, match="removed in ml4t-backtest 0.2.0"):
loud = LinearImpact(coefficient=0.1, permanent_fraction=0.8)
quiet = LinearImpact(coefficient=0.1)
args = {"quantity": 100_000.0, "price": 100.0, "volume": 1_000_000.0, "is_buy": True}
assert loud.calculate(**args) == quiet.calculate(**args)

def test_assigning_permanent_fraction_after_construction_warns(self):
"""A dataclass field is settable after `__init__`, and that path warned too."""
model = LinearImpact(coefficient=0.1)
with pytest.warns(DeprecationWarning, match="removed in ml4t-backtest 0.2.0"):
model.permanent_fraction = 0.8

def test_leaving_permanent_fraction_at_its_default_is_silent(self):
"""A caller who never touches the field is not warned about it.

`LinearImpact()` and an explicit `permanent_fraction=0.5` are indistinguishable
to a dataclass, so the default is the one value that cannot warn. Pinned as the
negative case: without it the warning could fire on every construction and the
two tests above would still pass.
"""
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
LinearImpact()
LinearImpact(coefficient=0.2, permanent_fraction=0.5)
Loading