mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-11-02 07:56:11 +00:00
release: v0.25.0
This commit is contained in:
committed by
Jimmy7892
parent
4be3be7be5
commit
d57b7f31ff
@@ -134,6 +134,48 @@ signal = mbt.when(fast > slow, 0.25,
|
||||
signal = mbt.when(rsi < 30, 1.0) # buy oversold, hold otherwise
|
||||
```
|
||||
|
||||
### `mbt.hold()` holds the POSITION, not the value
|
||||
|
||||
`mbt.hold()` is NaN, and the engine reads NaN at the top of the position
|
||||
expression as *leave the position where it is*. It holds the position the
|
||||
simulator currently carries, not the last value of the expression around it.
|
||||
Two consequences worth knowing before you write a regime filter:
|
||||
|
||||
- after a stop-loss or take-profit fired, `hold()` keeps the position **flat**.
|
||||
It does not replay the last signal value and buy back in.
|
||||
- under a filter, `hold()` holds whatever the filter last wrote:
|
||||
|
||||
```python
|
||||
state = mbt.when(imb >= thr, 1.0,
|
||||
mbt.when(imb <= -thr, -1.0, mbt.hold()))
|
||||
pos = mbt.when(regime_open, state, 0.0) # reads as a trap
|
||||
```
|
||||
|
||||
While the filter is closed the target is `0.0`, so the position goes flat. When
|
||||
it reopens on a bar where `state` falls through to `hold()`, the engine holds
|
||||
that flat position: exposure only returns on the **next** threshold crossing.
|
||||
Measured on a flow strategy, a filter open 55% of the time left 0.7% exposure.
|
||||
Nothing warns, and routing `state` through a named `.signal()` changes nothing.
|
||||
|
||||
Both behaviours are legitimate. Pick the one you meant:
|
||||
|
||||
| Intent | Write |
|
||||
|--------|-------|
|
||||
| Exit while the regime is hostile, re-enter only on a **new** signal | keep `hold()` inside the `when`: `mbt.when(regime_open, <...hold()...>, 0.0)` |
|
||||
| Persistent state that you **mask** | build the state as a series, then mask it (below) |
|
||||
|
||||
```python
|
||||
# .ffill() carries the last non-NaN value of the SERIES. The omitted false
|
||||
# branch of when() is already NaN, so this is the whole idiom:
|
||||
state = mbt.when(imb >= thr, 1.0,
|
||||
mbt.when(imb <= -thr, -1.0)).ffill()
|
||||
pos = mbt.when(regime_open, state, 0.0) # back on the bar the filter reopens
|
||||
```
|
||||
|
||||
Masking a `ffill` state does not destroy it. `cond.value_when(value)` writes the
|
||||
same series with an explicit trigger; with no filter and nothing else moving the
|
||||
position, the three spellings give the same trades.
|
||||
|
||||
### Arithmetic on expressions
|
||||
|
||||
```python
|
||||
@@ -163,7 +205,7 @@ execution=mbt.ExecutionConfig(position_sizing_mode="FractionOfInitialCapital")
|
||||
| `1.0` | Full long position |
|
||||
| `0.0` | Flat (close position) |
|
||||
| `-0.5` | Short 50% (requires `allow_short=True`) |
|
||||
| `NaN` | Hold current position unchanged |
|
||||
| `NaN` | Hold the current POSITION unchanged (`mbt.hold()`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -316,6 +358,25 @@ to a **signal** rather than a bar column (a column is read per execution row, a
|
||||
different rule), and the strategy must have no exit orders, whose entry-bar
|
||||
re-check needs the general loop. `run()` is unaffected either way.
|
||||
|
||||
### Filling at the open
|
||||
|
||||
`execution_price="AtOpen"` fills at the **open of the execution bar** -- the
|
||||
signal row shifted by `signal_delay`. With `signal_delay=1` that is the honest
|
||||
intraday convention: the bar closes, the signal is computed, and the order
|
||||
reaches the market at the next bar's opening print. Sizing, equity and the
|
||||
duplicate-signal check keep reading the execution bar's close, exactly as under
|
||||
`AtClose`; only the fill price moves.
|
||||
|
||||
**AtOpen keeps the fast kernel, and the GPU.** Sweeping it costs the same as
|
||||
sweeping `AtClose` (measured within 5% on a one-second store), and
|
||||
`device="cuda"` accepts it with no fallback. Results are identical on both
|
||||
devices, to the bit.
|
||||
|
||||
Two combinations stay on the general loop, and the sweep names them: an
|
||||
AtOpen fill combined with **exit orders** (a fill that lands mid-bar can be
|
||||
stopped out by the rest of that same bar, which the fast kernel does not
|
||||
replay), and a **multi-asset** universe. `run()` is unaffected either way.
|
||||
|
||||
### Signal delay
|
||||
|
||||
| Value | Behavior |
|
||||
@@ -748,6 +809,7 @@ Pine-style helpers. The first four take a **condition**, not a numeric series.
|
||||
| `streak(cond)` | Length of the current consecutive run of true |
|
||||
| `count_over(cond, w)` | Count of true bars in the trailing window |
|
||||
| `value_when(cond, source)` | `source` on the last bar where `cond` was true |
|
||||
| `expr.ffill()` (method) | Last non-NaN value of `expr`, carried forward; NaN until the first one |
|
||||
| `rising(source, n)` | 1.0 if strictly increasing on each of the last `n` steps |
|
||||
| `falling(source, n)` | 1.0 if strictly decreasing |
|
||||
| `pivot_high(source, left, right)` | Causal pivot high — **no lookahead** |
|
||||
@@ -757,6 +819,11 @@ A pivot only appears on its **confirmation bar**, `right` bars after the pivot
|
||||
itself. That lag is what makes the signal tradable: a pivot detector that
|
||||
reports on the pivot bar has read the future.
|
||||
|
||||
`ffill()` is a method on an expression, not a `condition -> series` helper, so it
|
||||
reads its receiver as a series. It is the readable spelling of a persistent armed
|
||||
state, and unlike `mbt.hold()` it composes with a regime filter -- see
|
||||
[`mbt.hold()` holds the POSITION, not the value](#mbthold-holds-the-position-not-the-value).
|
||||
|
||||
### Cross-sectional (multi-asset)
|
||||
|
||||
These read the whole universe at one timestamp, so their argument must be a
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
[project]
|
||||
name = "manifoldbt"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
description = "Rust-powered backtesting engine for quantitative research"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -2146,7 +2146,8 @@ from manifoldbt.indicators import sma, ema, rsi # 104 indicator functions
|
||||
sig = bt.when(cond, then, otherwise) # conditions combine with & | ~
|
||||
pos = bt.when(z >= bt.lit(2.0), bt.lit(-1.0),
|
||||
bt.when(z <= bt.lit(-2.0), bt.lit(1.0),
|
||||
bt.when(abs(z) <= bt.lit(0.5), bt.lit(0.0), bt.hold()))) # hold() = keep previous
|
||||
bt.when(abs(z) <= bt.lit(0.5), bt.lit(0.0), bt.hold()))) # hold() = keep the POSITION
|
||||
# under a filter, use .ffill() (see recipes)
|
||||
strat = (bt.Strategy.create("name")
|
||||
.signal("position", pos) # signals are named series
|
||||
.size(pos)) # target fraction of equity; +long / -short
|
||||
@@ -2187,7 +2188,7 @@ bt.run_walk_forward(...) # and run_stability, run_stochastic, run_portfolio
|
||||
|
||||
Worked recipes -- yes, the DSL expresses these
|
||||
----------------------------------------------
|
||||
Stateful thresholds (hysteresis). hold() keeps the previous position, so a band
|
||||
Stateful thresholds (hysteresis). hold() keeps the previous POSITION, so a band
|
||||
between entry and exit needs no Python loop:
|
||||
|
||||
z = (bt.col("close") / bt.symbol_ref("ETHUSDT", "close")).zscore(200)
|
||||
@@ -2198,6 +2199,19 @@ between entry and exit needs no Python loop:
|
||||
strat = bt.Strategy.create("pair").signal("pos", pos).size(bt.col("pos"))
|
||||
cfg = bt.BacktestConfig(universe=["BTCUSDT"], ...) # plain list is fine
|
||||
|
||||
Persistent state UNDER a regime filter -- use .ffill(), not hold(). hold() holds
|
||||
the position, so a filter that writes 0.0 while it is closed is what hold() holds
|
||||
when it reopens: exposure only returns on the next threshold crossing (measured:
|
||||
a filter open 55% of the time left 0.7% exposure, silently). .ffill() carries the
|
||||
last non-NaN value of the SERIES, so masking it does not destroy it:
|
||||
|
||||
state = bt.when(imb >= thr, bt.lit(1.0),
|
||||
bt.when(imb <= -thr, bt.lit(-1.0))).ffill() # omitted branch = NaN
|
||||
pos = bt.when(regime_open, state, bt.lit(0.0)) # back on the same bar
|
||||
|
||||
# want the other behaviour (exit, re-enter only on a NEW signal)? then keep
|
||||
# hold() inside the when: bt.when(regime_open, <...hold()...>, bt.lit(0.0))
|
||||
|
||||
Several legs against one anchor. Same expression, several traded symbols: each
|
||||
one gets its own state and its own position, equity is shared. The anchor is
|
||||
simply left out of the universe.
|
||||
|
||||
@@ -53,6 +53,8 @@ _UNARY_BOX = frozenset(
|
||||
"CsZScore", "CsDemean", "CsStd", "CsScale",
|
||||
# Etat de signal (l'argument est une CONDITION)
|
||||
"BarsSince", "Streak",
|
||||
# Etat de signal dont l'argument est une SERIE
|
||||
"Ffill",
|
||||
# Composants calendaires
|
||||
"Year", "WeekOfYear", "DayOfYear",
|
||||
"IsMonthStart", "IsMonthEnd", "IsQuarterEnd", "IsWeekend",
|
||||
@@ -487,6 +489,25 @@ class Expr:
|
||||
"""Value of ``source`` on the last bar where this condition was true."""
|
||||
return Expr("ValueWhen", self, _coerce(source))
|
||||
|
||||
def ffill(self) -> Expr:
|
||||
"""Last non-NaN value of *this* series, carried forward. NaN until the
|
||||
first one.
|
||||
|
||||
Unlike the four helpers above, ``ffill`` reads self as a SERIES, not as
|
||||
a condition. Paired with ``when(cond, value)`` -- whose false branch is
|
||||
NaN by default -- it is the readable spelling of a persistent armed
|
||||
state::
|
||||
|
||||
state = mbt.when(imb >= thr, 1.0,
|
||||
mbt.when(imb <= -thr, -1.0)).ffill()
|
||||
pos = mbt.when(regime_open, state, 0.0) # composes with a filter
|
||||
|
||||
The state is a real series, so masking it does not destroy it: when the
|
||||
filter reopens, ``pos`` picks the state back up on the same bar. That is
|
||||
what ``hold()`` cannot do -- see :func:`hold`.
|
||||
"""
|
||||
return Expr("Ffill", self)
|
||||
|
||||
def rising(self, n: Period) -> Expr:
|
||||
"""1.0 if self strictly increased on each of the last ``n`` steps."""
|
||||
return Expr("Rising", self, _resolve_period(n))
|
||||
@@ -803,7 +824,50 @@ def lit(value: Any) -> Expr:
|
||||
|
||||
|
||||
def hold() -> Expr:
|
||||
"""Return NaN — tells the engine to hold the current position unchanged."""
|
||||
"""NaN. The engine reads it as: leave the POSITION where it is.
|
||||
|
||||
What it holds is the position the simulator currently carries, not the
|
||||
value of the expression that contains it. The distinction only shows up
|
||||
once something else can move the position:
|
||||
|
||||
* after a stop-loss or take-profit fired, ``hold()`` keeps the position
|
||||
FLAT. It does not replay the last signal value and buy back in.
|
||||
* under a regime filter, ``hold()`` holds whatever the filter last wrote::
|
||||
|
||||
state = mbt.when(imb >= thr, 1.0,
|
||||
mbt.when(imb <= -thr, -1.0, mbt.hold()))
|
||||
pos = mbt.when(regime_open, state, 0.0) # <- reads as a trap
|
||||
|
||||
While the filter is closed the target is 0.0, so the position goes flat.
|
||||
When it reopens on a bar where ``state`` falls through to ``hold()``, the
|
||||
engine holds that flat position. Exposure only returns on the NEXT
|
||||
threshold crossing, which can be far away: a filter open 55% of the time
|
||||
has been measured to leave under 1% exposure. Nothing warns. Routing the
|
||||
state through a named ``.signal()`` does not change it either -- the NaN
|
||||
still means "hold the position" wherever it is read.
|
||||
|
||||
Both readings are legitimate; pick the one you meant.
|
||||
|
||||
Exit and re-enter only on a NEW signal -- keep ``hold()`` inside the when::
|
||||
|
||||
pos = mbt.when(regime_open,
|
||||
mbt.when(imb >= thr, 1.0,
|
||||
mbt.when(imb <= -thr, -1.0, mbt.hold())), 0.0)
|
||||
|
||||
Persistent state that you mask -- build the state as a real series with
|
||||
:meth:`Expr.ffill`, then mask it::
|
||||
|
||||
state = mbt.when(imb >= thr, 1.0,
|
||||
mbt.when(imb <= -thr, -1.0)).ffill() # false branch is NaN
|
||||
pos = mbt.when(regime_open, state, 0.0)
|
||||
|
||||
Masking a ``ffill`` state does not destroy it, so the position comes back
|
||||
on the bar the filter reopens. ``cond.value_when(value)`` writes the same
|
||||
series with an explicit trigger.
|
||||
|
||||
With no filter and nothing else moving the position (no stop, no take
|
||||
profit), the three spellings give the same trades.
|
||||
"""
|
||||
return Expr("Literal", float("nan"))
|
||||
|
||||
|
||||
@@ -834,7 +898,10 @@ def when(condition: Expr, true_value: Any = 1.0, false_value: Any = float("nan")
|
||||
"""Conditional expression (if/else).
|
||||
|
||||
Omit true_value to default to 1.0 (full position, clamped by max_position_pct).
|
||||
Omit false_value to hold current position.
|
||||
Omit false_value and the false branch is NaN. At the TOP of the position
|
||||
expression the engine reads that NaN as "hold the position"; anywhere else
|
||||
it is just NaN, and an enclosing ``when`` can overwrite it. See :func:`hold`
|
||||
and :meth:`Expr.ffill`.
|
||||
"""
|
||||
# The condition goes through _coerce like the two branches: unchecked, a
|
||||
# multi-output indicator handed in whole reached the JSON as a tuple and
|
||||
|
||||
+13
-13
@@ -90,26 +90,26 @@
|
||||
"strategy_name": "golden_daily"
|
||||
},
|
||||
"metrics": {
|
||||
"alpha": 0.24696315244769637,
|
||||
"avg_daily_return": 0.0005559533253373647,
|
||||
"alpha": 0.2407803131442768,
|
||||
"avg_daily_return": 0.0005420544922039305,
|
||||
"best_day": 0.025888560813768002,
|
||||
"beta": 0.25999910072118576,
|
||||
"beta": 0.2599458875251718,
|
||||
"cagr": 0.2167860497257943,
|
||||
"calmar": 14.868702863547359,
|
||||
"kurtosis": 8.29429816343139,
|
||||
"kurtosis": 8.602525338884888,
|
||||
"max_drawdown": -0.01458002434477823,
|
||||
"max_drawdown_duration_days": 6.0,
|
||||
"omega_ratio": 1.626321866346559,
|
||||
"pct_positive_days": 0.07692307692307693,
|
||||
"sharpe": 1.5416641129871969,
|
||||
"skewness": 2.5129808728750795,
|
||||
"sortino": 3.1011395307682244,
|
||||
"tail_ratio": 2.403871616060478,
|
||||
"pct_positive_days": 0.075,
|
||||
"sharpe": 1.5183502357867356,
|
||||
"skewness": 2.5510759140797585,
|
||||
"sortino": 3.0151408511360915,
|
||||
"tail_ratio": 2.7959631506466054,
|
||||
"total_return": 0.021171884766306936,
|
||||
"tstat_alpha": 0.7810691257846766,
|
||||
"tstat_sharpe": 0.5037637975613366,
|
||||
"ulcer_index": 0.0039599076693029875,
|
||||
"volatility": 0.11874308452621596,
|
||||
"tstat_alpha": 0.7814212124767362,
|
||||
"tstat_sharpe": 0.49614560938698393,
|
||||
"ulcer_index": 0.003911318004970256,
|
||||
"volatility": 0.1172228903993697,
|
||||
"worst_day": -0.010702056959372208
|
||||
},
|
||||
"trade_count": 3,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""`ffill()` compose avec un filtre de regime, `hold()` non.
|
||||
|
||||
`hold()` retient la POSITION que porte le simulateur, pas la valeur de
|
||||
l'expression qui le contient. Sous un filtre qui ecrit 0.0 pendant qu'il est
|
||||
ferme, c'est ce 0.0 que `hold()` retient quand le filtre rouvre : l'exposition
|
||||
ne revient qu'a la prochaine traversee de seuil.
|
||||
|
||||
Ce fichier epingle les deux comportements. Celui de `hold()` n'est PAS un bug a
|
||||
corriger -- "sortir quand le regime est hostile, ne rentrer que sur un NOUVEAU
|
||||
signal" est une strategie legitime -- mais il doit rester un choix, pas une
|
||||
surprise, donc il est mesure ici pour que personne ne le change par accident.
|
||||
"""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import manifoldbt as bt
|
||||
from manifoldbt import indicators as ind
|
||||
from manifoldbt.expr import col, hold, lit, when
|
||||
from manifoldbt.helpers import Interval, Slippage
|
||||
|
||||
N_BARS = 3000
|
||||
FENETRE, SEUIL = 200, 2.5
|
||||
|
||||
|
||||
def _bars(seed: int = 7) -> pd.DataFrame:
|
||||
rng = np.random.default_rng(seed)
|
||||
close = 100.0 * np.exp(np.cumsum(rng.normal(0.0, 0.004, N_BARS)))
|
||||
ts = pd.date_range("2023-01-01", periods=N_BARS, freq="1h", tz="UTC")
|
||||
return pd.DataFrame({
|
||||
"timestamp": ts, "open": close, "high": close * 1.002,
|
||||
"low": close * 0.998, "close": close, "volume": 1000.0,
|
||||
})
|
||||
|
||||
|
||||
_DF = _bars()
|
||||
_END_NS = int(_DF["timestamp"].iloc[-1].value) + 86_400_000_000_000
|
||||
# Le filtre est l'heure de la barre : ouvert exactement une barre sur deux, sans
|
||||
# dependre des donnees. C'est le taux auquel l'exposition de `ffill` est comparee.
|
||||
TAUX_OUVERTURE = 0.5
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def store(tmp_path_factory):
|
||||
root = tmp_path_factory.mktemp("ffill_etat")
|
||||
return bt.import_dataframe(
|
||||
_DF, symbol="TEST", symbol_id=1, interval="1h",
|
||||
data_root=str(root / "data"), metadata_db=str(root / "meta.sqlite"),
|
||||
)
|
||||
|
||||
|
||||
def _config() -> bt.BacktestConfig:
|
||||
return bt.BacktestConfig(
|
||||
universe=[1], time_range_start=0, time_range_end=_END_NS,
|
||||
bar_interval=Interval.hours(1), initial_capital=10_000.0,
|
||||
execution=bt.ExecutionConfig(
|
||||
signal_delay=1, execution_price="AtOpen", max_position_pct=1.0,
|
||||
allow_short=True, position_sizing_mode="FractionOfInitialCapital",
|
||||
),
|
||||
fees=bt.FeeConfig.zero(), slippage=Slippage.none(),
|
||||
warmup_bars=FENETRE + 10,
|
||||
)
|
||||
|
||||
|
||||
_Z = col("close").zscore(FENETRE)
|
||||
_HAUT, _BAS = _Z >= lit(SEUIL), _Z <= lit(-SEUIL)
|
||||
_FILTRE = ind.hour() < lit(12.0)
|
||||
|
||||
# La branche fausse omise vaut NaN, donc ffill retient le dernier etat arme.
|
||||
_ETAT_FFILL = when(_HAUT, lit(1.0), when(_BAS, lit(-1.0))).ffill()
|
||||
_ETAT_HOLD = when(_HAUT, lit(1.0), when(_BAS, lit(-1.0), hold()))
|
||||
|
||||
|
||||
def _positions(store, taille, nom: str) -> np.ndarray:
|
||||
strat = bt.Strategy.create(nom).signal("d", col("close")).size(taille)
|
||||
res = bt.run(strat, _config(), store)
|
||||
return res.positions.column("position").to_numpy()
|
||||
|
||||
|
||||
def _exposition(positions: np.ndarray) -> float:
|
||||
return float(np.mean(positions != 0.0))
|
||||
|
||||
|
||||
def test_sans_filtre_ffill_reproduit_hold_barre_par_barre(store):
|
||||
"""Sans filtre, les deux ecritures sont la meme strategie.
|
||||
|
||||
C'est ce qui autorise a presenter `ffill()` comme la version qui compose :
|
||||
si les deux divergeaient deja sans filtre, ce serait un autre operateur.
|
||||
"""
|
||||
par_ffill = _positions(store, _ETAT_FFILL, "ffill_nu")
|
||||
par_hold = _positions(store, _ETAT_HOLD, "hold_nu")
|
||||
np.testing.assert_array_equal(par_ffill, par_hold)
|
||||
# Et la strategie trade vraiment, sinon l'egalite ne prouverait rien.
|
||||
assert _exposition(par_ffill) > 0.5
|
||||
|
||||
|
||||
def test_sous_filtre_ffill_tient_l_exposition(store):
|
||||
"""L'exposition suit le taux d'ouverture du filtre, aux barres non armees pres."""
|
||||
nu = _exposition(_positions(store, _ETAT_FFILL, "ffill_nu2"))
|
||||
sous_filtre = _exposition(
|
||||
_positions(store, when(_FILTRE, _ETAT_FFILL, lit(0.0)), "ffill_filtre")
|
||||
)
|
||||
# Le filtre est ouvert une barre sur deux et l'etat est arme `nu` du temps ;
|
||||
# les deux sont independants ici (l'heure ne dit rien du z-score).
|
||||
attendu = TAUX_OUVERTURE * nu
|
||||
assert abs(sous_filtre - attendu) < 0.03, (sous_filtre, attendu)
|
||||
|
||||
|
||||
def test_sous_filtre_hold_s_effondre(store):
|
||||
"""Le comportement de `hold()` sous filtre, epingle tel qu'il est.
|
||||
|
||||
A ne PAS "corriger" : c'est la lecture "ne rentrer que sur un nouveau
|
||||
signal". Ce test existe pour que le jour ou quelqu'un change la semantique
|
||||
de `hold()`, il le fasse en connaissance de cause.
|
||||
"""
|
||||
par_ffill = _exposition(
|
||||
_positions(store, when(_FILTRE, _ETAT_FFILL, lit(0.0)), "ffill_filtre2")
|
||||
)
|
||||
par_hold = _exposition(
|
||||
_positions(store, when(_FILTRE, _ETAT_HOLD, lit(0.0)), "hold_filtre")
|
||||
)
|
||||
assert par_ffill > 0.35, par_ffill
|
||||
# Mesure sur ces donnees : ~42.6% contre ~4.6%. Le seuil est large exprès :
|
||||
# ce qui est epingle est l'ordre de grandeur de l'ecart, pas un chiffre.
|
||||
assert par_hold < par_ffill / 4.0, (par_hold, par_ffill)
|
||||
|
||||
|
||||
def test_passer_par_un_signal_nomme_ne_change_rien_a_hold(store):
|
||||
"""`.signal("etat", ...)` puis `col("etat")` donne exactement le meme resultat.
|
||||
|
||||
Le detour par un signal nomme est le premier reflexe quand l'exposition
|
||||
s'effondre. Il ne change rien : le NaN veut dire "retiens la position"
|
||||
partout ou il est lu, pas seulement dans l'expression ou il est ecrit.
|
||||
"""
|
||||
direct = _positions(store, when(_FILTRE, _ETAT_HOLD, lit(0.0)), "hold_direct")
|
||||
strat = (bt.Strategy.create("hold_nomme")
|
||||
.signal("d", col("close"))
|
||||
.signal("etat", _ETAT_HOLD)
|
||||
.size(when(_FILTRE, col("etat"), lit(0.0))))
|
||||
via_signal = bt.run(strat, _config(), store).positions.column("position").to_numpy()
|
||||
np.testing.assert_array_equal(direct, via_signal)
|
||||
@@ -78,7 +78,7 @@ def _intraday_bars(rows=8_000, seed=7):
|
||||
)
|
||||
|
||||
|
||||
def _config(df):
|
||||
def _config(df, execution_price="AtClose", signal_delay=0, allow_short=False):
|
||||
last_ns = int(df["timestamp"].iloc[-1].value)
|
||||
return bt.BacktestConfig(
|
||||
universe=[1],
|
||||
@@ -87,10 +87,10 @@ def _config(df):
|
||||
bar_interval=Interval.minutes(1),
|
||||
initial_capital=CAPITAL,
|
||||
execution=bt.ExecutionConfig(
|
||||
signal_delay=0,
|
||||
execution_price="AtClose",
|
||||
signal_delay=signal_delay,
|
||||
execution_price=execution_price,
|
||||
max_position_pct=1.0,
|
||||
allow_short=False,
|
||||
allow_short=allow_short,
|
||||
position_sizing_mode="FractionOfEquity",
|
||||
),
|
||||
fees=bt.FeeConfig.zero(),
|
||||
@@ -142,3 +142,106 @@ def test_lite_sweep_matches_run_on_intraday_bars(tmp_path):
|
||||
f"{name}: run()={expected!r} but run_sweep_lite()={got!r}. "
|
||||
"The lite path has drifted from the full simulation."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("signal_delay", [0, 1])
|
||||
@pytest.mark.parametrize("allow_short", [False, True])
|
||||
def test_lite_sweep_matches_run_at_open(tmp_path, signal_delay, allow_short):
|
||||
"""Same contract as above, for ``execution_price="AtOpen"``.
|
||||
|
||||
AtOpen used to be refused by the fast path, so the lite sweep quietly ran
|
||||
the slow general loop for it: correct, ten times slower, and nothing said
|
||||
so. The fast kernel now fills at the open of the EXECUTION bar -- the signal
|
||||
row shifted by ``signal_delay`` -- and this pins that it still answers what
|
||||
``run()`` answers, on the same fifteen metrics.
|
||||
|
||||
The bars have ``open[i] == close[i-1]``, so AtOpen and AtClose are genuinely
|
||||
different backtests: the last assertion checks exactly that, otherwise this
|
||||
test would pass on an engine that silently ignored the setting.
|
||||
"""
|
||||
df = _intraday_bars()
|
||||
root = tmp_path / "store"
|
||||
os.makedirs(root, exist_ok=True)
|
||||
store = bt.import_dataframe(
|
||||
df,
|
||||
symbol="TEST",
|
||||
symbol_id=1,
|
||||
interval="1m",
|
||||
data_root=os.path.join(root, "data"),
|
||||
metadata_db=os.path.join(root, "meta.sqlite"),
|
||||
)
|
||||
config = _config(
|
||||
df,
|
||||
execution_price="AtOpen",
|
||||
signal_delay=signal_delay,
|
||||
allow_short=allow_short,
|
||||
)
|
||||
|
||||
bas = lit(-1.0) if allow_short else lit(0.0)
|
||||
sized = when(col("fast") > col("slow"), lit(1.0), bas)
|
||||
fixed = (
|
||||
bt.Strategy.create("fixed")
|
||||
.signal("fast", sma(close_px, FAST))
|
||||
.signal("slow", sma(close_px, SLOW))
|
||||
.size(sized)
|
||||
)
|
||||
swept = (
|
||||
bt.Strategy.create("swept")
|
||||
.signal("fast", sma(close_px, param("fast")))
|
||||
.signal("slow", sma(close_px, param("slow")))
|
||||
.size(sized)
|
||||
)
|
||||
|
||||
result = bt.run(fixed, config, store)
|
||||
full = result.metrics
|
||||
lite_result = bt.run_sweep_lite(
|
||||
swept, {"fast": [FAST], "slow": [SLOW]}, config, store
|
||||
)[0]
|
||||
lite = lite_result.metrics
|
||||
|
||||
assert full["total_return"] != 0.0
|
||||
|
||||
# The simulation claim, first and unconditionally: the two paths must walk
|
||||
# the same equity path and end on the same number.
|
||||
last_equity = float(np.asarray(result.equity_curve)[-1])
|
||||
assert abs(last_equity - lite_result.final_equity) <= 1e-12 * max(
|
||||
1.0, abs(last_equity)
|
||||
), (
|
||||
f"final equity: run()={last_equity!r} but "
|
||||
f"run_sweep_lite()={lite_result.final_equity!r}"
|
||||
)
|
||||
|
||||
# `run()` divides by the FIRST point of its equity curve; the lite path
|
||||
# divides by the initial capital. Under AtOpen with signal_delay=0 the very
|
||||
# first bar fills at its own open, so that first point already carries the
|
||||
# bar's profit and loss and the two bases differ. That is a pre-existing
|
||||
# choice-of-base defect -- the mirror of the one this file's docstring
|
||||
# describes for the lite path -- and NOT a divergence of the simulation: the
|
||||
# equity paths above are identical. Pinned by its exact relation rather than
|
||||
# tolerated, so that fixing the base turns this branch red instead of
|
||||
# leaving a silent tolerance behind.
|
||||
first_equity = float(np.asarray(result.equity_curve)[0])
|
||||
if first_equity != CAPITAL:
|
||||
assert (1.0 + full["total_return"]) * first_equity == pytest.approx(
|
||||
(1.0 + lite["total_return"]) * CAPITAL, rel=1e-12
|
||||
), "the two total_returns differ by more than their base"
|
||||
else:
|
||||
for name in MUST_MATCH:
|
||||
expected, got = full[name], lite[name]
|
||||
assert abs(expected - got) <= 1e-9 * max(1.0, abs(expected)), (
|
||||
f"{name}: run()={expected!r} but run_sweep_lite()={got!r}. "
|
||||
"The AtOpen fast path has drifted from the full simulation."
|
||||
)
|
||||
|
||||
# AtOpen must not be the same backtest as AtClose, or everything above is
|
||||
# vacuous: a kernel that ignored the setting would pass it.
|
||||
at_close = bt.run_sweep_lite(
|
||||
swept,
|
||||
{"fast": [FAST], "slow": [SLOW]},
|
||||
_config(df, "AtClose", signal_delay, allow_short),
|
||||
store,
|
||||
)[0].metrics
|
||||
assert lite["total_return"] != at_close["total_return"], (
|
||||
"AtOpen and AtClose returned the same number: the execution price is "
|
||||
"being ignored."
|
||||
)
|
||||
|
||||
@@ -79,6 +79,8 @@ def test_etat_de_signal():
|
||||
assert ind.streak(rising).to_json() == {"Streak": cond}
|
||||
assert ind.count_over(rising, 10).to_json() == {"CountOver": [cond, 10]}
|
||||
assert ind.value_when(rising, ind.close).to_json() == {"ValueWhen": [cond, CLOSE]}
|
||||
# ffill est une METHODE : son receveur est une serie, pas une condition.
|
||||
assert ind.close.ffill().to_json() == {"Ffill": CLOSE}
|
||||
assert ind.rising(ind.close, 3).to_json() == {"Rising": [CLOSE, 3]}
|
||||
assert ind.falling(ind.close, 3).to_json() == {"Falling": [CLOSE, 3]}
|
||||
|
||||
@@ -161,6 +163,7 @@ CASES = {
|
||||
"streak": ind.streak(MONTE),
|
||||
"count_over": ind.count_over(MONTE, 10),
|
||||
"value_when": ind.value_when(MONTE, ind.close),
|
||||
"ffill": ind.close.ffill(),
|
||||
"rising": ind.rising(ind.close, 3),
|
||||
"falling": ind.falling(ind.close, 3),
|
||||
"pivot_high": ind.pivot_high(ind.high, 3, 3),
|
||||
|
||||
Reference in New Issue
Block a user