Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions docs/user-guide/market-impact.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,18 @@ from ml4t.backtest.execution import LinearImpact

engine = Engine(
feed, strategy, config,
market_impact_model=LinearImpact(eta=0.1),
market_impact_model=LinearImpact(coefficient=0.1),
)
```

An order that is 10% of bar volume with `eta=0.1` moves the fill price by 1%.
An order that is 10% of bar volume with `coefficient=0.1` moves the fill price by 1%.

Impact from every model here is entirely temporary: `calculate` is handed one order and holds no
reference to earlier slices of the same parent, so there is nothing for a permanent component to
persist into. `LinearImpact` accepted a `permanent_fraction` argument through 0.1.6 and never read
it, so a caller asking for a mostly permanent model got a fully temporary one and no warning. The
argument is removed rather than defaulted, so the request now raises `TypeError` instead of being
answered wrongly. Model persistence outside the engine if you need it.

### Square-Root Impact

Expand All @@ -176,7 +183,7 @@ from ml4t.backtest.execution import SquareRootImpact

engine = Engine(
feed, strategy, config,
market_impact_model=SquareRootImpact(eta=0.5),
market_impact_model=SquareRootImpact(coefficient=0.5),
)
```

Expand Down
10 changes: 7 additions & 3 deletions src/ml4t/backtest/execution/impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,20 @@ class LinearImpact(MarketImpactModel):
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

The impact this returns is entirely temporary: ``calculate`` sees one order
and holds no reference to earlier slices of the same parent, so there is
nothing for a permanent component to persist into. A ``permanent_fraction``
field was accepted and documented here until 0.1.7 and was never read; it is
removed rather than defaulted so that asking for a permanent component
raises instead of silently returning a fully temporary one.

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

coefficient: float = 0.1
permanent_fraction: float = 0.5

def calculate(
self,
Expand Down
2 changes: 1 addition & 1 deletion tests/compatibility/snapshots/v0.1.json
Original file line number Diff line number Diff line change
Expand Up @@ -2696,7 +2696,7 @@
},
"module": "ml4t.backtest.execution.impact",
"qualname": "LinearImpact",
"signature": "(coefficient: float = 0.1, permanent_fraction: float = 0.5) -> None"
"signature": "(coefficient: float = 0.1) -> None"
},
"ml4t.backtest.execution:NoImpact": {
"kind": "class",
Expand Down
41 changes: 40 additions & 1 deletion tests/contracts/test_real_strategy_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,14 +362,53 @@ def test_real_strategy_benchmark_interval_is_deterministic() -> None:


def test_real_strategy_performance_evidence_fails_stale_source() -> None:
"""An engine source that is neither measured-under nor certified is refused.

Both escape hatches have to be closed at once. The shipped report reaches the
current source through `certified_equivalent_sources`, so blanking only the
measured-under digest leaves it publishable and proves nothing.
"""
benchmark = _load_benchmark()
validation = Path(__file__).parents[2] / "validation"
correctness = json.loads((validation / "REAL_STRATEGY_RESULTS.json").read_text())
report = json.loads((validation / "REAL_STRATEGY_PERFORMANCE.json").read_text())
changed = copy.deepcopy(report)
changed.setdefault("provenance", {})["ml4t_engine_source_sha256"] = "0" * 64
changed["provenance"]["certified_equivalent_sources"] = []

assert benchmark.report_failures(report, correctness) == []
failures = benchmark.report_failures(changed, correctness)

assert "Real-strategy performance engine source digest is stale" in failures
assert any("neither the source these" in failure for failure in failures)


def test_certification_alone_publishes_a_later_engine_source() -> None:
"""Timings measured under one source publish for a certified later one."""
benchmark = _load_benchmark()
validation = Path(__file__).parents[2] / "validation"
correctness = json.loads((validation / "REAL_STRATEGY_RESULTS.json").read_text())
report = json.loads((validation / "REAL_STRATEGY_PERFORMANCE.json").read_text())
provenance = report["provenance"]
certified = provenance.get("certified_equivalent_sources") or []

# The working tree is reachable only through a certification, never because the
# timings were measured under it: those two digests must differ, or this test
# passes for the wrong reason.
current = benchmark._tree_digest(benchmark.PROJECT_ROOT / "src/ml4t/backtest")
assert current != provenance["ml4t_engine_source_sha256"]
assert current in {entry["engine_source_sha256"] for entry in certified}
assert benchmark.report_failures(report, correctness) == []


def test_malformed_certification_is_refused() -> None:
"""A certification missing its evidence cannot publish anything."""
benchmark = _load_benchmark()
validation = Path(__file__).parents[2] / "validation"
correctness = json.loads((validation / "REAL_STRATEGY_RESULTS.json").read_text())
report = json.loads((validation / "REAL_STRATEGY_PERFORMANCE.json").read_text())

for field in ("reason", "correctness_evidence_sha256", "correctness_pairs_passed"):
changed = copy.deepcopy(report)
del changed["provenance"]["certified_equivalent_sources"][0][field]
failures = benchmark.report_failures(changed, correctness)
assert any("Certification 0 lacks" in failure for failure in failures), field
14 changes: 13 additions & 1 deletion tests/execution/test_impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import math

import pytest

from ml4t.backtest.execution.impact import (
LinearImpact,
NoImpact,
Expand Down Expand Up @@ -39,7 +41,17 @@ def test_default_values(self):
"""Test default configuration."""
model = LinearImpact()
assert model.coefficient == 0.1
assert model.permanent_fraction == 0.5

def test_rejects_permanent_fraction(self):
"""A permanent component cannot be honoured, so asking for one raises.

``calculate`` sees a single order with no reference to earlier slices of
the same parent, so impact it returns is entirely temporary. The field
was accepted and never read through 0.1.6: a caller asking for a mostly
permanent model got a fully temporary one and no warning.
"""
with pytest.raises(TypeError):
LinearImpact(coefficient=0.1, permanent_fraction=0.8)

def test_buy_positive_impact(self):
"""Test that buy orders have positive impact (price goes up)."""
Expand Down
Loading