Files
manifoldbt/python/tests/test_garde_configuration_execution.py
T

248 lines
11 KiB
Python
Raw Normal View History

2026-10-05 21:11:28 +00:00
"""Configurations refused instead of producing a wrong number, or a panic.
Two guards, both on the Python side because that is the last place that still
knows what the user wrote.
**At ``signal_delay = 0``, a fill cannot land on a point of the bar it decided
on.** Decide on bar ``t``, execute on bar ``t`` is sound at the close: the fill
price *is* the information the decision was made on, and it is what
``signal_delay = 0`` exists for. It stops being sound as soon as the fill lands
somewhere else inside that same bar, because reaching that point would have
meant trading before the close that produced the signal. Measured on a
driftless random walk, "long when close > open" returns +1913% under
``AtOpen``, +361% under ``AtVwap``, ``MidPrice`` and ``custom("low")``, against
+5% under the defaults. The engine said nothing.
``custom(<a signal the strategy defines>)`` is exempt, and deliberately: there
the level is one the DSL computed, and the fill only books if the bar actually
traded through it. That is the band case ``test_exec_price_signal`` pins, and
it measures like the close on this bench. What such a level owes to its own bar
is the resting-order question, carried by the guard next door.
**``ExecutionConfig`` values reach ``f64::clamp`` unchecked.** The core clamps
the desired position to ``(-max_units, max_units)`` derived from
``max_position_pct``. Rust's ``f64::clamp`` asserts ``min <= max``, so a
negative percentage panics, and a non-finite one makes both bounds NaN and
panics too. A Rust panic crosses PyO3 as ``PanicException``, which inherits
from ``BaseException`` and walks straight through a user's
``except Exception``.
The resting-order guard next to these two already refused ``signal_delay = 0``
for limit entries; these tests also pin that it still does, and that the
default configuration keeps working.
"""
import numpy as np
import pandas as pd
import pytest
import manifoldbt as bt
from manifoldbt.exceptions import ConfigError
from manifoldbt.expr import col, lit, when
from manifoldbt.helpers import ExecutionPrice, Slippage
N_BARS = 2000
CAPITAL = 10_000.0
def _bars(seed: int = 11) -> pd.DataFrame:
"""A driftless random walk whose open is the previous close."""
rng = np.random.default_rng(seed)
close = 100.0 * np.exp(np.cumsum(rng.normal(0.0, 0.0015, N_BARS)))
open_ = np.empty(N_BARS)
open_[0], open_[1:] = 100.0, close[:-1]
ts = pd.date_range("2024-01-01", periods=N_BARS, freq="1min", tz="UTC")
return pd.DataFrame({
"timestamp": ts, "open": open_,
"high": np.maximum(open_, close) * 1.0002,
"low": np.minimum(open_, close) * 0.9998,
"close": close, "volume": 1000.0,
})
_DF = _bars()
_END_NS = int(_DF["timestamp"].iloc[-1].value) + 86_400_000_000_000
@pytest.fixture(scope="module")
def store(tmp_path_factory):
root = tmp_path_factory.mktemp("garde_config")
return bt.import_dataframe(
_DF, symbol="TEST", symbol_id=1, interval="1m",
data_root=str(root / "data"), metadata_db=str(root / "meta.sqlite"),
)
def _config(**execution) -> bt.BacktestConfig:
return bt.BacktestConfig(
universe=[1], time_range_start=0, time_range_end=_END_NS,
initial_capital=CAPITAL,
execution=bt.ExecutionConfig(**execution),
slippage=Slippage.none(),
)
def _reads_its_own_bar() -> bt.Strategy:
"""Long when the current bar is up: it can only pay if the bar is known."""
pos = when(col("close") > col("open"), lit(1.0), lit(0.0))
return bt.Strategy.create("lit_sa_barre").signal("pos", pos).size(col("pos"))
# ---------------------------------------------------------------- the price --
@pytest.mark.parametrize("price", ["AtOpen", "AtVwap", "MidPrice", "NextBarOpen"])
def test_un_prix_intrabarre_sans_delai_est_refuse(store, price):
with pytest.raises(ConfigError, match="needs signal_delay >= 1"):
bt.run(_reads_its_own_bar(),
_config(signal_delay=0, execution_price=price), store)
@pytest.mark.parametrize("colonne", ["low", "high", "open", "vwap"])
def test_une_colonne_de_barre_en_custom_sans_delai_est_refusee(store, colonne):
"""`custom("low")` at delay 0 hands the strategy its own bar's extreme."""
with pytest.raises(ConfigError, match="needs signal_delay >= 1"):
bt.run(_reads_its_own_bar(),
_config(signal_delay=0,
execution_price=ExecutionPrice.custom(colonne)), store)
def test_un_niveau_calcule_par_la_strategie_reste_permis(store):
"""`custom(<a signal>)` is exempt: the level is one the DSL chose.
The fill only books if the bar actually traded through it, which is the
band case `test_exec_price_signal` pins. On this bench it measures like the
close, not like `custom("low")`. What that level owes to its own bar is the
resting-order question, carried by the guard next door.
"""
pos = when(col("close") > col("open"), lit(1.0), lit(0.0))
strategy = (bt.Strategy.create("bande")
.signal("exec_level", col("close") * lit(1.0005))
.signal("pos", pos).size(col("pos")))
result = bt.run(strategy,
_config(signal_delay=0,
execution_price=ExecutionPrice.custom("exec_level")),
store)
assert abs(result.metrics["total_return"]) < 0.5
def test_un_nom_inconnu_garde_le_message_du_moteur(store):
"""Neither a bar column nor a signal: the engine names it, not this guard."""
with pytest.raises(Exception, match="neither a bar column nor a signal"):
bt.run(_reads_its_own_bar(),
_config(signal_delay=0,
execution_price=ExecutionPrice.custom("nexistepas")), store)
def test_le_message_nomme_le_prix_et_la_sortie(store):
"""A refusal has to say what to write instead, not just that it refused.
`ConfigError` and not `ValueError`: `run()` funnels every `ValueError`
through `_classify_error`, which buckets by keyword. This message contains
"signal", so it would surface as a `StrategyError` when the execution
config is what is wrong. Raising the right class crosses untouched.
"""
with pytest.raises(ConfigError) as e:
bt.run(_reads_its_own_bar(),
_config(signal_delay=0, execution_price="AtOpen"), store)
message = str(e.value)
assert "AtOpen" in message
assert "signal_delay=1" in message
assert "AtClose" in message
2026-10-12 23:00:48 +00:00
@pytest.mark.parametrize("moyenne", ["TypicalPrice", "OhlcAverage"])
def test_une_moyenne_intrabarre_sans_delai_est_refusee(store, moyenne):
"""`fill_model.intra_bar_price` averages the execution bar whatever the
price says: at delay 0, with the default close, it filled at (H+L+C)/3 or
(O+H+L+C)/4 of the signal's own bar (Sharpe 37 and 65 on a driftless
walk, `detect_lookahead` PASS). Refused like `AtVwap`."""
with pytest.raises(ConfigError, match="needs signal_delay >= 1") as exc:
bt.run(_reads_its_own_bar(),
_config(signal_delay=0, fill_model={"intra_bar_price": moyenne}),
store)
assert moyenne in str(exc.value)
assert "signal_delay=1" in str(exc.value)
@pytest.mark.parametrize("moyenne", ["TypicalPrice", "OhlcAverage"])
def test_une_moyenne_intrabarre_avec_delai_reste_permise(store, moyenne):
"""With a delay the averaged bar follows the signal: nothing to refuse."""
result = bt.run(_reads_its_own_bar(),
_config(signal_delay=1, fill_model={"intra_bar_price": moyenne}),
store)
assert abs(result.metrics["total_return"]) < 0.5
def test_single_point_sans_delai_reste_permis(store):
"""`SinglePoint` is the default: the price named by `execution_price`."""
result = bt.run(_reads_its_own_bar(),
_config(signal_delay=0, fill_model={"intra_bar_price": "SinglePoint"}),
store)
assert abs(result.metrics["total_return"]) < 0.5
2026-10-05 21:11:28 +00:00
@pytest.mark.parametrize("price", ["AtClose", "NextBarClose"])
def test_la_cloture_reste_permise_sans_delai(store, price):
"""These two fill at the close that produced the signal: nothing to refuse.
This is the case the guard must not break: it is what `signal_delay = 0`
is for, and the returns stay in the range a driftless walk allows.
"""
result = bt.run(_reads_its_own_bar(),
_config(signal_delay=0, execution_price=price), store)
assert abs(result.metrics["total_return"]) < 0.5
@pytest.mark.parametrize("price", ["AtOpen", "AtVwap", "MidPrice"])
def test_un_delai_dune_barre_rend_le_prix_licite(store, price):
"""With a delay the fill follows the signal, so every price is allowed."""
result = bt.run(_reads_its_own_bar(),
_config(signal_delay=1, execution_price=price), store)
assert abs(result.metrics["total_return"]) < 0.5
# ------------------------------------------------------------- the config ---
@pytest.mark.parametrize("valeur", [-1.0, 0.0, float("nan"), float("inf")])
def test_max_position_pct_hors_domaine_est_refuse(valeur):
"""Refused where the field still has a name, not in `f64::clamp`."""
with pytest.raises(ValueError, match="max_position_pct"):
bt.ExecutionConfig(max_position_pct=valeur)
@pytest.mark.parametrize("valeur", [-1, 1.5, True])
def test_signal_delay_hors_domaine_est_refuse(valeur):
with pytest.raises(ValueError, match="signal_delay"):
bt.ExecutionConfig(signal_delay=valeur)
def test_les_valeurs_licites_passent():
assert bt.ExecutionConfig().max_position_pct == 1.0
assert bt.ExecutionConfig(max_position_pct=0.5).max_position_pct == 0.5
assert bt.ExecutionConfig(signal_delay=0).signal_delay == 0
assert bt.ExecutionConfig(signal_delay=3).signal_delay == 3
def test_le_defaut_du_produit_tourne_toujours(store):
"""`AtClose` with `signal_delay = 0` is the shipped default, and is sound."""
result = bt.run(_reads_its_own_bar(), _config(), store)
assert abs(result.metrics["total_return"]) < 0.5
# ------------------------------------------ the guard that already existed --
def test_un_ordre_au_repos_sans_delai_reste_refuse(store):
"""The resting-order guard predates these two and must keep firing.
It reads the orders off the strategy, which is where `entry_price()` puts
them, and it raises a bare `ValueError` that `run()` then reclassifies by
keyword: "signal_delay" matches "signal", so it arrives as a
`StrategyError`. Pinned here as it stands, not as it should be; changing
the class of an error users already catch is its own change.
"""
pos = when(col("close") > col("open"), lit(1.0), lit(0.0))
strategy = (bt.Strategy.create("au_repos").signal("pos", pos)
.size(col("pos"))
.limit_entry(offset_bps=5.0))
with pytest.raises(bt.BacktesterError, match="signal_delay"):
bt.run(strategy, _config(signal_delay=0), store)