release: v0.13.0

This commit is contained in:
github-actions[bot]
2026-07-17 16:31:17 +00:00
parent e73d224cda
commit 944c2f6d21
5 changed files with 504 additions and 18 deletions
+152
View File
@@ -0,0 +1,152 @@
# Plan — Séries dérivées hoistées sur GPU (« option C ») (2026-07-17)
Objectif : permettre aux ops fenêtrées du transpileur (`ZScore`, `RollingMean`,
`Rsi`, `Roc`, futurs `RollingStd/Sum/...`) de prendre une **expression** en
entrée, pas seulement une colonne brute — ce qui débloque l'exemple 16
(`zscore(price_ratio - hr_ratio, zwin)`) et toute la famille « indicateur d'un
spread/ratio » sur GPU.
## Le problème (analysé le 2026-07-17, voir la discussion du rapport)
Le kernel évalue en **streaming** : les intermédiaires vivent une barre dans des
registres. Une op fenêtrée doit évincer la valeur qui sort de la fenêtre
(`sum -= x[jj-w]`) : pour une colonne (`CCOL`) ou une série hoistée (`HIND`)
c'est un accès mémoire ; pour une expression dérivée, la valeur d'il y a `w`
barres a été jetée. Le CPU n'a pas ce problème : son évaluateur matérialise
chaque nœud du DAG en array.
Seul `EwmMean` accepte une expression aujourd'hui — une EMA ne regarde jamais
en arrière. `RollingMean`, `Rsi`, `ZScore` exigent tous une colonne brute ;
cette limite est **préexistante et structurelle**, pas un choix de mon ZScore.
## Le design
**Matérialiser l'expression d'entrée comme lignes `hind` supplémentaires**,
dédupliquées par résolution de paramètres, remplies par un kernel généré ; l'op
fenêtrée reste un **fold in-thread** (par combo) qui lit la ligne dérivée en
accès aléatoire (`HINDAT(j, jj)` / `HINDAT(j, jj-w)`).
```
stage 1 hoist_fill : indicateurs feuilles (SMA/EMA/RSI/ROC/ZScore sur colonnes)
stage 2 derived_fill : expressions POINTWISE sur (cols lignes stage 1) ← NOUVEAU
sim sweep_t : folds fenêtrés in-thread lisant les lignes dérivées ← ÉTENDU
```
Décisions clés :
- **Éligibilité** : l'entrée doit être pointwise sur (colonnes brutes feuilles
hoistables littéraux params). Sinon → CPU, raison nommée (Phase 0 style) :
« windowed op over a non-pointwise expression ».
- **Dédup** : clé = empreinte structurelle de l'expression + bits résolus de
CHAQUE param référencé (vecteur, pas un seul u64 — une expression peut lire
plusieurs params). Même mécanique HashMap que `build_hoist_plan`. Nombre de
lignes = résolutions distinctes de l'ENTRÉE (ex. 16 : #smooth distincts, PAS
#(smooth × zwin) — la fenêtre reste par combo, in-thread). C'est ce qui rend
la VRAM raisonnable à 1M combos.
- **`derived_fill` est un kernel GÉNÉRÉ** (comme `sweep_t`), pas un kernel fixe :
le corps pointwise vient du MÊME `PipeCtx::emit` que le sim (mêmes sémantiques
NaN, division-par-zéro → NaN, etc. — zéro seconde implémentation qui dérive).
Pointwise ⇒ barres indépendantes ⇒ un thread par (ligne, barre), coalescé,
~1 ms pour 1000 lignes × 44k barres. Lancé entre hoist_fill et sweep_t.
- **Chaînage limité en V1** : dérivé = pointwise uniquement. Un fenêtré DANS un
fenêtré sur expression (`zscore(sma(expr))`) → CPU, raison nommée. (Extensible
plus tard en étages topologiques ; hors périmètre V1.)
- **Folds in-thread** : sémantique `*_no_nulls` de bt-expr AU COMPLET, y compris
`nan_count` — voir P0 ci-dessous. État ZScore in-thread ≈ 4-6 registres.
- **fp32 scan** : lignes dérivées calculées en f64, stockées f32 en mode scan
(même argument que le float-hind de la Part 5 : identique au cast côté
lecteur).
- **Garde VRAM** : bytes(hind + derived) > free/2 → CPU, raison nommée.
- **Multi-asset** : hors V1 (comme l'exo), raison nommée.
## Bit-identité (la chaîne de preuve)
CPU : matérialise l'entrée (array) puis `zscore_no_nulls(vals)`. GPU : la ligne
dérivée doit être **bit-identique à l'array CPU** (mêmes ops pointwise dans le
même ordre via le même emit(), fmad off, mêmes sources : cols identiques,
lignes stage-1 déjà pinnées à bt-expr) ; puis le fold in-thread refait
exactement `zscore_no_nulls` sur les mêmes valeurs. Chaque maillon testé.
## Étapes
### P0 — Bug latent découvert par l'analyse (à faire AVANT tout)
Le fold SMA **in-thread** (`ss += CCOL(...)`, chemin non-hoisté = multi-asset)
n'a AUCUNE gestion NaN, alors que `rolling_mean_pair` (bt-expr) exclut les NaN
de ses sommes (`bad_a`/`bad_b`). Divergence silencieuse sur données à trous en
multi-asset — même famille que `804de5e`, invisible parce que les tests NaN
tournent en mono (hoisting actif) et que le e2e multi n'a pas de NaN.
- Auditer les 3 folds in-thread (SMA / RSI / ROC) contre bt-expr sur NaN.
- Test : e2e multi avec NaN closes (étendre `gpu_sweep_multi_e2e`), vérifier
qu'il MORD (rituel du bug délibéré), corriger les folds.
- Estimation : ½ journée. Indépendant de C mais bloquant : les folds dérivés
réutiliseront ce code.
### P1 — Enregistrement des lignes dérivées (host)
- `PipeCtx` : détection d'éligibilité (fn `is_pointwise_over_leaves`),
empreinte, `derived_slot(expr) -> dj`, table `derived: Vec<DerivedSpec>`.
- `build_hoist_plan` étendu : résolution par combo des params de chaque
expression dérivée, dédup, `hrow` étendu aux lignes dérivées, garde VRAM.
### P2 — Kernel `derived_fill` généré + wiring
- Génération du source (corps par ligne via emit(), switch sur le kind de
ligne), compile NVRTC (cache par hash), launch stage 2, slot dans
`MBT_GPU_PROBE`.
- `ptxas -v` sur le kernel généré (attendu trivial : pointwise, pas d'état).
### P3 — Folds in-thread sur lignes dérivées
- Macro `HINDAT(j, idx)` (HIND à index arbitraire).
- `ZScore` d'abord (débloque ex. 16), puis `RollingMean`, `Rsi`, `Roc` :
entrée Column → chemins existants ; entrée dérivée-éligible → fold in-thread
avec sémantique bt-expr complète.
- **GATE ptxas** : le sim est à 48/48 registres. Si le fold in-thread fait
spiller, relâcher `__launch_bounds__` UNIQUEMENT pour les sources générées qui
contiennent des folds in-thread (le bound est par source générée, donc par
sweep) — mesurer, pas supposer.
### P4 — Tests (chaque test vérifié MORDANT via bug délibéré)
- `derived_fill_matches_bt_expr` : ligne dérivée vs array intermédiaire bt-expr,
barre par barre, NaN gaps inclus (harnais `hoist_fill_matches_bt_expr` étendu).
- Fold in-thread vs bt-expr : zscore(expr) complet, barre par barre.
- e2e forme exemple 16 (exo + spread + zscore, périodes en params) :
bit-identité CPU==GPU + non-vacuité + appel direct `run_sweep_lite_gpu`
(une retombée = échec, pas une comparaison CPU-vs-CPU silencieuse).
- Retombées nommées : non-pointwise, fenêtré imbriqué, VRAM, multi-asset.
### P5 — Bench + rapport
- Exemple 16 réel (hashrate), grille ~10k : cible GPU >> CPU (aujourd'hui :
2 589 c/s CPU, GPU retombe). Section dans `gpu_funding_bench.md`.
- Mettre à jour `docs/gpu-sweep-coverage-plan.md` (la Phase 2b absorbe C).
## Estimation & risques
| | |
|---|---|
| P0 | ½ j (correction comprise si le bug se confirme) |
| P1+P2 | ~1 j |
| P3 | ½ j + gate registres |
| P4+P5 | ½ j |
| **Total** | **~2-2,5 jours** |
Risques : (1) pression registres des folds in-thread — gate ptxas, bounds par
source ; (2) explosion du nombre de lignes dérivées si une expression référence
plusieurs params à forte cardinalité — garde VRAM + retombée nommée ; (3) toute
divergence NaN — couverte par les tests barre-par-barre ancrés bt-expr, jamais
par des miroirs.
---
## STATUT : LIVRÉ (2026-07-17)
- P0 : fold SMA in-thread corrigé (bad-count miroir de `rolling`), RSI/ROC
audités conformes ; e2e `gpu_multi_sma_nan_volume_{2,5}` vérifié mordant.
- P1-P2 : `DerivedReg`/`derived_slot`, plan étendu (dédup multi-params, hrow
absolu, garde VRAM sur le total), `derived_fill` généré par le même emit
(macros remappées), `hoist_fill` a un stride explicite.
- P3 : fold ZScore in-thread sur ligne dérivée via `HINDAT` ; GATE ptxas :
bounds relâchés à 7 (f64) / 8 (fp32) blocs pour les sources à folds, zéro
spill ; fold-free inchangé (48 regs @10).
- P4 : `derived_fill_matches_bt_expr` (mord sur +1e-12 que l'e2e ne voit pas),
e2e bit-identité + retombée nommée fenêtré-imbriqué.
- P5 : exemple 16 réel, 102x à 30k combos, Part 11 du rapport.
- Reste (hors V1, repris en Phase 2b) : `RollingMean`/`Rsi`/`Roc` sur entrée
dérivée (mécanique identique au ZScore), multi-asset, chaînage fenêtré.
+242
View File
@@ -0,0 +1,242 @@
# Plan — Couverture GPU du sweep (2026-07-17)
Contexte : le sweep GPU fait 300k combos/s (fp64 bit-identique) / 676k (fp32 scan),
mais seules les stratégies dans un périmètre étroit en profitent. Tout le reste
retombe sur le CPU (~1-20k combos/s) **en silence**. Ce plan séquence l'élargissement
du périmètre, du moins cher au plus cher, avec un gate data-driven au milieu.
État de départ : PR #74 (18 commits GPU) en attente de merge ; branche
`perf/python-metrics-extraction` (3 commits : dicts directs, `sweep_columns`,
fix du pool VRAM) prête. Les phases ci-dessous partent au-dessus.
Log de mesures : `gpu_funding_bench.md`. Toute nouvelle phase y ajoute sa section.
---
## Les trois couches qui bloquent une stratégie
1. **Ops transpileur manquants** : `Eq, Lag, Lead, RollingStd, RollingSum,
RollingMin, RollingMax, Scan` (→ Kalman et tout indicateur à état custom).
2. **Périmètre fast-path** (11 conditions) : la plus coûteuse est probablement
`orders.is_empty()` — **toute stratégie avec SL/TP retombe**. Aussi :
AtClose only, FixedBps only, frais mono-venue, pas de borrow rate, etc.
3. **Garde-fous sweep** : MTF, exogène, multi-source, cross-sectionnel,
funding multi-asset.
Tri technique de la couche 3 : funding multi = ~15 lignes (terme déjà écrit pour
le mono) ; exogène = ~30 lignes (l'exo est ASOF-joint sur la grille de barres au
chargement → colonne ordinaire pour le kernel) ; cross-sectionnel = moyen (le
kernel multi a déjà tous les symboles dans le thread, restructuration en 2
passes) ; MTF = moyen-lourd ; multi-source = lourd.
---
## Phase 0 — Rendre la retombée visible (≈1 h) — À FAIRE EN PREMIER
**Problème** : `run_sweep_lite(device="cuda")` retombe en silence. L'utilisateur
paie Pro, voit 12k combos/s, ne saura jamais que retirer son SL/TP le rendrait
25x plus rapide. Et nous, on priorise les phases suivantes à l'aveugle.
**Livrable**
- La raison de retombée (déjà construite : `gpu-sweep-unsupported: <raison>`)
remonte à l'utilisateur : `warnings.warn` une fois par sweep, et champ
`profile["gpu_fallback_reason"]` sur les résultats.
- Les raisons sont des chaînes stables (elles le sont déjà) → agrégeables plus
tard en télémétrie si souhaité.
**Validation** : sweep avec SL/TP → warning avec la raison exacte ; sweep GPU
nominal → aucun warning, champ absent ; e2e inchangés.
**Gate de décision** : après quelques jours d'usage réel (et un tour des
exemples/notebooks), la fréquence des raisons décide l'ordre des phases 2-4.
Sans données, l'ordre ci-dessous est notre meilleure estimation.
---
## Phase 1 — Quick wins périmètre (≈½ journée)
### 1a. Funding multi-asset dans `sweep_t_multi` (~15 lignes)
Le terme funding est déjà dans `sweep_t` (mono) : `capital += -position * close
* rate` par barre, par symbole ici. Retirer le garde-fou
`"multi-asset funding not yet on the GPU kernel"`.
- **Validation** : colonne funding synthétique injectée, e2e multi bit-identique
CPU==GPU (même harnais que le fix funding mono).
### 1b. Colonnes exogènes dans le sweep GPU (~30 lignes)
`load_exo_columns` ASOF-joint déjà l'exo sur `master_timestamps` → un
`Float64Array` de `num_bars`, indistinguable de `close` pour le kernel. Étendre
la résolution des `col_names` du transpileur aux colonnes exo alignées, retirer
`!config.exo_data.is_empty()` du garde-fou (garder `exo_sources`/multi-source
exclus).
- **Validation** : e2e avec exo synthétique bit-identique CPU==GPU ; l'exemple
`16_hashrate_exogene.py` passe sur GPU ; NaN de tête d'ASOF (avant le premier
point exo) couverts par le test.
- **Attention** : chemin multi-asset — un exo est global (pas par symbole), le
layout `cols [n_cols][n_sym][num_bars]` duplique par symbole ou garde-fou
multi+exo conservé en V1.
---
## Phase 2 — Ops transpileur faciles (≈½ journée) — **FAIT 2026-07-17**
**Livré** : ZScore/RollingStd/RollingSum/RollingMean/Rsi/Roc sur colonne OU
expression pointwise (séries dérivées), Eq, RollingMin/Max (hoistés, rescan
exact), scan sans param → colonne hôte. **Refusé avec raison nommée** :
Lag/Lead — mur sémantique null-vs-NaN (le CPU garde la position sur cible
null, le kernel n'a qu'un encodage) ; il faudrait un canal de validité.
Détails : Part 14 du rapport.
`ZScore`, `RollingStd`, `Eq`, `Lag`, `Lead`, `RollingSum`, `RollingMin`, `RollingMax`.
Débloque : Bollinger (rolling_std), breakouts (rolling_max/min), signaux à
retard (lag), et retire un pan de retombées silencieuses.
**`ZScore` en tête de liste** : mesuré 2026-07-17, c'est ce qui bloque
`examples/16_hashrate_exogene.py` (la Phase 1b a débloqué son exo, mais il bute
maintenant sur `indicator ZScore is not supported`). ZScore est une op à part
entière dans bt-expr, pas un `rolling_std` déguisé — je l'avais manquée dans
l'inventaire initial ; c'est le warning de la Phase 0 qui l'a nommée.
**Mise à jour 2026-07-17 (suite)** : ZScore-sur-colonne est livré (`d883edd`),
mais l'exemple 16 fait `zscore(EXPRESSION)` et retombe encore : TOUTES les ops
fenêtrées du transpileur exigent une colonne brute (limite structurelle
préexistante — l'éviction `sum -= x[jj-w]` exige un accès aléatoire que le
streaming n'a pas ; seule l'EMA, sans regard arrière, accepte une expression).
Le correctif structurel est le **plan « séries dérivées »** :
[`gpu-derived-series-plan.md`](gpu-derived-series-plan.md) (~2-2,5 j, P0 = bug
NaN latent des folds in-thread multi-asset découvert par l'analyse).
**Mise à jour 2026-07-17 (fin de journée) : plan séries dérivées LIVRÉ
(P0-P5).** Le bug NaN du fold SMA in-thread est corrigé (e2e multi NaN-volume,
rouge-puis-vert), et `zscore(EXPRESSION)` tourne sur GPU : l'entrée pointwise
est matérialisée en lignes dérivées (kernel `derived_fill` généré par le même
emit), le fold z-score lit la ligne via `HINDAT` in-thread. Exemple 16 réel :
**102x** à 30k combos (0.43 s vs 43.6 s CPU), bit-identique. Rapport : Part 11
de `gpu_funding_bench.md`. Reste de la Phase 2 : brancher `RollingMean`/`Rsi`/
`Roc` sur le même chemin dérivé, puis les ops manquantes ci-dessus.
**Trou distinct découvert au passage** : une stratégie SANS périodes dynamiques
part dans `transpile_sizing` (pointwise uniquement) et meurt sur le premier
`EwmMean`, bien avant le pipeline. L'exemple 16 tel quel tombe là-dessus. Peu
grave pour un sweep (on paramètre les fenêtres, donc on passe par
`transpile_pipeline`), mais à garder en tête pour `run_batch`.
**Règles héritées de la campagne (non négociables)**
- Chaque fold miroir **bt-expr op-pour-op**, y compris NaN : le bug des folds
NaN (Part 5) venait exactement d'un miroir non ancré au moteur.
Pièges connus : `Eq` = `(a-b).abs() < f64::EPSILON` (pas `==`) ;
`RollingStd` : même formule de Welford/somme que bt-expr, même fenêtre sale.
- Tests de parité **contre bt-expr** (pattern `gpu_fold_nan_gaps_*`), jamais
contre un miroir du même algorithme.
- Hoisting : chaque nouveau kind hoistable rejoint `hoist_fill` (kind + period),
sinon état par thread via `state_decls` — attention au **cap 48 registres**
(ptxas -v systématique, la table des résultats négatifs Parts 4-5 fait foi).
---
## Phase 3 — `scan` sur GPU → Kalman — **FAIT 2026-07-17**
**Livré** : gate 3a passé (bande Kalman dépliée + fold = 94 regs, zéro
spill) ; `lower_scan_tape` extrait dans bt-expr (une bande, deux exécuteurs) ;
traduction 1:1 ScanOp→CUDA (Eq EPSILON du VM répliqué, ≠ du == array) ;
`derived_fill` séquentiel par ligne → fenêtré-sur-scan-sweepé OK (dédup par
bits résolus) ; fix param()-dans-scan (has_dynamic_periods + collecteur
Python). Surface (q,w) 1M réelle : 25.8 s. Refus nommés : log/exp, inits
non-pointwise, colonnes à nulls. Phase 5 (dispatch auto) : device="auto",
seuil mesuré 1k combos, Part 15.
## Phase 3 (plan d'origine, conservé pour référence)
### 3a. Sonde registres (30 min, GATE)
Écrire À LA MAIN le kernel Kalman (bande ScanOp dépliée en CUDA), `ptxas -v`
sm_86. Le kernel sim est à 48/48 registres **zéro marge** : si la bande scan
spille, le gain s'évapore → **stop, on documente, on ne construit pas le
générateur**.
### 3b. Générateur ScanOp → CUDA
La bande `CompiledScan.instructions` est en SSA plat (24 opcodes scalaires) →
traduction mécanique, 1 ligne CUDA par opcode, branchée sur
`state_decls`/`state_init`/`body` existants.
Sémantiques à mirrorer exactement (source de vérité : `evaluator.rs`) :
- `Div` : dénominateur `== 0.0`**NaN** (pas l'inf IEEE)
- `Eq` : `(a-b).abs() < f64::EPSILON`
- init : `prev_state[i] = init_arr.value(0)`, null → `0.0`
- ordre d'exécution : registre = pointeur d'instruction, writeback après output
### 3c. `param()` dans un nœud scan (bug indépendant, petit)
`CompiledScan.param_names` existe déjà ; c'est la **découverte des paramètres de
stratégie** qui ne descend pas dans les nœuds scan ("uses undefined parameters:
q"). Fix côté validation/collecte → débloque le sweep de `q` (la dimension la
plus intéressante du filtre), même sur CPU.
### 3d. Acceptance
- Parité GPU vs VM bt-expr bit-identique, NaN gaps inclus.
- e2e sweep Kalman CPU==GPU bit-identique.
- **La heatmap 1M Kalman (w×en, puis q×en) sur GPU** — l'objectif d'origine.
Budget attendu : quelques secondes au lieu de 15 min CPU.
- fp32 : vérifier le ranking (le scan ajoute des Div/Sqrt → sensibilité à
mesurer, pas à supposer).
---
## Phase 4 — SL/TP sur le fast path GPU (gros ; décision APRÈS Phase 0)
`orders.is_empty()` est probablement le garde-fou le plus coûteux en usage réel
(hypothèse à confirmer par les données de la Phase 0). Résolution intra-bar
SL/TP = high/low + sémantique de priorité déjà fixée à la 0.12.0 côté lite CPU.
Design séparé — ne pas commencer avant d'avoir les données de fréquence et le
merge des phases précédentes.
## Phase 5 — Dispatch GPU/CPU (à faire À LA FIN)
**Mesuré 2026-07-17** : sur une grille de 100 combos, le GPU est **6x plus LENT**
que le CPU (882 vs 5 327 combos/s) — les coûts fixes (upload, launch, hoist,
compile) écrasent le gain. À 6 000 combos le GPU gagne 7x. Il existe donc un
seuil de rentabilité, et aujourd'hui l'utilisateur qui passe `device="cuda"` sur
une petite grille **paie pour être ralenti, sans rien qui le prévienne**.
Deux options, à trancher le moment venu :
1. **Bascule automatique** sous un seuil (attention : `device="cuda"` explicite
qui exécute sur CPU, c'est exactement le genre de magie silencieuse que la
Phase 0 vient de supprimer — il faudrait le dire).
2. **Warning symétrique** : « grille trop petite pour amortir le GPU (N combos,
seuil ~M) », et laisser l'utilisateur décider.
Le seuil dépend de num_bars, n_sym et de la complexité du pipeline : à calibrer
par mesure, pas à coder en dur (cf. [[feedback_portability_first]] : pas de
seuil spécifique à une machine). Placé en fin de plan volontairement : c'est du
polish, les phases 1-3 débloquent des stratégies entières.
## Phase 6 — Backlog (ordre selon Phase 0)
- Cross-sectionnel dans `sweep_t_multi` (2 ops, restructuration 2 passes).
- Exo en multi-asset (broadcast de la série globale dans chaque slab symbole).
- MTF (grilles multiples, mapping d'index par timeframe).
- Multi-source / cross-exchange (chemin général, lourd).
- Frais par venue, borrow rate, AtOpen (extensions du fast path).
- `transpile_sizing` (chemin sans params) limité au pointwise → bloque `run_batch`
sur toute stratégie à indicateurs.
---
## Invariants transverses (toutes phases)
1. **Bit-identité fp64 CPU==GPU** : chaque phase ajoute ses cas au harnais e2e
(`to_bits` par métrique). Les checksums 21-métriques du bench servent de
non-régression (`14fabff006fda0a0` fp64 / `c1be6db787e2f49f` fp32 sur la
grille de référence).
2. **Parité ancrée à bt-expr**, jamais à un miroir (leçon Part 5).
3. **ptxas -v à chaque changement de kernel** (cap 48 regs, spills interdits).
4. **Un garde-fou ne se retire qu'avec le test qui prouve le remplacement.**
5. **Chaque phase = sa branche + sa PR + sa section dans le bench log.**
Livraison incrémentale, pas de méga-branche de 18 commits une deuxième fois.
## Estimation d'ensemble
| Phase | Effort | Gain |
|---|---|---|
| 0 — retombée visible | ~1 h | priorisation data-driven + UX Pro |
| 1 — funding multi + exo | ~½ j | 2 familles débloquées, quasi sans risque |
| 2 — 7 ops faciles | ~½ j | Bollinger/breakout/lag sur GPU |
| 3 — scan → Kalman | 1-2 j (gate 30 min) | la stratégie de l'article sur GPU + échappatoire universel |
| 4 — SL/TP | gros | à chiffrer après Phase 0 |
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "manifoldbt"
version = "0.12.3"
version = "0.13.0"
description = "Rust-powered backtesting engine for quantitative research"
requires-python = ">=3.9"
license = { file = "LICENSE" }
+92 -10
View File
@@ -1,7 +1,7 @@
"""manifoldbt: Fast research backtesting with Rust core + Python DSL."""
import copy
import json
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, Union
import importlib as _importlib
@@ -18,6 +18,7 @@ from manifoldbt._native import (
run_json,
run_sweep as _run_sweep_native,
run_sweep_lite as _run_sweep_lite_native,
sweep_columns as _sweep_columns_native,
run_with_parquet,
py_run_walk_forward as _run_walk_forward_native,
py_run_sweep_2d as _run_sweep_2d_native,
@@ -869,7 +870,8 @@ def run_sweep_lite(
store: DataStore,
*,
max_parallelism: int = 0,
device: str = "cpu",
device: str = "auto",
precision: str = "fp64",
) -> List["BatchResultLite"]:
"""Run a parameter sweep returning only metrics (no Arrow output).
@@ -882,14 +884,47 @@ def run_sweep_lite(
config: Backtest configuration.
store: Data store.
max_parallelism: Maximum threads. 0 = all available cores.
device: ``"cpu"`` (default) or ``"cuda"``/``"gpu"``. The GPU path
accelerates single-asset, AtClose + FixedBps sweeps and produces
results numerically identical to the CPU path. **Pro-only**: a
Community license raises ``PermissionError`` for ``device="cuda"``
(Community keeps the full-speed CPU sweep with no restriction).
Requires a build with ``--features cuda`` and a CUDA device; for any
unsupported strategy/config (or when no GPU is present at runtime) it
silently falls back to the CPU sweep, so results are never affected.
device: ``"auto"`` (default), ``"cpu"``, or ``"cuda"``/``"gpu"``.
The GPU path produces results numerically identical to the CPU
path. ``"auto"`` picks per sweep: small grids run on the CPU (the
GPU has a ~50 ms fixed launch floor, so the CPU wins below ~1,000
combos -- override with ``MBT_GPU_AUTO_MIN_COMBOS``), large grids
run on the GPU when the build, a device, and a Pro license are
available, and the CPU otherwise. This is the default because it is
never slower than the better of the two by more than the launch
floor and its results match the CPU bit-for-bit, so it is safe to
leave on: with no GPU, no Pro license, or a Community build it is
simply the CPU sweep. **Pro-only**: a Community license raises
``PermissionError`` for ``device="cuda"`` (``"auto"`` simply stays
on the CPU; Community keeps the full-speed CPU sweep with no
restriction). ``"cuda"`` requires a build with ``--features cuda``
and a CUDA device; for any unsupported strategy/config (or when no
GPU is present at runtime) it falls back to the CPU sweep with a
``UserWarning`` naming the reason, so results are never affected.
An unknown device string raises ``ValueError`` instead of silently
running on the CPU.
precision: ``"fp64"`` (default) runs the GPU sweep in double precision,
bit-identical to the CPU path. ``"fp32"`` runs the single-asset GPU
kernel in single precision at the cost of approximate results: a
signal within ~1e-7 relative of a decision threshold can flip vs f64,
so occasional combos diverge. Intended as a **scan-only** accelerator
(rank in fp32, re-run the winner in fp64 for an exact P&L). Note the
speedup is modest (~1.1x measured on an RTX 3090): the per-bar
capital/position recurrence is latency-bound, so fp32's throughput
advantage barely applies. ``"fp32"`` requires ``device="cuda"``.
Metric resolution:
The lite path computes risk metrics from one equity point per UTC day
(this is what makes it fast), whereas :func:`run` uses the full-resolution
curve. ``final_equity``, ``total_return``, ``sharpe``, ``sortino``,
``volatility`` and ``max_drawdown`` are unaffected -- they match ``run``
exactly. Three annualisation-sensitive metrics differ slightly because
they are derived from the daily series: ``cagr`` (it starts from the
first daily equity rather than initial capital), ``calmar`` and
``ulcer_index``. The gap is small (< ~0.4% relative on a multi-year daily
backtest) and is the same for every sweep regardless of orders. Sort and
rank on it freely; for an exact single-figure P&L, re-run the winning
combo through :func:`run`.
Returns:
One :class:`BatchResultLite` per combo (Cartesian product order).
@@ -911,11 +946,58 @@ def run_sweep_lite(
store,
max_parallelism,
device,
precision,
)
except (ValueError, RuntimeError) as exc:
raise _classify_error(exc) from exc
def sweep_columns(
batch: List["BatchResultLite"],
names: Union[str, List[str]],
) -> Union["Any", Dict[str, "Any"]]:
"""Extract whole metric columns from a sweep as numpy arrays.
``result.metrics`` builds a 21-key dict per combo, so reading one metric off
a large sweep creates millions of throwaway floats. This walks the results
once and copies each requested column straight into a numpy array, which is
~20x faster: on a 1M-combo sweep, ~1.1s of extraction becomes ~0.05s.
Args:
batch: The list returned by :func:`run_sweep_lite`.
names: One column name, or a list of them. Available: ``final_equity``,
``trade_count``, and every :class:`PerformanceMetrics` field
(``sharpe``, ``sortino``, ``calmar``, ``max_drawdown``, ``alpha``,
``beta``, ``tstat_alpha``, ``total_return``, ``cagr``,
``volatility``, ``skewness``, ``kurtosis``, ``tail_ratio``,
``omega_ratio``, ``ulcer_index``, ``best_day``, ``worst_day``,
``avg_daily_return``, ``pct_positive_days``,
``max_drawdown_duration_days``, ``tstat_sharpe``).
Returns:
A single ``np.ndarray`` if ``names`` is a string, else a dict mapping
each name to its array. Arrays are float64 and in combo order (the same
order as ``batch``), so ``np.argmax``/``argsort`` indices map straight
back onto it. ``trade_count`` comes back as float64 like the rest.
Note:
The arrays are read-only views over the returned buffers (no copy). Call
``.copy()`` if you need to mutate one.
Example:
>>> batch = mbt.run_sweep_lite(strategy, grid, config, store, device="cuda")
>>> sharpe = mbt.sweep_columns(batch, "sharpe")
>>> best = batch[int(sharpe.argmax())]
"""
import numpy as _np
single = isinstance(names, str)
wanted = [names] if single else list(names)
raw = _sweep_columns_native(batch, wanted)
out = {n: _np.frombuffer(raw[n], dtype=_np.float64) for n in wanted}
return out[names] if single else out
# ---------------------------------------------------------------------------
# Research API
# ---------------------------------------------------------------------------
+17 -7
View File
@@ -31,13 +31,23 @@ def _collect_params(expr: "Expr", out: Dict[str, Any]) -> None:
if name not in out:
out[name] = expr._param_meta
for arg in expr._args:
if isinstance(arg, Expr):
_collect_params(arg, out)
elif isinstance(arg, str):
# DynPeriod/DynFloat param name — check global registry
from manifoldbt.expr import _param_registry
if arg in _param_registry and arg not in out:
out[arg] = _param_registry[arg]
_collect_params_arg(arg, out)
def _collect_params_arg(arg: Any, out: Dict[str, Any]) -> None:
if isinstance(arg, Expr):
_collect_params(arg, out)
elif isinstance(arg, (list, tuple)):
# Scan nodes carry their init/update expressions as LISTS of Exprs;
# skipping them silently dropped every param() used inside a scan
# ("strategy uses undefined parameters: q" on a swept Kalman).
for item in arg:
_collect_params_arg(item, out)
elif isinstance(arg, str):
# DynPeriod/DynFloat param name — check global registry
from manifoldbt.expr import _param_registry
if arg in _param_registry and arg not in out:
out[arg] = _param_registry[arg]
class Strategy: