mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-24 14:38:04 +00:00
release: v0.15.0
This commit is contained in:
@@ -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`.
|
||||
@@ -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:
|
||||
|
||||
@@ -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.7x–6.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.1x–8.7x to 9.5x–16.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.
|
||||
@@ -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.5x–16.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.5x–16.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.
|
||||
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user