diff --git a/src/ml4t/backtest/execution/impact.py b/src/ml4t/backtest/execution/impact.py index d17db16b..42b500dc 100644 --- a/src/ml4t/backtest/execution/impact.py +++ b/src/ml4t/backtest/execution/impact.py @@ -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): @@ -64,11 +68,19 @@ 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) @@ -76,7 +88,34 @@ class LinearImpact(MarketImpactModel): """ 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, diff --git a/tests/execution/test_impact.py b/tests/execution/test_impact.py index ba408649..ff4e0c8c 100644 --- a/tests/execution/test_impact.py +++ b/tests/execution/test_impact.py @@ -1,6 +1,9 @@ """Tests for market impact models.""" import math +import warnings + +import pytest from ml4t.backtest.execution.impact import ( LinearImpact, @@ -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)