release: v0.15.0

This commit is contained in:
github-actions[bot]
2026-08-16 12:02:58 +00:00
parent 1bc5fe3038
commit 5452806314
12 changed files with 1783 additions and 19 deletions
+263
View File
@@ -0,0 +1,263 @@
# Plan — ordres d'entrée conditionnels (market / limit / stop à prix déterminé)
Branche : `claude/limit-market-order-entries-mqvww2` (depuis `main` @ `c483032`, 0.14.1).
## État d'avancement
| Phase | Statut |
|---|---|
| 0 — garde-fous perf | fait (baseline benchée, goldens verts) |
| 1 — modèle de prix d'entrée (boucles générales) | **fait** |
| 2 — surface utilisateur, validation, observabilité | **fait** |
| 3 — ordre au repos dans le kernel rapide + CUDA | **pas fait** — voir §3 |
| 3b — prix piloté par série sur GPU | pas fait |
| 4 — mesure | fait — `perf/entry-orders.md` |
Ce qui est livré couvre les quatre déclencheurs (`Limit`, `Stop`, `StopLimit`,
`MarketIfTouched`), les trois formes de prix (`OffsetBps`, `Absolute`, `Signal`), le
time-in-force existant, le sizing optionnel au prix de l'ordre, la validation à la
compilation et le décompte des ordres non remplis. Une entrée conditionnelle reste hors du
kernel rapide : `fast_path_blocker` continue de la refuser et de le dire.
But : permettre à l'utilisateur de décrire **où** son entrée se remplit — pas seulement
« au close de la barre suivante » — sans casser la vitesse ni les résultats existants.
Contrainte cardinale : **zéro coût quand la fonctionnalité n'est pas utilisée**, et pas de
quatrième transcription de la règle de fill (cf. `refactor(engine): one fill/sizing rule
instead of three transcriptions`).
---
## 1. État des lieux
### Ce qui existe
`OrderConfig.limit_entry` (`crates/bt-core/src/orders.rs:27`) :
```rust
pub struct LimitOrderConfig {
pub offset_bps: f64, // distance depuis le close de la barre de SIGNAL
pub time_in_force: TimeInForce, // GTC | GTB(n) | IOC
}
```
Réellement branché, sur les deux boucles générales :
| Élément | Emplacement |
|---|---|
| Calcul du prix limite (close du signal, pas de look-ahead) | `orchestrator.rs:1628-1638`, `:2961-2969` |
| Gate de fill (`low <= limit` achat, `high >= limit` vente) | `orchestrator.rs:1679-1704` |
| TIF (IOC annule, GTB(n) expire, GTC persiste) | `orchestrator.rs:1689-1701` |
| Fill au prix limite, frais **maker**, slippage nul | `orchestrator.rs:1751-1762` |
| État de l'ordre au repos | `PendingOrder`, `orchestrator.rs:442-462` |
| Bracket SL/TP armé dès la première fraction remplie | `orchestrator.rs:1791-1800` |
| Tests | `crates/bt-core/tests/backtest_orders.rs:601`, `:647`, `:687` |
Exposition : dict brut via `ExecutionConfig.orders` (`config.py:24`) ou champ `orders` du
StrategyDef (`compiler.rs:24`), résolu par `effective_orders` (`orchestrator.rs:4578`) —
le per-stratégie écrase le global.
### Ce qui manque
1. **Aucun prix absolu ni piloté par une expression.** Seul « X bps sous le close du signal »
est exprimable. Pas de « achète à 60 000 », pas de « achète sur l'EMA20 / le VWAP /
`close - 2*atr(14)` / le low de la veille ».
2. **Pas de stop entry (breakout).** Le sens est codé en dur en passif
(`orchestrator.rs:1682-1686`).
3. **Pas de gestion de gap à l'entrée.** Le fill se fait au prix limite exact, sans regarder
l'open. Pour un *limit* c'est conservateur (on paye la limite alors que le gap aurait donné
mieux) — acceptable. Pour un *stop* ce serait faux et **optimiste** : la règle gap-aware
existe déjà côté sortie (`sim_fast_lite_core_single`, `orchestrator.rs:5931-5946`) et doit
être réutilisée telle quelle.
4. **Pas de méthode dans le builder Python.** `Strategy` a `.stop_loss()`, `.take_profit()`,
`.trailing_stop()` (`strategy.py:124-160`) mais rien côté entrée.
5. **`ExitReason::LimitExpiry` déclaré et jamais émis** (`orders.rs:124`) : un ordre annulé
disparaît sans trace. L'utilisateur ne peut pas savoir que sa stratégie n'a jamais été
remplie.
6. **Le sizing ignore le prix de fill.** `sanitize_target` reçoit le close de la barre courante
(`orchestrator.rs:1618-1625`) : en `FractionOfEquity`, une entrée limite à 2 % sous le close
achète 2 % de notionnel de trop.
### Le vrai coût : le fast path
`fast_path_blocker` refuse **toute** limit entry, sans condition :
```rust
// orchestrator.rs:4927
if orders.limit_entry.is_some() {
return Some("the strategy has a conditional entry order (limit / stop)");
}
```
Commentaire à l'appui (`orchestrator.rs:4612`) : *« A LIMIT ENTRY blocks regardless: it can rest
unfilled across bars, which the single-bar fill assumption here cannot express. »*
Conséquence : une entrée conditionnelle sort du kernel rapide **et** du GPU. Sur un sweep, c'est
le facteur ~25× qui disparaît. Si on rend la fonctionnalité attrayante sans toucher à ça, les
utilisateurs vont la mettre partout et perdre la vitesse qui est l'argument du produit.
Les trois niveaux d'exécution à garder en tête :
| Niveau | Fonction | Ordres au repos ? |
|---|---|---|
| Boucle générale (`run`, `run_lite_on_aligned`) | `orchestrator.rs:1357`, `:2154` | oui |
| Kernels rapides CPU | `simulate_fast:4995`, `simulate_fast_lite:5225`, `sim_fast_lite_core_single:5869` | non |
| Kernel CUDA (transpilé 1:1 du précédent) | `gpu_sweep.rs:1115` (`bracket_src`) | non |
---
## 2. Les possibilités (espace de conception)
### Axe A — déclencheur
| Mode | Sémantique | Frais | Statut |
|---|---|---|---|
| `Market` | fill à la barre d'exécution selon `execution_price` | taker + slippage | existe (défaut) |
| `Limit` | fill si le prix **touche** le niveau (passif) | maker, pas de slippage | existe |
| `Stop` | fill si le prix **franchit** le niveau (breakout) | taker + slippage + gap | **à faire** |
| `StopLimit` | déclenché au stop, rempli au mieux à la limite | maker | optionnel, phase ultérieure |
`Stop` est le symétrique exact du `check_stop` de sortie ; le code de trigger et de gap existe
déjà, il n'y a qu'à l'appeler côté entrée.
### Axe B — ancrage du prix
| Variante | Exemple | Coût runtime |
|---|---|---|
| `OffsetBps(f64)` | `-25 bps sous le close du signal` | nul (existe) |
| `Absolute(f64)` | `60_000.0` | nul (scalaire) |
| `Signal(String)` | `"entry_px"` avec `entry_px: "ema(close,20)"` | une slice `&[f64]` de plus |
`Signal` couvre tout le reste (ATR, VWAP, plus-haut de N barres, niveau de Fibonacci, prix
externe injecté en colonne exo) sans inventer de mini-langage : la série est déjà matérialisée
dans `symbol_envs` au moment où le sizing est évalué (`orchestrator.rs:1140-1175`), il suffit de
l'extraire comme on extrait `position_sizing`, de la passer dans `expand_to_fine_resolution` et
de la slicer par symbole comme `target_slices` (`orchestrator.rs:1314`).
`Absolute` est redondant avec `Signal` (`entry_px: "60000"`) mais reste utile : c'est un
scalaire, donc il ne bloque pas le GPU et ne coûte pas une série.
### Axe C — durée de vie
`GTC` / `GTB(n)` / `IOC` existent et suffisent. À ajouter seulement :
- annulation quand le signal s'inverse (aujourd'hui l'override existe mais n'est pas explicite,
`orchestrator.rs:1575-1594`) ;
- **observabilité** de l'annulation (voir §3 phase 2).
### Axe D — sizing
Deux comportements, à rendre explicite :
- `size_at_signal_close` (actuel, rétro-compatible) ;
- `size_at_fill_price` : la quantité est calculée sur le prix limite, ce que l'utilisateur
attend quasi toujours.
Défaut proposé : garder l'actuel pour ne rien casser, exposer l'option, la documenter.
---
## 3. Plan par phases
### Phase 0 — garde-fous (avant toute ligne de feature)
- `cargo bench -p bt-core` : figer `single_asset_50k`, `sweep_1000`, `multi_asset_10sym`
(méthodo min-médiane sur 3 passes, cf. `perf/BASELINE.md`).
- Test de non-régression « zéro coût » : une stratégie sans `orders` doit produire un résultat
**bit-à-bit** identique avant/après. Les tests de parité existent déjà
(`test_fast_lite_core_matches_simulate_fast_lite`, `orchestrator.rs:6844`) — les étendre.
### Phase 1 — modèle de prix d'entrée (boucle générale, CPU)
```rust
pub enum EntryPrice {
OffsetBps(f64),
Absolute(f64),
Signal(String),
}
pub enum EntryTrigger { Market, Limit, Stop }
pub struct EntryOrderConfig {
pub price: EntryPrice,
pub trigger: EntryTrigger,
pub time_in_force: TimeInForce,
pub size_at_fill_price: bool,
}
```
- Rétro-compatibilité serde : `{"offset_bps": 10.0, "time_in_force": "GTC"}` continue de
désérialiser en `Limit` + `OffsetBps` (alias serde + `#[serde(default)]`).
- Résolution du prix : une seule fonction `resolve_entry_price(...) -> f64`, appelée depuis les
**deux** boucles générales — pas deux transcriptions.
- Trigger `Stop` : réutiliser la règle gap-aware de la sortie, sans la recopier.
- Validation : `Signal(name)` inconnu → erreur dans `validate_strategy`
(`crates/bt-strategy/src/validate.rs`), pas un NaN silencieux à l'exécution.
- Tests : fill, non-fill, expiration, gap, short, multi-bar fill, `Signal` piloté par indicateur.
### Phase 2 — surface utilisateur
- `Strategy.limit_entry(...)`, `.stop_entry(...)`, `.market_entry(...)` (`strategy.py`), sur le
modèle exact de `.stop_loss()`.
- `OrderConfig` typé côté Python plutôt que dict brut (`config.py:10-56`).
- Émission de `ExitReason::LimitExpiry` : une entrée annulée doit être visible. Comme elle ne
produit pas de trade, l'exposer via un compteur (`orders_cancelled`) dans le résultat + un
warning quand le taux de non-remplissage dépasse un seuil — un backtest « parfait » qui n'a
jamais rempli est le piège n°1 des entrées limites.
- Doc : section dédiée dans `docs/strategy-authoring.md`, plus un exemple dans `examples/`.
### Phase 3 — perf : l'ordre au repos dans le kernel rapide
C'est la phase qui protège la vitesse. Même patron que `LiteBracket` (`orchestrator.rs:5751`) :
struct plate `Copy`, `NaN` = absent, pas d'enum, pas de branche quand la feature est absente.
```rust
#[derive(Clone, Copy, Default)]
struct LitePending {
limit_price: f64, // NaN = pas d'ordre au repos
remaining: f64,
bars_alive: f64,
is_buy: f64, // encodé en f64 pour rester transpilable
}
```
- Ajouter au `sim_fast_lite_core_single` en `Option<...>` comme `exits`, pour que le chemin
sans ordre reste byte-for-byte identique.
- Élargir `fast_path_blocker` : accepter `Limit`/`Stop` quand le prix est `OffsetBps` ou
`Absolute`. Garder le blocage pour `Signal` **tant que** la série n'est pas uploadée
(phase 3b), et surtout garder le message explicite — c'est ce qui dit à l'utilisateur ce
qu'il paye.
- CUDA : ajouter un `pending_src` sur le modèle de `bracket_src` (`gpu_sweep.rs:1115`) et étendre
`pack_cfg` (`gpu_sweep.rs:1084`). Ordre des opérations FP **strictement** préservé, sinon la
parité bit-à-bit saute.
- Tests de parité CPU/GPU étendus aux ordres au repos.
### Phase 3b (optionnelle) — prix d'entrée piloté par série sur GPU
Uploader la série `Signal(name)` comme une colonne de plus, au même titre que les closes. Ne le
faire que si la mesure de la phase 4 montre que `Signal` est le cas d'usage dominant.
### Phase 4 — mesure et documentation
- Re-bench, comparaison à la baseline de la phase 0, seuil de régression accepté : **0 %** sur
le chemin sans ordres, à documenter sur le chemin avec ordres.
- Consigner dans `perf/` comme les campagnes précédentes.
---
## 4. Risques
| Risque | Mitigation |
|---|---|
| Divergence FP CPU/CUDA (kernel transpilé à la main) | une seule règle partagée, ordre des opérations figé, tests de parité étendus |
| Régression silencieuse sur le chemin sans ordres | golden bit-à-bit en phase 0, `Option` partout |
| Utilisateur qui perd le fast path sans le savoir | message de `fast_path_blocker` explicite, déjà surfacé par le sweep GPU |
| Backtest flatteur parce que rien n'a été rempli | compteur d'annulations + warning (phase 2) |
| Sizing incohérent avec le prix de fill | option explicite, défaut inchangé |
## 5. Arbitrages à trancher
1. **Stop entry (breakout) dans le lot initial ?** Le code de trigger et de gap existe côté
sortie, le coût marginal est faible — recommandé oui.
2. **Phase 3 (kernel rapide + CUDA) maintenant ou après retour utilisateur ?** C'est la moitié
de l'effort. Sans elle, la fonctionnalité marche mais coûte le fast path.
3. **`size_at_fill_price` par défaut ?** Plus juste, mais change les résultats des stratégies
existantes utilisant `limit_entry`.
+87 -7
View File
@@ -15,13 +15,14 @@ This guide describes how to define trading strategies using the manifoldbt Pytho
5. [Backtest Configuration](#backtest-configuration)
6. [Execution Model](#execution-model)
7. [Fee & Slippage Models](#fee--slippage-models)
8. [Orders (SL/TP/Trailing)](#orders-sltp-trailing)
9. [Cross-Asset References](#cross-asset-references)
10. [Dataset Auto-Resolution](#dataset-auto-resolution)
11. [Diagnostics](#diagnostics)
12. [Profiling](#profiling)
13. [Complete Examples](#complete-examples)
14. [Indicator Reference](#indicator-reference)
8. [Orders (SL/TP/Trailing)](#orders-sltptrailing)
9. [Entry Orders](#entry-orders)
10. [Cross-Asset References](#cross-asset-references)
11. [Dataset Auto-Resolution](#dataset-auto-resolution)
12. [Diagnostics](#diagnostics)
13. [Profiling](#profiling)
14. [Complete Examples](#complete-examples)
15. [Indicator Reference](#indicator-reference)
---
@@ -195,6 +196,11 @@ best = sweep.best("sharpe")
batch = mbt.run_sweep_lite(strategy, {"fast": range(5, 100), "slow": range(10, 500)}, config, store)
```
Grids this size need Pro. Community is capped at 256 backtests cumulatively per
Python session across all sweep/batch calls, and each sweep call waits 5 s
before starting; single `bt.run()` calls are never gated. See
`docs/sweep-combo-limit-plan.md`.
`run_sweep_lite` is optimized for large parameter grids (100k+ combos):
- Cartesian product expansion in Rust (no Python loop)
- Shared indicator cache (EMA(12) computed once, reused across combos)
@@ -312,6 +318,80 @@ strategy = (
---
## Entry Orders
By default an entry takes a market fill on the execution bar (see
[Execution Model](#execution-model)). Four order types let the entry rest at a
price instead:
| Builder method | Fills when | Fill price | Costs |
|---|---|---|---|
| `.limit_entry(...)` | price comes **to** the level | the level exactly | maker, no slippage |
| `.stop_entry(...)` | price breaks **through** the level | the level, or the open if the bar gapped through it | taker + slippage |
| `.market_if_touched(...)` | price comes **to** the level | the level | taker + slippage |
| `.stop_limit_entry(...)` | breaks through `stop`, then rests at `limit` | the limit | maker, no slippage |
### Where the level comes from
Every method takes exactly one of three price forms:
```python
.limit_entry(offset_bps=25) # 25 bps below the signal close (above, for a sell)
.limit_entry(price=60_000) # a fixed level
.limit_entry(signal="entry_px") # a level this strategy computes
```
`signal=` is the general form: name any signal the strategy defines and the
order rests on that series, read on the signal bar.
```python
from manifoldbt.indicators import atr, close, ema
trend = ema(close, 50)
entry_px = close - atr(14) # rest one ATR below the close
strategy = (
mbt.Strategy.create("pullback_entry")
.signal("trend", trend)
.signal("entry_px", entry_px) # named so the order can reference it
.size(mbt.when(close > trend, 1.0, 0.0))
.limit_entry(signal="entry_px", time_in_force={"GTB": 5})
.stop_loss(pct=3.0)
)
```
### Time in force
`"GTC"` (default, rests until filled or the signal changes), `{"GTB": n}`
(cancel after n bars), `"IOC"` (fill on the arrival bar or cancel).
### Two things to watch
**A resting entry can simply never fill.** A strategy whose entries never
trigger produces a flat equity curve with no drawdown, which reads as a clean
backtest. The engine counts unfilled entries and reports them:
```python
result = mbt.run_backtest(strategy, config)
for w in result.warnings:
print(w) # "N entry order(s) expired unfilled and M were still resting ..."
```
**Sizing uses the close, not the level.** In `FractionOfEquity` mode a target of
`1.0` is converted to units at the signal-bar close, so an entry resting 2% away
buys ~2% too much notional. `size_at_fill_price=True` sizes off the order's own
level instead. It is off by default because turning it on changes the results of
strategies written against the old behaviour.
### Cost
A conditional entry runs on the general simulation loop rather than the fast
kernel, so parameter sweeps over one are slower than sweeps over a market entry
and cannot use the GPU. `run_sweep` reports which setting took you off the fast
path.
---
## Cross-Asset References
Use `mbt.symbol_ref()` to reference another symbol's data in multi-asset strategies:
+177
View File
@@ -0,0 +1,177 @@
# Community sweep gating — shipped design
Two mechanisms, both in `crates/bt-python/src/lib.rs` around
`require_combo_limit`:
1. a **cumulative combo budget** per process (256), and
2. a **wall-clock rate gate** — 5 s between Community sweeps, serialised
machine-wide by an exclusive file lock.
Pro is untouched: the gate returns before both.
Measured effect on the bypass this exists to stop (one fresh interpreter per
slice, since the counter dies with the process):
| strategy | combos | Pro | bypass, sequential | bypass, parallel | friction |
|-------------------|--------|--------|--------------------|------------------|----------|
| RSI + EMA (light) | 5,000 | 5.9 s | 103 s | 95 s | 16.2x |
| RSI + EMA (light) | 20,000 | 23.3 s | 413 s | 390 s | 16.7x |
| SMA bands (heavy) | 5,000 | 10.0 s | 107 s | 95 s | 9.5x |
| SMA bands (heavy) | 20,000 | 40.0 s | 424 s | 391 s | 9.8x |
Friction is stable *within* a strategy (16.2 → 16.7, 9.5 → 9.8) and differs
*between* them: it no longer depends on grid size at all, only on how expensive
the strategy is per combination. At the previous 500-combo cap the same four
cells read 7.7x / 8.7x / 5.1x / 5.2x — halving the cap roughly doubled them, as
the model predicts.
Every cell read **1.0x** before this work: slicing a grid across processes cost
the same as one big licensed call.
## The hole
The Community limit (`COMMUNITY_MAX_SWEEP_COMBOS`, 500 at the time) used to be
checked **per call**. A parameter grid is sliceable, so splitting it bypassed
the cap:
```python
for i in range(0, 500, 5): # 100 calls x 500 combos
bt.run_sweep_lite(strat, {"dev_up": DU[i:i+5], "dev_dn": DD}, cf, store)
```
| run | time | per combo |
|------------------------------------|--------|-----------|
| 1 call x 50,000 combos (Pro) | 89.9 s | 1.80 ms |
| 100 calls x 500 combos (Community) | 90.3 s | 1.81 ms |
The split cost **+0.5%**, and per-call overhead is ~10 ms once data is cached,
so no per-call charge could make slicing expensive without hitting legitimate
small sweeps first.
## 1. Cumulative combo budget
A process-lifetime `AtomicU64`. Non-Pro calls are refused when
`total + n_combos > COMMUNITY_MAX_SWEEP_COMBOS`; otherwise the total is
CAS-incremented — a plain load+store would let two concurrent sweeps both slip
under the cap. A refused call consumes no budget and pays no wait. Pro skips
check and accounting. `_native._combo_budget()` exposes `(used, limit, is_pro)`
read-only.
All native fan-out entry points share one total: `run_sweep`, `run_sweep_lite`,
`run_batch`, `run_batch_lite`, `run_sweep_2d`, `run_stability`. A single
`bt.run()` is ungated.
The Python-side `_require_pro_over_combos` stays a fast-fail courtesy check for
the single-call case; the authoritative gate is native, since the Python layer
is trivially patchable.
This alone leaves the restart bypass wide open — the counter dies with the
process — which is what mechanism 2 prices.
## 2. Rate gate: 5 s, serialised by a file lock
Before each accepted Community call, the gate waits `SWEEP_MIN_INTERVAL` (5 s)
while holding an exclusive lock on `{license_dir}/sweep.lock`.
**The lock is the mechanism, not plumbing.** Waiting costs no cores, so N
concurrent processes would otherwise all clear the same 5 s — the way every
sleep-based limiter dies. Serialising the wait behind one machine-wide lock
makes N slices cost N x 5 s whatever the core count. The measurements confirm
it exactly: 19 slices in parallel took 95 s and 78 took 390 s, against a
theoretical floor of 95 s and 390 s. Parallelising buys nothing.
**Stateless on purpose.** An earlier shape recorded "last sweep at T" and only
waited the remainder — friendlier, since an idle user pays nothing. But the
record is the attack surface: restoring a copy from a minute ago grants an
immediate pass, and a MAC prevents forgery, not replay (a *stale* restored file
is the permissive one, so replay always favours the attacker). Here the file
carries no data, only the lock. Nothing to roll back; deleting it merely makes
the next caller recreate it, and the wait still happens.
**Fails closed on an unwritable state directory** (read-only HOME, locked-down
CI): the wait still happens, only without cross-process serialisation. Failing
open would make an unwritable HOME the bypass.
**Paid up front**, before results exist, so killing the process to dodge the
wait also discards the run. The GIL is released throughout.
Implemented with `std::fs::File::lock` — stable since Rust 1.89, so no new
dependency, but that is the minimum toolchain for this crate now.
### Why not a CPU penalty (what this replaced)
The previous mechanism burned ~3 s of counted CPU per process. It worked
(3.7x6.2x) but had three defects the rate gate does not:
- **Cost in core-seconds, not seconds.** Friction was
`1 + fixed_cost / (Pro cost of the slice)`, so the same penalty bought 6.2x on
a light strategy, 3.9x on a heavy one, and would fall to ~1.2x on 3 years of
bars. The rate gate's floor is wall-clock, so strategy weight and dataset size
drop out.
- **Machine-dependent**, needing a runtime calibration and per-thread clamps to
stay near 3 s across CPUs. The rate gate needs neither.
- **Wasteful and visible.** Burning every core for 3 s is indefensible if a
user profiles it. Waiting costs nothing and leaves the machine usable.
## Tuning
Behaviour is now predictable:
```
friction ≈ (combos / cap) x 5 s / (Pro time for those combos)
```
The cap is therefore the lever, and its effect is calculable rather than
measured: halving it roughly doubles the friction, at the cost of a smaller free
allowance. Verified — moving from 500 to 256 took the four cells above from
5.1x8.7x to 9.5x16.7x, a 1.9x shift against a predicted 1.95x.
Current settings: `COMMUNITY_MAX_SWEEP_COMBOS = 256`, `SWEEP_MIN_INTERVAL = 5 s`.
A legitimate free user pays 5 s per sweep, capped at 256 combinations per
process — still a 16x16 grid.
## Rejected alternatives
- **Disk-persisted counter, or a required "ticket" file.** Deleted with `rm`,
or restored with `cp`; a hash prevents forgery, not replay.
- **Shared memory / sentinel process.** One `pkill` away, and a library
spawning hidden background processes is an antivirus profile on Windows.
- **Delay measured from a recorded last-sweep time.** Needs history, which is
restorable — see above. The stateless always-wait shape avoids it.
- **Plain sleep without the lock.** Overlaps for free across processes; this is
the single reason the lock exists.
- **Penalty scaled by combos, or by combos x dataset span.** Measured and
dropped: a legitimate 500-combo sweep reached 3 s and 4.4 s respectively.
- **`run_sweep_lite` restricted to Pro.** Would work — capability cannot be
sliced around — but turned down on positioning: the free tier should be
capped in *how much*, not degraded in *how well*.
## Known limits
- **The clock is the user's.** `faketime`/`LD_PRELOAD` can shorten the sleep,
and per-slice mount namespaces (`unshare`) give each process its own lock
file. Both are deliberate technical acts, unlike "restart Python".
- **A patched wheel defeats any client-side check.** This is the ceiling of the
whole approach; the goal is to make bypassing cost more than a subscription,
not to make it impossible.
- Escalation path if telemetry ever shows this is not enough:
`docs/sweep-key-rate-limit-plan.md`.
## Related findings (tracked separately)
1. `run_sweep` (full Arrow output) is OOM-killed around ~6.8 MB/combo: 2,000
combos ≈ 13.4 GB RSS, 3,000 → SIGKILL, no Python exception, no warning. Only
Pro users can reach it, since the Community cap keeps grids below the danger
zone — a paying-customer-only failure. Needs an upfront memory estimate and
a clean error pointing at `run_sweep_lite`.
2. Level-fill via `execution_source` regenerates the execution bar feed per
combination (~80x slower sweeps); an expression-based execution price would
move the level back into parameter space. Separate design.
## Measurement note
Per-combo cost is flat across grid size — 1.21 / 1.13 / 1.11 / 1.17 / 1.11
ms/combo at 500 / 2k / 10k / 50k / 200k on one strategy. An earlier revision of
this document claimed the big call degraded at scale; that was wrong, and came
from comparing grids with different *content*. Per-combo cost tracks trade
count, not grid size — which is also why friction varies by strategy.
+247
View File
@@ -0,0 +1,247 @@
# Server-issued sweep keys — escalation plan
Status: **specified, deliberately not built.** The shipped local rate gate
(`docs/sweep-combo-limit-plan.md`) measures 9.5x16.7x at a 256-combo cap, and
lowering the cap further scales it linearly — the level this design was meant to
buy,
without a server, without making the free tier online-only, and without a
privacy review.
Build this **only** if telemetry shows the local gate being defeated at scale.
What would justify it: evidence of `faketime`/`LD_PRELOAD` clock manipulation
or per-slice namespaces in the wild, i.e. the two attacks the local gate cannot
answer. Nothing else here is worth the cost.
The rest of the document is the spec, kept complete so the decision can be
revisited without redoing the analysis.
## 1. What it does
A sweep must present a valid, unexpired, server-signed key. The key carries a
**budget of combinations**, not a single authorisation, so a burst of small
sweeps shares one round-trip.
| tier | rate | combos per key | TTL | round-trips |
|-----------|-----------------|----------------|-------|-----------------------|
| Community | 1 key per 5 s | 100 | 30 s | 1 per 100 combos |
| Pro | none | unlimited | 24 h | 1 per day |
Expected effect, from the measured Pro baselines (3 months of 1m bars, 4 cores):
| combos | Pro | Community floor | friction |
|--------|--------|---------------------|----------|
| 5,000 | 8.0 s | 50 keys → 4.2 min | ~31x |
| 20,000 | 26.6 s | 200 keys → 16.7 min | ~38x |
These hold on any machine, strategy and dataset size — but so does the shipped
local rate gate, for the same reason (a wall-clock floor rather than CPU work).
The server's only remaining advantage is that its clock is out of the user's
reach: no `faketime`, no namespace trick. That is the entire delta now.
## 2. Why the server, and why no offline lane
Every client-side variant was built or analysed and fails the same two ways
(measurements in the companion doc):
- **State dies with the process.** The cumulative counter resets on restart. On
disk it is one `rm` away; as a required "ticket" file it is one `cp` away —
a MAC prevents forgery, not replay, and a *stale* restored file is more
permissive, so replay always favours the attacker.
- **A fixed client *CPU* cost buys a variable ratio.** Friction was
`1 + fixed_cost / (Pro cost of the slice)`, so the CPU penalty gave 6.2x on a
light strategy and 3.9x on a heavy one. (The lock-serialised wall-clock gate
that replaced it does not have this defect — it is why this plan is no longer
urgent.)
**Why no offline lane.** "Free users get N combos offline, the key is only
needed beyond that" sounds like a friendly compromise and is worth nothing: the
offline allowance resets on restart, so an abuser stays in the offline lane
forever and never requests a key. Any tolerated offline mode is a hole exactly
the size of the allowance. The choice is binary — accept the network
requirement, or keep the local gate's 9.5x16.7x.
## 3. Protocol
```
client server
|-- POST /api/sweep-key ---------------->| { device_hash, license_hash?,
| | version, requested_combos }
| | identify -> rate + quota check
|<---------- signed key -----------------| { combos, expires_at, device_hash,
| | nonce, tier, sig }
| verify sig (embedded public key) |
| spend from the budget as sweeps run |
```
**Key payload** (JSON, serialised canonically — reuse the existing licence rule
that payloads re-serialise byte-identically so signatures verify):
```json
{ "combos": 100, "expires_at": "2026-08-11T09:31:04.000Z",
"device_hash": "…", "nonce": "…", "tier": "community" }
```
Signed Ed25519, verified against a public key compiled into the wheel. Reuse
`bt-license`: `ed25519-dalek` is already a dependency, `license.rs` already
verifies this exact shape, and `PUBLIC_KEY_BYTES` is already stored XOR-masked
and split rather than as a contiguous blob. **Use a separate key pair from the
licence one** so a leaked sweep-signing key cannot mint licences.
`device_hash` is inside the signature, so a key copied to another machine is
rejected locally, with no round-trip.
### Why the two TTLs differ
The 5 s rhythm is **not** enforced by the TTL — the server refuses to mint
before 5 s have passed, which is server state. The short Community TTL exists
to prevent **stockpiling**: with a one-hour TTL an abuser requests a key every
5 s for an hour, banks 720 keys, then burns them in parallel and gets 72,000
combinations at once. A 30 s TTL makes an unused key worthless while still
covering a burst of small sweeps.
Pro's 24 h TTL is the opposite trade: one round-trip, then a full day offline.
Its exposure is key sharing, already covered by the device binding in the
signature plus the existing per-licence device limit.
### Rate limiting caps throughput, not volume
At one key per 5 s a patient Community user still reaches ~72,000 combinations
per hour. If the intent is a ceiling rather than a drip, the server must also
hold a **daily total per account**. Recommended: yes, with a generous limit
(e.g. 5,000/day) — it costs one counter and turns "slow" into "bounded".
## 4. Client implementation
All in `crates/bt-license` (transport, crypto, state) and
`crates/bt-python/src/lib.rs` (the gate).
**4.1 — `bt-license`: new `sweep_key` module**
- `pub fn acquire(requested: u32) -> Result<SweepKey, LicenseError>`
- Returns the process-cached key if unexpired with budget left.
- Otherwise `POST /api/sweep-key`, verify signature, verify `device_hash`
matches `device::device_hash()`, cache, return.
- **Cache in memory only, never on disk.** An on-disk key is a stockpile, and
the TTL is short enough that persistence buys nothing.
- Reuse the telemetry HTTP pattern (`reqwest` + a current-thread tokio runtime),
but **blocking** with a **500 ms timeout** — a library that freezes on its
first call gets uninstalled. Distinguish the three failures explicitly:
network unreachable, rate-limited (HTTP 429 + `retry_after`), quota exhausted
(HTTP 402).
- Clock skew: trust `expires_at` against local time but also keep a local
monotonic deadline from receipt; use whichever expires first, so a client
clock set backwards cannot extend a key.
**4.2 — `bt-python`: replace the gate**
`require_combo_limit(py, n_combos, label)` becomes `require_sweep_budget`:
1. Pro (`check_feature("sweep")`) → acquire/refresh the 24 h key, return.
2. Community → `sweep_key::acquire(n_combos)`; on success debit the budget, on
failure raise `PermissionError` with the server's reason.
3. Keep the in-process cumulative counter as a **local fast-fail** so an
over-budget call fails without a round-trip.
4. Release the GIL around the request (`py.allow_threads`).
Call sites are unchanged: `run_sweep`, `run_sweep_lite`, `run_batch`,
`run_batch_lite`, `run_sweep_2d`, `run_stability`. A single `bt.run()` stays
ungated.
**4.3 — Error messages.** Three distinct, actionable texts:
- rate-limited → `"Community sweeps are limited to one every 5 s (4 s
remaining). Upgrade at …"`
- daily quota → `"Community daily sweep quota reached (5,000 combinations).
Resets at 00:00 UTC. Upgrade at …"`
- offline → `"Sweeps require a connection to www.manifoldbt.com on the
Community tier. bt.run() and single backtests work offline."`
**4.4 — Remove** `community_rate_gate`, `SWEEP_MIN_INTERVAL` and the lock file.
The server rhythm replaces the local one; running both would charge the wait
twice.
## 5. Server implementation
- `POST /api/sweep-key` — authenticate (licence hash when present, device hash
otherwise), enforce rate + daily quota, sign, return. Reject unknown or
revoked licences.
- Storage: per-device `last_issued_at` and per-account `combos_today`. A single
small table; the rate check is one read + one write.
- **Rate-limit by IP as well as by device.** Rotating `device_hash` to mint a
fresh identity is the one bypass this design does not close; per-IP limits
and requiring an account for Community keys raise the bar substantially.
- Metrics to emit from day one: keys issued, refusals by reason, distinct
devices per account, combos per account per day. This is also the first real
usage data on the free tier.
- Key rotation: publish the sweep-signing public key with a version byte in the
payload so a future rotation does not break older wheels.
## 6. Rollout
1. **Ship telemetry first.** Add sweep-usage reporting to the current wheel and
watch for a release. If the data shows nobody is chaining sweeps, stop here
and keep the client-side gating — this design is not free.
2. **Server first, enforcement later.** Deploy the endpoint and let the client
request keys *without* enforcing, logging refusals it would have made.
Confirms capacity and false-positive rate against real usage.
3. **Enforce behind a version gate.** Only wheels ≥ the release that ships this
require a key; older wheels keep working, so the change cannot brick an
existing install.
4. **Announce before enforcing.** Community losing offline sweeps is a
user-visible regression and must be in the release notes, not discovered.
## 7. Non-technical prerequisites
- **Privacy.** Unlicensed installs will contact the server for the first time —
the current telemetry ping only fires when a licence is present
(`guard.rs: if let Some(ref lic) = license`). Needs a privacy notice, a
documented retention policy, and a GDPR review. `device_hash` is a salted
hash of a machine ID: pseudonymous, not anonymous.
- **Availability becomes a product dependency.** If the endpoint is down, no
Community user can sweep. Needs an uptime target, and a documented
fallback decision (fail-closed is what makes the design hold; a fail-open
switch is a bypass anyone can trigger by blocking a domain).
## 8. Attack surface after this lands
| attack | cost after |
|-------------------------------|-----------|
| loop inside one process | blocked by the key budget |
| fresh process per slice | useless — the server clock does not reset |
| copy the key file | no key file exists; in-memory only |
| replay a captured key | 30 s TTL, bound to the device |
| forge a key | Ed25519 |
| stockpile keys | TTL shorter than the accumulation window |
| parallelise | useless — the rate is per account, not per process |
| rotate `device_hash` | **works** — mitigated by per-IP limits and account requirement, not closed |
| block the network | fails closed: no sweep |
| patch the wheel | **works** — the ceiling of any client-side check |
The last two lines are the honest limit: this makes bypassing a deliberate,
technical act rather than a side effect of restarting Python.
## 9. Tests
- Community: two sweeps back to back — the second refused until 5 s elapse;
101 combinations in one call refused outright; a burst of five 20-combo
sweeps consumes one key, not five.
- Key expiry: a key held past its TTL is refused and transparently renewed.
- Signature: edited payload rejected; valid key minted for another
`device_hash` rejected.
- Clock: local clock moved backwards does not extend a key (monotonic deadline
wins).
- Pro: one key covers a 200,000-combo sweep; no rate limit; no penalty.
- Server unreachable: fails within the timeout with the offline message, never
hangs.
- Restart: a fresh process cannot obtain a key faster than the server rhythm —
the end-to-end property the whole design exists for. Same harness as the
400-slice bypass benchmark used for the CPU penalty.
## 10. Still to decide
1. Daily quota: on or off, and at what value.
2. Rate: 5 s is a guess. It sets the free tier's usable throughput; pick it
from the telemetry in step 6.1 rather than from intuition.
3. Whether Community keys require a registered account (email) or just a
device hash. Accounts make per-IP limits far more effective, at the cost of
a signup wall in front of a free tier.
+98
View File
@@ -0,0 +1,98 @@
"""Entry orders — resting an entry at a price instead of taking the close.
By default an entry takes a market fill on the execution bar. This example runs
the same signal four ways so the difference is visible in one place:
market fill at the execution bar's close
limit wait for a pullback, fill passively (maker, no slippage)
stop wait for a breakout, fill through the level (taker + gap)
limit on a signal rest on a level the DSL computes (here: 1 ATR below close)
Usage:
python examples/20_entry_orders.py
"""
import os
from time import perf_counter
import manifoldbt as mbt
from manifoldbt.indicators import atr, close, ema
from manifoldbt.helpers import Interval, Slippage, time_range
# -- Signal -------------------------------------------------------------------
fast = ema(close, 12)
slow = ema(close, 50)
trend = mbt.when(fast > slow, 1.0, 0.0)
# The level a signal-priced entry rests on: one ATR below the close.
pullback = close - atr(14)
def build(name: str, entry) -> "mbt.Strategy":
"""The same strategy every time; only the entry order changes."""
s = (
mbt.Strategy.create(name)
.signal("fast", fast)
.signal("slow", slow)
.signal("pullback", pullback)
.size(trend)
.stop_loss(pct=3.0)
)
return entry(s) if entry else s
VARIANTS = {
# Market: no entry order at all. The fast kernel stays available.
"market": None,
# Passive: 25 bps below the signal close, cancelled if unfilled after 5 bars.
"limit -25bps": lambda s: s.limit_entry(offset_bps=25, time_in_force={"GTB": 5}),
# Breakout: 25 bps above. Crosses the book, and a gap through it fills at the open.
"stop +25bps": lambda s: s.stop_entry(offset_bps=-25, time_in_force={"GTB": 5}),
# Signal-priced: rest on whatever the DSL computed, here close - atr(14).
"limit @ close-ATR": lambda s: s.limit_entry(signal="pullback", time_in_force={"GTB": 5}),
}
# -- Config -------------------------------------------------------------------
start, end = time_range("2022-01-01", "2025-01-01")
config = mbt.BacktestConfig(
universe={"binance": ["BTC-USDT:perp"]},
time_range_start=start,
time_range_end=end,
bar_interval=Interval.hours(4),
initial_capital=10_000,
fees=mbt.FeeConfig.binance_perps(),
slippage=Slippage.fixed_bps(2),
warmup_bars=60,
)
# -- Run ----------------------------------------------------------------------
if __name__ == "__main__":
root = os.path.join(os.path.dirname(__file__), "..")
data_root = os.path.abspath(os.path.join(root, "data"))
store = mbt.DataStore(
data_root=data_root,
metadata_db=os.path.abspath(os.path.join(root, "metadata", "metadata.sqlite")),
arrow_dir=os.path.join(data_root, "mega"),
)
print(f"{'entry':<20} {'trades':>7} {'return':>9} {'sharpe':>8} {'elapsed':>9}")
print("-" * 56)
for label, entry in VARIANTS.items():
strategy = build(label.replace(" ", "_"), entry)
t0 = perf_counter()
result = mbt.run(strategy, config, store)
elapsed = perf_counter() - t0
m = result.metrics
print(
f"{label:<20} {result.trades.num_rows:>7} "
f"{m['total_return']:>8.1%} {m['sharpe']:>8.2f} {elapsed:>8.2f}s"
)
# A resting entry can simply never fill. That failure mode looks like a
# clean backtest, so the engine reports it rather than staying silent.
for w in result.warnings:
if "unfilled" in w:
print(f"{'':<20} ! {w}")
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "manifoldbt"
version = "0.14.1"
version = "0.15.0"
description = "Rust-powered backtesting engine for quantitative research"
requires-python = ">=3.9"
license = { file = "LICENSE" }
+12 -6
View File
@@ -38,6 +38,7 @@ from manifoldbt.config import (
FeeConfig,
OrderConfig,
VenueFees,
entry_price,
resolve_universe,
)
from manifoldbt.exceptions import (
@@ -157,9 +158,10 @@ def _require_pro_for_gpu(device, feature: str) -> None:
# Community fan-out budget: sweeps and batches may run up to this many backtests
# per call for free; beyond it requires Pro. Single run() is never affected.
# Keep in sync with the native bt_license::COMMUNITY_MAX_SWEEP_COMBOS.
_COMMUNITY_MAX_COMBOS = 500
# cumulatively per process for free; beyond it requires Pro. Single run() is
# never affected. Keep in sync with the native
# bt_license::COMMUNITY_MAX_SWEEP_COMBOS.
_COMMUNITY_MAX_COMBOS = 256
def _grid_combos(param_grid) -> int:
@@ -173,14 +175,17 @@ def _grid_combos(param_grid) -> int:
def _require_pro_over_combos(n_combos: int, what: str) -> None:
"""Raise LicenseError if a fan-out exceeds the Community combination limit.
No-op at or below the limit, or for Pro users. Mirrors the native
``require_combo_limit`` so Community and Pro see identical behaviour.
Fast-fail UX layer only: catches a single call that could never fit the
budget. The authoritative gate is the native ``require_combo_limit``,
which enforces the limit **cumulatively per session** small calls also
consume budget there, and this mirror cannot (and must not) track that.
"""
if n_combos <= _COMMUNITY_MAX_COMBOS or _is_pro():
return
raise LicenseError(
f"{what} with {n_combos} runs exceeds the Community limit of "
f"{_COMMUNITY_MAX_COMBOS}. Upgrade to Pro at www.manifoldbt.com"
f"{_COMMUNITY_MAX_COMBOS} combinations per session. "
f"Upgrade to Pro at www.manifoldbt.com"
)
@@ -1603,6 +1608,7 @@ __all__ = [
"FeeConfig",
"VenueFees",
"OrderConfig",
"entry_price",
# Helpers
"date_to_ns",
"time_range",
+83 -5
View File
@@ -6,19 +6,51 @@ from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union
def entry_price(
*,
offset_bps: Optional[float] = None,
price: Optional[float] = None,
signal: Optional[str] = None,
) -> dict:
"""Build the price spec for an entry order. Pass exactly one of:
- ``offset_bps``: distance from the signal-bar close, in bps. Positive is
more passive (buy lower / sell higher).
- ``price``: a fixed level, the same on every bar.
- ``signal``: the name of a strategy signal to read the level from, so the
order can rest on ``ema(close, 20)``, ``close - 2 * atr(close, 14)``, a
prior swing low, or anything else the DSL can express.
"""
given = [x for x in (offset_bps, price, signal) if x is not None]
if len(given) != 1:
raise ValueError("entry_price takes exactly one of offset_bps, price, signal")
if offset_bps is not None:
return {"OffsetBps": offset_bps}
if price is not None:
return {"Absolute": price}
return {"Signal": signal}
@dataclass
class OrderConfig:
"""Order management configuration for limit entries, stop-loss, take-profit,
and trailing stops. All fields are optional when nothing is set the engine
uses the legacy market-order path with zero overhead.
"""Order management configuration for conditional entries, stop-loss,
take-profit, and trailing stops. All fields are optional when nothing is
set the engine uses the legacy market-order path with zero overhead.
Sub-config dicts:
limit_entry: {"offset_bps": 10.0, "time_in_force": "GTC"}
offset_bps: distance from close in bps (buy: close*(1-offset/10000))
limit_entry: where an entry rests instead of taking a market fill.
price: {"OffsetBps": 10.0} | {"Absolute": 60000.0} | {"Signal": "entry_px"}
(omit and set offset_bps for the legacy shape)
trigger: "Limit" (default), "Stop", "StopLimit", "MarketIfTouched"
limit_price: same shape as price; required by "StopLimit"
time_in_force: "GTC" (default), {"GTB": 5}, or "IOC"
size_at_fill_price: size off the order's own level instead of the close
stop_loss: {"stop_pct": 2.0} % from entry price
take_profit: {"profit_pct": 5.0} % from entry price
trailing_stop: {"trail_pct": 3.0, "use_high": true}
Note that a conditional entry runs on the general simulation loop, not the
fast kernel, so sweeps over one are slower than sweeps over a market entry.
"""
limit_entry: Optional[dict] = None
@@ -44,6 +76,52 @@ class OrderConfig:
"""Convenience: trailing stop only."""
return cls(trailing_stop={"trail_pct": trail_pct, "use_high": use_high})
@classmethod
def limit_entry_at(
cls,
*,
offset_bps: Optional[float] = None,
price: Optional[float] = None,
signal: Optional[str] = None,
time_in_force: Union[str, dict] = "GTC",
size_at_fill_price: bool = False,
) -> "OrderConfig":
"""Convenience: a passive limit entry resting at the given level."""
return cls(
limit_entry={
"price": entry_price(
offset_bps=offset_bps, price=price, signal=signal
),
"trigger": "Limit",
"time_in_force": time_in_force,
"size_at_fill_price": size_at_fill_price,
}
)
@classmethod
def stop_entry_at(
cls,
*,
offset_bps: Optional[float] = None,
price: Optional[float] = None,
signal: Optional[str] = None,
time_in_force: Union[str, dict] = "GTC",
size_at_fill_price: bool = False,
) -> "OrderConfig":
"""Convenience: a breakout entry that fills once price trades through
the level. Crosses the book, so it pays taker fees and slippage, and a
bar that gaps through the level fills at the open."""
return cls(
limit_entry={
"price": entry_price(
offset_bps=offset_bps, price=price, signal=signal
),
"trigger": "Stop",
"time_in_force": time_in_force,
"size_at_fill_price": size_at_fill_price,
}
)
def to_json_dict(self) -> dict:
d: dict = {}
if self.limit_entry is not None:
+125
View File
@@ -158,6 +158,131 @@ class Strategy:
self._json_cache = None
return self
def _entry(
self,
trigger: str,
offset_bps: Optional[float],
price: Optional[float],
signal: Optional[str],
time_in_force: Any,
size_at_fill_price: bool,
limit_price: Optional[Dict[str, Any]] = None,
) -> "Strategy":
from .config import entry_price
if self._orders is None:
self._orders = {}
entry: Dict[str, Any] = {
"price": entry_price(offset_bps=offset_bps, price=price, signal=signal),
"trigger": trigger,
"time_in_force": time_in_force,
"size_at_fill_price": size_at_fill_price,
}
if limit_price is not None:
entry["limit_price"] = limit_price
self._orders["limit_entry"] = entry
self._json_cache = None
return self
def limit_entry(
self,
*,
offset_bps: Optional[float] = None,
price: Optional[float] = None,
signal: Optional[str] = None,
time_in_force: Any = "GTC",
size_at_fill_price: bool = False,
) -> "Strategy":
"""Rest the entry passively at a level instead of taking a market fill.
Pass exactly one of ``offset_bps`` (distance from the signal-bar close),
``price`` (a fixed level), or ``signal`` (the name of a signal this
strategy defines, so the level can be any series the DSL computes).
A passive fill pays maker fees, takes no slippage, and lands on the
level exactly. It can also never fill: check ``result.warnings``.
Args:
offset_bps: Distance from the signal close in bps (positive = more passive).
price: A fixed price level.
signal: Name of a signal to read the level from.
time_in_force: ``"GTC"`` (default), ``{"GTB": 5}``, or ``"IOC"``.
size_at_fill_price: Size off the order's level instead of the close.
"""
return self._entry(
"Limit", offset_bps, price, signal, time_in_force, size_at_fill_price
)
def stop_entry(
self,
*,
offset_bps: Optional[float] = None,
price: Optional[float] = None,
signal: Optional[str] = None,
time_in_force: Any = "GTC",
size_at_fill_price: bool = False,
) -> "Strategy":
"""Enter on a breakout: fill once price trades **through** the level.
The mirror of :meth:`limit_entry`. It crosses the book, so it pays taker
fees and slippage, and a bar that gaps through the level fills at the
open rather than at the level.
"""
return self._entry(
"Stop", offset_bps, price, signal, time_in_force, size_at_fill_price
)
def market_if_touched(
self,
*,
offset_bps: Optional[float] = None,
price: Optional[float] = None,
signal: Optional[str] = None,
time_in_force: Any = "GTC",
size_at_fill_price: bool = False,
) -> "Strategy":
"""Wait for price to come to the level, then take a market fill.
Same trigger as :meth:`limit_entry`, but the fill crosses the book:
taker fees and slippage apply.
"""
return self._entry(
"MarketIfTouched",
offset_bps,
price,
signal,
time_in_force,
size_at_fill_price,
)
def stop_limit_entry(
self,
*,
stop: Optional[float] = None,
stop_signal: Optional[str] = None,
limit: Optional[float] = None,
limit_signal: Optional[str] = None,
time_in_force: Any = "GTC",
size_at_fill_price: bool = False,
) -> "Strategy":
"""Breakout that arms a resting limit.
The ``stop`` level arms the order; it then rests at ``limit`` and fills
there with maker fees. Pass each level either as a number or as the name
of a signal.
"""
from .config import entry_price
return self._entry(
"StopLimit",
None,
stop,
stop_signal,
time_in_force,
size_at_fill_price,
limit_price=entry_price(price=limit, signal=limit_signal),
)
def describe(self, text: str) -> "Strategy":
"""Set strategy description (returns self for chaining)."""
self._description = text
+243
View File
@@ -0,0 +1,243 @@
"""Community sweep gating: cumulative combo budget + throughput penalty.
See docs/sweep-combo-limit-plan.md. Two mechanisms, tested here through real
sweep calls on a tiny dataset:
* the 500-combo cap is enforced on the running total **per process**, not per
call otherwise slicing a grid into small calls bypasses it at a measured
+0.5% cost;
* every accepted Community call waits `SWEEP_MIN_INTERVAL`, held under a
machine-wide file lock, so the remaining bypass (a fresh interpreter per
slice) costs 5 s each and cannot be parallelised away.
The rate-gate tests run in subprocesses on purpose: "serialised across
processes" is only observable between processes, and an in-process test would
also depend on which test happened to run first.
"""
import subprocess
import sys
import textwrap
import time
import numpy as np
import pandas as pd
import pytest
import manifoldbt as bt
from manifoldbt._native import _combo_budget
IS_PRO = bt.license_info()[0] == "Pro"
community_only = pytest.mark.skipif(
IS_PRO, reason="Community-only; deactivate Pro/BT_UNLOCKED to test"
)
pro_only = pytest.mark.skipif(not IS_PRO, reason="requires an active Pro license")
@pytest.fixture(scope="module")
def store_paths(tmp_path_factory):
"""A minimal store on disk; returns (data_root, metadata_db, arrow_dir)."""
root = tmp_path_factory.mktemp("combo_limit")
idx = pd.date_range("2024-01-01", periods=120, freq="1min", tz="UTC")
close = 100.0 + np.arange(120, dtype=float)
df = pd.DataFrame({
"timestamp": idx,
"open": close, "high": close * 1.01, "low": close * 0.99,
"close": close, "volume": np.full(120, 1_000.0),
})
data_root, metadata_db = str(root / "data"), str(root / "metadata.sqlite")
bt.import_dataframe(
df, symbol="CL", symbol_id=1, interval="1m",
data_root=data_root, metadata_db=metadata_db,
)
return data_root, metadata_db, f"{data_root}/mega"
@pytest.fixture(scope="module")
def daily_store(store_paths):
data_root, metadata_db, arrow_dir = store_paths
return bt.DataStore(data_root, metadata_db, "bars_1m", None, arrow_dir)
# --- shared snippet: build strategy + config, run one sweep of n combos ------
_HARNESS = '''
import sys, time
import manifoldbt as bt
store = bt.DataStore({data_root!r}, {metadata_db!r}, "bars_1m", None, {arrow_dir!r})
strat = bt.Strategy(
name="budget_probe",
signals={{"signal": bt.lit(1.0)}},
position_sizing=bt.lit(1.0) * bt.param("size", default=1.0),
parameters={{"size": bt.param("size", default=1.0)}},
)
t0, t1 = bt.time_range("2024-01-01", "2024-01-02")
cfg = bt.BacktestConfig(universe=[1], time_range_start=t0, time_range_end=t1,
bar_interval={{"Minutes": 1}})
def sweep(n):
grid = {{"size": [1.0 + 0.001 * i for i in range(n)]}}
return bt.run_sweep_lite(strat, grid, cfg, store)
def timed(n):
# PermissionError comes from the native gate (cumulative cap), LicenseError
# from the Python mirror (a single call larger than the cap). Both are
# "refused", and neither should have waited out the rate limit.
t = time.perf_counter()
try:
sweep(n)
ok = True
except (PermissionError, bt.LicenseError):
ok = False
return time.perf_counter() - t, ok
'''
def _run(store_paths, body):
"""Run `body` in a fresh interpreter; return its stdout floats/flags."""
data_root, metadata_db, arrow_dir = store_paths
code = _HARNESS.format(
data_root=data_root, metadata_db=metadata_db, arrow_dir=arrow_dir
) + textwrap.dedent(body)
out = subprocess.run(
[sys.executable, "-c", code], capture_output=True, text=True, timeout=300
)
assert out.returncode == 0, f"subprocess failed:\n{out.stdout}\n{out.stderr}"
return out.stdout.strip().splitlines()[-1].split()
def _sweep(store, n_combos):
"""One in-process run_sweep_lite call with exactly n_combos combinations."""
strat = bt.Strategy(
name="budget_probe",
signals={"signal": bt.lit(1.0)},
position_sizing=bt.lit(1.0) * bt.param("size", default=1.0),
parameters={"size": bt.param("size", default=1.0)},
)
# Minutes(1) is silently coarsened to daily on Community; the runs then
# produce zero trades, which is irrelevant here — only the gate matters.
t0, t1 = bt.time_range("2024-01-01", "2024-01-02")
cfg = bt.BacktestConfig(
universe=[1], time_range_start=t0, time_range_end=t1,
bar_interval={"Minutes": 1},
)
grid = {"size": [1.0 + 0.001 * i for i in range(n_combos)]}
return bt.run_sweep_lite(strat, grid, cfg, store)
# --------------------------------------------------------------- the budget --
@community_only
def test_cumulative_budget(daily_store):
used0, limit, is_pro = _combo_budget()
assert not is_pro
remaining = limit - used0
if remaining < 8:
pytest.skip(f"only {remaining} combos left in this process")
# Two calls of just over half the remaining budget: the first fits,
# the second would cross the cap even though it is individually small.
n = int(remaining) // 2 + 1
_sweep(daily_store, n)
with pytest.raises(PermissionError, match="already used this session"):
_sweep(daily_store, n)
# The rejected call consumed nothing: what actually remains still fits.
leftover = int(limit - _combo_budget()[0])
assert leftover == int(remaining) - n
if leftover >= 1:
_sweep(daily_store, leftover)
assert _combo_budget()[0] == limit
# Budget now exhausted: even a single combo is refused.
with pytest.raises(PermissionError, match="0 remaining"):
_sweep(daily_store, 1)
# ------------------------------------------------------------ the rate gate --
#
# SWEEP_MIN_INTERVAL is 5 s. Thresholds leave generous slack: a call that
# waited is asserted above 4 s, one that did not below 2 s. Nothing here
# depends on machine speed — that is the point of a wall-clock gate.
_INTERVAL = 5.0
_WAITED = 4.0
_DID_NOT_WAIT = 2.0
@community_only
def test_rate_gate_applies_to_every_call(store_paths):
"""Each accepted call waits the interval — it is a rate limit, not a toll."""
first, second = (
float(x) for x in _run(store_paths, """
t1, _ = timed(2)
t2, _ = timed(2)
print(t1, t2)
""")
)
assert first > _WAITED, f"first call took {first:.2f}s, expected a ~5 s wait"
assert second > _WAITED, (
f"second call took {second:.2f}s — the gate is behaving like a one-off "
f"charge instead of a rate limit"
)
@community_only
def test_rate_gate_serialises_across_processes(store_paths):
"""The lock is the mechanism: concurrent waits must queue, not overlap.
Without the file lock two processes would sleep through the same 5 s and
both proceed the failure mode of every sleep-based limiter. With it, two
concurrent sweeps cost two intervals.
"""
data_root, metadata_db, arrow_dir = store_paths
code = _HARNESS.format(
data_root=data_root, metadata_db=metadata_db, arrow_dir=arrow_dir
) + "timed(2)\n"
t = time.perf_counter()
procs = [
subprocess.Popen([sys.executable, "-c", code],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(2)
]
for proc in procs:
assert proc.wait(timeout=120) == 0
elapsed = time.perf_counter() - t
assert elapsed > 2 * _WAITED, (
f"two concurrent sweeps took {elapsed:.2f}s — under two intervals, so "
f"their waits overlapped and the lock is not serialising them"
)
@community_only
def test_refused_call_does_not_wait(store_paths):
"""Refusing is instant: no 5 s wait before being told no.
The refusal comes first in a fresh process, so a later accepted call still
waits the refusal neither charged nor exempted anything.
"""
refused, ok, accepted = _run(store_paths, """
t_refused, ok = timed(1000) # larger than the cap
t_accepted, _ = timed(2) # first *accepted* call: waits
print(t_refused, ok, t_accepted)
""")
assert ok == "False", "expected the over-cap call to be refused"
assert float(refused) < _DID_NOT_WAIT, (
f"refused call took {float(refused):.2f}s — it should not wait"
)
assert float(accepted) > _WAITED, (
"the accepted call after a refusal did not wait out the rate limit"
)
@pro_only
def test_pro_is_not_rate_limited(store_paths):
"""Pro skips the gate entirely: no counter, no wait, on any call."""
first, second = (
float(x) for x in _run(store_paths, """
t1, _ = timed(2)
t2, _ = timed(2)
print(t1, t2)
""")
)
assert first < _DID_NOT_WAIT and second < _DID_NOT_WAIT, (
f"Pro waited ({first:.2f}s, {second:.2f}s) — the rate gate leaked"
)
+48
View File
@@ -162,3 +162,51 @@ def test_import_dataframe_integer_timestamp_raises(tmp_path):
def test_import_dataframe_empty_raises(tmp_path):
with pytest.raises(bt.DataError, match="no data rows"):
_import_df(_bars_df(0), tmp_path)
def test_import_dataframe_daily_interval_runs(tmp_path):
"""Daily bars import AND backtest.
Regression: the resolution table listed only 1m/1h, so a daily store
resolved to the (empty) 1m directory and the run died with "empty bar
dataset for symbol". A ``1d`` entry in the table lets the daily provider
layout be found. 1m/1h were unaffected, which is exactly why this slipped.
"""
n = 30
ts = pd.date_range("2021-01-01", periods=n, freq="1D", tz="UTC")
close = [100.0 + i for i in range(n)] # strictly rising → buy & hold profits
df = pd.DataFrame(
{
"timestamp": ts,
"open": close,
"high": [c + 1.0 for c in close],
"low": [c - 1.0 for c in close],
"close": close,
"volume": [10.0] * n,
}
)
store = _import_df(df, tmp_path, name="daily", interval="1d")
assert store.resolve_symbol("BTCUSDT") == 1
strategy = bt.Strategy(
name="bh",
signals={"signal": bt.lit(1.0)},
position_sizing=bt.col("signal"),
)
config = bt.BacktestConfig(
universe=[1],
time_range_start=0,
time_range_end=int(ts[-1].value) + 5 * 86_400_000_000_000,
bar_interval={"Days": 1},
initial_capital=1000.0,
execution=bt.ExecutionConfig(
signal_delay=1, execution_price="AtClose",
position_sizing_mode="Units",
),
fees=bt.FeeConfig(),
slippage={"FixedBps": {"bps": 0.0}},
)
result = bt.run(strategy, config, store)
equity = result.equity_curve.to_pylist()
assert len(equity) > 0
assert equity[-1] > 1000.0
+399
View File
@@ -0,0 +1,399 @@
"""Cross-engine parity: manifoldbt vs vectorbt on brackets, shorts, fees.
This suite pins manifoldbt's fill semantics against an independent engine
(vectorbt) on controlled synthetic bars, so a refactor that silently changes a
fill price, a stop level, or PnL booking is caught here rather than in the wild.
Coverage only what vectorbt can legitimately model apples-to-apples:
* market entry + take-profit (test_market_take_profit_parity)
* market entry + stop-loss (test_market_stop_loss_parity)
* combined SL+TP bracket (test_bracket_sl_tp_parity)
* short entry + take-profit (test_short_take_profit_parity)
* trailing stop (test_trailing_stop_parity)
* fees over multiple round-trips (test_fees_multi_trade_parity)
Out of scope for vectorbt (validated separately, NOT against vectorbt):
* determined-price / resting limit entry vectorbt has no resting order, so
``test_limit_entry_matches_independent_reference`` pins it against a NumPy
model instead.
* sizing under fees with ``FractionOfEquity`` the engines size differently
once fees exist (manifoldbt charges the fee on top of a full-equity notional;
vectorbt reserves it out of cash). Both are legitimate; the fee test sizes in
fixed units to compare the fee arithmetic without that policy difference.
What is compared, and why only this:
* Trade fills (entry price, exit price, exit reason) and final ``total_return``.
These are computed at full internal resolution and are exact. The *equity
curve* is deliberately NOT compared: on a Community build the output series is
capped to daily resolution, so its shape is not apples-to-apples with
vectorbt. The realised trades and the final equity are unaffected by that cap.
Convention alignment (measured against manifoldbt 0.14.1, not assumed):
* ``signal_delay=0`` + ``AtClose`` a market entry fills at the *close* of the
signal bar. vectorbt ``from_signals`` fills the entry bar at close by default,
so entries line up with no shift.
* ``FractionOfEquity`` sizing is taken at the *signal-bar close*
(``size_at_fill_price=False``). For a market entry that equals the fill price,
so vectorbt ``size_type="percent"`` matches. For a resting limit entry the
signal close and the fill price differ, so vectorbt is fed an explicit unit
size to reproduce manifoldbt's "size at signal close" rule.
* Take-profit is a passive target: it fills at the level even if the bar gaps
through it. Stop-loss fills at the level (or worse on a gap). vectorbt's
``stop_exit_price=StopMarket`` reproduces the level fill on these
no-gap-at-open scenarios.
vectorbt has no resting entry order, so the limit-entry scenario also carries an
independent NumPy reference for *where* the order fills; vectorbt only checks the
downstream take-profit off that fill.
"""
import os
import pytest
pd = pytest.importorskip("pandas")
vbt = pytest.importorskip("vectorbt")
import manifoldbt as bt # noqa: E402
from manifoldbt.expr import col, lit, when # noqa: E402
from manifoldbt.helpers import Interval, Slippage # noqa: E402
from vectorbt.portfolio.enums import StopExitPrice, Direction # noqa: E402
CAPITAL = 10_000.0
REL_TOL = 1e-6
# Exit-reason codes emitted in trades_df (measured):
REASON_NONE, REASON_SL, REASON_TP, REASON_TRAIL = 0, 1, 2, 3
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _bars(o, h, l, c, start="2023-01-01"):
ts = pd.date_range(start, periods=len(c), freq="1h", tz="UTC")
return pd.DataFrame(
{"timestamp": ts, "open": list(map(float, o)), "high": list(map(float, h)),
"low": list(map(float, l)), "close": list(map(float, c)),
"volume": [1000.0] * len(c)}
)
def _mbt_run(df, strat, tmp_path, name, *, delay=0, allow_short=False,
sizing="FractionOfEquity", fees=None):
"""Run manifoldbt on an in-memory OHLC frame; return the Result."""
root = str(tmp_path / name)
os.makedirs(root, exist_ok=True)
store = bt.import_dataframe(
df, symbol="TEST", symbol_id=1, interval="1h",
data_root=os.path.join(root, "data"),
metadata_db=os.path.join(root, "meta.sqlite"),
)
ts = df["timestamp"]
cfg = bt.BacktestConfig(
universe=[1],
time_range_start=0,
time_range_end=int(ts.iloc[-1].value) + 30 * 86_400_000_000_000,
bar_interval=Interval.hours(1),
initial_capital=CAPITAL,
execution=bt.ExecutionConfig(
signal_delay=delay, execution_price="AtClose",
max_position_pct=1.0, allow_short=allow_short,
position_sizing_mode=sizing,
),
fees=fees if fees is not None else bt.FeeConfig.zero(),
slippage=Slippage.none(),
warmup_bars=0,
)
return bt.run(strat, cfg, store)
def _mbt_trades(res):
"""(entry_fill, exit_fill, exit_reason) from a two-row round-trip."""
tr = res.trades_df()
assert len(tr) == 2, f"expected one round-trip, got {len(tr)} rows:\n{tr}"
entry = tr.iloc[0]
exit_ = tr.iloc[1]
return float(entry["fill_price"]), float(exit_["fill_price"]), int(exit_["exit_reason"])
def _vbt_from_signals(df, entries, *, exits=None, tp=None, sl=None,
sl_trail=False, size=1.0, size_type="percent",
direction=None, fees=0.0):
idx = pd.DatetimeIndex(df["timestamp"])
close = pd.Series(df["close"].values, index=idx, dtype=float)
ent = pd.Series(entries, index=idx)
ex = pd.Series(exits if exits is not None else False, index=idx)
kwargs = dict(
open=pd.Series(df["open"].values, index=idx, dtype=float),
high=pd.Series(df["high"].values, index=idx, dtype=float),
low=pd.Series(df["low"].values, index=idx, dtype=float),
init_cash=CAPITAL, size=size, size_type=size_type,
fees=fees, slippage=0.0, sl_stop=sl, tp_stop=tp, sl_trail=sl_trail,
stop_exit_price=StopExitPrice.StopMarket,
freq="1h", accumulate=False,
)
if direction is not None:
kwargs["direction"] = direction
return vbt.Portfolio.from_signals(close, ent, ex, **kwargs)
def _assert_close(a, b, msg):
assert abs(a - b) <= REL_TOL * max(1.0, abs(b)), f"{msg}: {a} != {b}"
# --------------------------------------------------------------------------- #
# Scenario A — market entry + take-profit
# --------------------------------------------------------------------------- #
def test_market_take_profit_parity(tmp_path):
# Enter long at bar 0 close (100). TP +10% (110) is crossed at bar 3
# (open 108 < 110 < high 115): both engines fill the target at 110.
df = _bars(
o=[100, 100, 104, 108, 111, 113],
h=[101, 102, 106, 115, 112, 114],
l=[99, 99, 103, 107, 110, 112],
c=[100, 100, 105, 112, 111, 113],
)
# Long only while close in (99.5, 106): true on bars 0-2, false after, so
# the position is a single clean round-trip closed by the TP.
entry = when((col("close") > lit(99.5)) & (col("close") < lit(106.0)),
lit(1.0), lit(0.0))
strat = (bt.Strategy.create("mkt_tp")
.signal("d", col("close")).size(entry).take_profit(pct=10.0))
res = _mbt_run(df, strat, tmp_path, "A")
m_entry, m_exit, reason = _mbt_trades(res)
assert reason == REASON_TP
_assert_close(m_entry, 100.0, "mbt entry")
_assert_close(m_exit, 110.0, "mbt tp exit")
pf = _vbt_from_signals(df, [True, False, False, False, False, False], tp=0.10)
v_tr = pf.trades.records_readable.iloc[0]
_assert_close(float(v_tr["Avg Entry Price"]), m_entry, "entry price")
_assert_close(float(v_tr["Avg Exit Price"]), m_exit, "exit price")
_assert_close(pf.total_return(), res.metrics["total_return"], "total_return")
# --------------------------------------------------------------------------- #
# Scenario B — market entry + stop-loss
# --------------------------------------------------------------------------- #
def test_market_stop_loss_parity(tmp_path):
# Enter long at bar 0 close (100). SL -5% (95) is hit at bar 3
# (open 97 > 95, low 94 <= 95): both engines fill the stop at 95.
df = _bars(
o=[100, 100, 99, 97, 96, 95],
h=[101, 101, 100, 98, 97, 96],
l=[99, 99, 96, 94, 95, 94],
c=[100, 100, 98, 96, 96, 95],
)
entry = when(col("close") >= lit(97.0), lit(1.0), lit(0.0))
strat = (bt.Strategy.create("mkt_sl")
.signal("d", col("close")).size(entry).stop_loss(pct=5.0))
res = _mbt_run(df, strat, tmp_path, "B")
m_entry, m_exit, reason = _mbt_trades(res)
assert reason == REASON_SL
_assert_close(m_entry, 100.0, "mbt entry")
_assert_close(m_exit, 95.0, "mbt sl exit")
pf = _vbt_from_signals(df, [True, False, False, False, False, False], sl=0.05)
v_tr = pf.trades.records_readable.iloc[0]
_assert_close(float(v_tr["Avg Entry Price"]), m_entry, "entry price")
_assert_close(float(v_tr["Avg Exit Price"]), m_exit, "exit price")
_assert_close(pf.total_return(), res.metrics["total_return"], "total_return")
# --------------------------------------------------------------------------- #
# Scenario C — resting limit entry at a determined price + take-profit
# --------------------------------------------------------------------------- #
def _resting_limit_reference(df, signal_bar, offset_frac, tp_frac, capital):
"""Independent NumPy model of a resting buy-limit + take-profit.
Mirrors the measured manifoldbt rule: the limit rests at
``signal_close * (1 - offset_frac)``, fills on the first bar AFTER the
signal bar whose low touches it (fill AT the level), sizes at the signal
close, then a passive TP at ``fill * (1 + tp_frac)`` closes it on the first
later bar whose high reaches it.
"""
close = df["close"].to_numpy(float)
high = df["high"].to_numpy(float)
low = df["low"].to_numpy(float)
signal_close = close[signal_bar]
limit = signal_close * (1.0 - offset_frac)
qty = capital / signal_close # size_at_fill_price=False
fill_bar = next((i for i in range(signal_bar + 1, len(low)) if low[i] <= limit), None)
assert fill_bar is not None, "limit never filled in reference"
tp = limit * (1.0 + tp_frac)
exit_bar = next((i for i in range(fill_bar, len(high)) if high[i] >= tp), None)
assert exit_bar is not None, "TP never reached in reference"
total_return = qty * (tp - limit) / capital
return dict(limit=limit, qty=qty, fill_bar=fill_bar, tp=tp,
exit_bar=exit_bar, total_return=total_return)
def test_limit_entry_matches_independent_reference(tmp_path):
"""Determined-price (resting limit) entry — validated WITHOUT vectorbt.
vectorbt has no resting entry order: it cannot wait across bars for price to
trade down to a level, so a "vs vectorbt" check would not be apples-to-apples
and is deliberately not attempted. This manifoldbt-only feature is pinned
against an independent NumPy model of the resting fill instead. The vectorbt
suite above covers what both engines share (market entry, SL, TP).
Signal at bar 0 (close 100). Limit rests 2% below (98). Bar 1 low 97 <= 98
fills at 98. TP +5% off the fill (102.9) is reached at bar 3 (open 102 < the
target, so it fills the passive target at the level, not on a gap).
"""
df = _bars(
o=[100, 99, 101, 102, 104, 105],
h=[100.5, 100, 102, 104, 105, 106],
l=[99.5, 97, 100, 101.5, 103, 104],
c=[100, 99, 101, 103, 104, 105],
start="2023-01-02",
)
# Signal fires only on bar 0 so exactly one resting order is placed.
entry = when((col("close") >= lit(99.5)) & (col("close") <= lit(100.5)),
lit(1.0), lit(0.0))
strat = (bt.Strategy.create("lim_tp")
.signal("d", col("close"))
.size(entry)
.limit_entry(offset_bps=200, time_in_force="GTC") # 200 bps = 2%
.take_profit(pct=5.0))
res = _mbt_run(df, strat, tmp_path, "C")
m_entry, m_exit, reason = _mbt_trades(res)
ref = _resting_limit_reference(df, signal_bar=0, offset_frac=0.02,
tp_frac=0.05, capital=CAPITAL)
_assert_close(m_entry, ref["limit"], "limit fill price") # 98.0
_assert_close(m_exit, ref["tp"], "tp exit price") # 102.9
assert reason == REASON_TP
_assert_close(res.metrics["total_return"], ref["total_return"], "total_return")
# --------------------------------------------------------------------------- #
# Scenario D — combined SL+TP bracket (both armed, the right one fires)
# --------------------------------------------------------------------------- #
def test_bracket_sl_tp_parity(tmp_path):
# SL -5% (95) AND TP +10% (110) armed together. Price rises, so the TP fires
# at bar 3 and the stop never triggers — the bracket must not misfire.
df = _bars(
o=[100, 100, 104, 108, 111, 113],
h=[101, 102, 106, 115, 112, 114],
l=[99, 99, 103, 107, 110, 112],
c=[100, 100, 105, 112, 111, 113],
)
entry = when((col("close") > lit(99.5)) & (col("close") < lit(106.0)),
lit(1.0), lit(0.0))
strat = (bt.Strategy.create("bracket")
.signal("d", col("close")).size(entry)
.stop_loss(pct=5.0).take_profit(pct=10.0))
res = _mbt_run(df, strat, tmp_path, "D")
m_entry, m_exit, reason = _mbt_trades(res)
assert reason == REASON_TP
_assert_close(m_exit, 110.0, "mbt tp exit")
pf = _vbt_from_signals(df, [True, False, False, False, False, False],
sl=0.05, tp=0.10)
v_tr = pf.trades.records_readable.iloc[0]
_assert_close(float(v_tr["Avg Exit Price"]), m_exit, "exit price")
_assert_close(pf.total_return(), res.metrics["total_return"], "total_return")
# --------------------------------------------------------------------------- #
# Scenario E — short entry + take-profit
# --------------------------------------------------------------------------- #
def test_short_take_profit_parity(tmp_path):
# Short at bar 0 close (100). TP -5% (95, profit for a short) is hit at bar 3
# (open 96 > 95, low 94 <= 95): both engines cover at 95 for a +5% return.
# Signal is short on bars 0-2 and flat from bar 3, so the TP closes it with
# no re-entry.
df = _bars(
o=[100, 99, 98, 96, 95, 94],
h=[100.5, 100, 99, 97, 96, 95],
l=[99.5, 98, 97, 94, 94, 93],
c=[100, 98, 97, 95, 94, 93],
)
entry = when(col("close") >= lit(96.0), lit(-1.0), lit(0.0))
strat = (bt.Strategy.create("short_tp")
.signal("d", col("close")).size(entry).take_profit(pct=5.0))
res = _mbt_run(df, strat, tmp_path, "E", allow_short=True)
m_entry, m_exit, reason = _mbt_trades(res)
assert reason == REASON_TP
_assert_close(m_entry, 100.0, "short entry")
_assert_close(m_exit, 95.0, "short cover")
pf = _vbt_from_signals(df, [True, False, False, False, False, False],
tp=0.05, direction=Direction.ShortOnly)
v_tr = pf.trades.records_readable.iloc[0]
_assert_close(float(v_tr["Avg Entry Price"]), m_entry, "entry price")
_assert_close(float(v_tr["Avg Exit Price"]), m_exit, "cover price")
_assert_close(pf.total_return(), res.metrics["total_return"], "total_return")
# --------------------------------------------------------------------------- #
# Scenario F — trailing stop
# --------------------------------------------------------------------------- #
def test_trailing_stop_parity(tmp_path):
# Always long. The high peaks at 112 (bar 3-4), so a 5% trailing stop rests
# at 112 * 0.95 = 106.4. Bar 5 low (104) trades through it: both engines exit
# at 106.4. (vectorbt's sl_trail also trails off the high when high is given.)
df = _bars(
o=[100, 101, 106, 110, 111, 108],
h=[100, 102, 108, 112, 112, 109],
l=[100, 100, 105, 109, 109, 104],
c=[100, 102, 107, 111, 110, 105],
)
strat = (bt.Strategy.create("trail")
.signal("d", col("close")).size(lit(1.0))
.trailing_stop(pct=5.0, use_high=True))
res = _mbt_run(df, strat, tmp_path, "F")
tr = res.trades_df()
# Always-long re-enters at the exit bar's close (a mark-flat no-op on the
# last bar), so the round-trip is the first two rows; assert on those.
assert float(tr.iloc[1]["fill_price"]) == pytest.approx(106.4)
assert int(tr.iloc[1]["exit_reason"]) == REASON_TRAIL
pf = _vbt_from_signals(df, [True, False, False, False, False, False],
sl=0.05, sl_trail=True)
v_tr = pf.trades.records_readable.iloc[0]
_assert_close(float(v_tr["Avg Exit Price"]), 106.4, "trailing exit")
_assert_close(pf.total_return(), res.metrics["total_return"], "total_return")
# --------------------------------------------------------------------------- #
# Scenario G — fees over multiple round-trips (cumulative accounting)
# --------------------------------------------------------------------------- #
def test_fees_multi_trade_parity(tmp_path):
# Two round-trips with a 20 bps taker fee, sized in FIXED UNITS. Fixed units
# are deliberate: under FractionOfEquity the engines size differently once
# fees exist (manifoldbt charges the fee on top of a full-equity notional,
# vectorbt reserves the fee out of cash), which is a legitimate design choice
# rather than a parity bug. Fixing the unit count isolates the thing both
# engines must agree on — the fee arithmetic and its cumulative effect.
units = 50.0
close = [100, 101, 102, 99, 98, 103, 99]
df = _bars(
o=close, h=[c + 0.5 for c in close], l=[c - 0.5 for c in close], c=close,
start="2023-06-01",
)
# Long while close > 100: enters bar 1, exits bar 3, re-enters bar 5, exits
# bar 6 → two clean round-trips.
entry = when(col("close") > lit(100.0), lit(units), lit(0.0))
strat = bt.Strategy.create("fees").signal("d", col("close")).size(entry)
fees = bt.FeeConfig(maker_fee_bps=10.0, taker_fee_bps=20.0)
res = _mbt_run(df, strat, tmp_path, "G", sizing="Units", fees=fees)
sig = pd.Series(close, dtype=float) > 100
entries = sig & ~sig.shift(1, fill_value=False)
exits = ~sig & sig.shift(1, fill_value=False)
pf = _vbt_from_signals(df, entries.tolist(), exits=exits.tolist(),
size=units, size_type="amount", fees=0.002)
_assert_close(pf.total_return(), res.metrics["total_return"], "total_return")