# 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`.