release: v0.25.0

This commit is contained in:
github-actions[bot]
2026-10-23 23:06:12 +02:00
committed by Jimmy7892
parent 4be3be7be5
commit d57b7f31ff
8 changed files with 418 additions and 23 deletions
+68 -1
View File
@@ -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
View File
@@ -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"
+16 -2
View File
@@ -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.
+69 -2
View File
@@ -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
View File
@@ -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,
+141
View File
@@ -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)
+107 -4
View File
@@ -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."
)
+3
View File
@@ -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),