01 · Architecture du bot¶
Un bot de trading est un système temps réel avec de l'argent en jeu. Les propriétés qui comptent ne sont pas celles d'une application web classique.
Les cinq propriétés non négociables¶
| Propriété | Ce qu'elle signifie |
|---|---|
| Déterminisme | Les mêmes données produisent les mêmes décisions |
| Idempotence | Rejouer un message ne crée pas un second ordre |
| Réconciliation | Après reconnexion, l'état local correspond à celui du courtier |
| Observabilité | Toute décision est reconstituable a posteriori |
| Arrêt sûr | Il existe un moyen d'arrêter et de tout fermer en une action |
L'ordre de priorité en cas de doute
Sécurité > correction > performance. Un bot lent qui refuse d'agir quand il doute perd de l'argent lentement. Un bot rapide qui agit sur un état incertain le perd d'un coup. En cas d'ambiguïté sur l'état, ne rien faire est la bonne réponse par défaut.
L'architecture recommandée¶
┌──────────────────────────────────────────────────────┐
│ superviseur │
│ (santé, arrêt d'urgence, redémarrage) │
└──────────────────────────────────────────────────────┘
│ │
┌───────▼────────┐ ┌────────▼─────────┐
│ adaptateur │ événements │ journal │
│ de données │─────────────────▶│ (append-only) │
│ (Rithmic…) │ └──────────────────┘
└───────┬────────┘
│ MarketEvent
┌───────▼────────────────────────────────┐
│ agrégation │
│ footprint · OFI · barres · contexte │
└───────┬────────────────────────────────┘
│ État de marché
┌───────▼────────────────────────────────┐
│ moteur de signaux │
│ (pur, sans effet de bord, testable) │
└───────┬────────────────────────────────┘
│ Intent
┌───────▼────────────────────────────────┐
│ risque et conformité │
│ plafonds · consistance · drawdown │
└───────┬────────────────────────────────┘
│ Order
┌───────▼────────────────────────────────┐
│ adaptateur d'exécution │
│ (Rithmic / ProjectX / simulateur) │
└─────────────────────────────────────────┘
Le principe central¶
Le moteur de signaux est une fonction pure
def signaux(etat_marche, contexte, parametres) -> list[Intent]:
...
Pas d'entrées/sorties, pas d'horloge interne, pas d'appel réseau, pas d'aléatoire. Conséquences directes :
- il est testable avec de simples assertions ;
- il est backtestable sans réécriture — le backtest et le live appellent exactement le même code ;
- il est déterministe : le même flux d'événements produit la même séquence de décisions, ce qui rend les bugs reproductibles.
C'est le seul choix d'architecture de ce rapport qui mérite d'être imposé avant d'écrire la première ligne. Tout le reste peut être ajouté plus tard ; celui-ci ne peut pas l'être.
Les types de données¶
from dataclasses import dataclass
from enum import Enum
class Side(Enum):
BUY = 1
SELL = -1
@dataclass(frozen=True)
class Trade:
ts_exchange: int # nanosecondes, horloge bourse
ts_local: int # nanosecondes, horloge locale
symbol: str
price: float
size: int
aggressor: int # +1, -1 ou 0
@dataclass(frozen=True)
class Quote:
ts_exchange: int
ts_local: int
symbol: str
bid: float
bid_size: int
ask: float
ask_size: int
@dataclass(frozen=True)
class Intent:
"""Ce que le moteur de signaux veut faire. Ne connaît aucun courtier."""
symbol: str
side: Side
size: int
stop_price: float
target_price: float
reason: str # pour le journal
max_hold_seconds: int
Pourquoi frozen=True
Un événement de marché immuable ne peut pas être modifié par erreur en aval. Le coût est nul, le bénéfice est une classe entière de bugs éliminée. C'est de la paresse bien placée : une ligne évite un débogage de trois heures.
Les deux horodatages ne sont pas redondants
ts_exchange ordonne les événements ; ts_local détermine ce que le
bot pouvait savoir. Un backtest qui n'utilise que ts_exchange fait
voyager le bot dans le temps : il réagit à un événement avant de l'avoir
reçu. Conservez les deux, dès la collecte — on ne peut pas les reconstruire
après coup.
La boucle principale¶
async def boucle(adaptateur, agregat, moteur, risque, executeur, journal):
async for event in adaptateur.stream():
journal.write(event) # 1. journaliser d'abord
etat = agregat.update(event) # 2. agréger
if etat is None:
continue
intents = moteur.signaux(etat) # 3. décider (pur)
for intent in intents: # 4. filtrer
verdict = risque.evaluer(intent, etat)
journal.write(verdict)
if verdict.autorise:
await executeur.envoyer(intent) # 5. exécuter
L'ordre importe : journaliser avant de décider. Si le bot plante à l'étape 3, le journal contient l'événement qui l'a provoqué. C'est la différence entre un bug reproductible et un mystère.
Le journal¶
Un fichier append-only, une ligne JSON par événement, jamais réécrit.
import json, time
class Journal:
def __init__(self, path):
self.f = open(path, "a", buffering=1) # ligne par ligne
def write(self, obj):
self.f.write(json.dumps({
"ts": time.time_ns(),
"type": type(obj).__name__,
"data": obj.__dict__ if hasattr(obj, "__dict__") else obj,
}) + "\n")
Le journal est le seul outil de débogage qui fonctionne en production
Un bot de trading ne se débogue pas au pas à pas : le marché n'attend pas. Le journal permet de rejouer une séance exactement, de comprendre pourquoi une décision a été prise, et de prouver ce qui s'est passé en cas de litige avec une prop firm.
Coût : trente lignes de code et quelques mégaoctets par jour. Aucun autre investissement du rapport n'a un rapport valeur/effort comparable.
Écrivez-le avant la première stratégie.
La gestion des connexions¶
Rithmic sépare ses services en plants indépendants (voir Panorama). Chacun peut tomber séparément.
| État | Comportement du bot |
|---|---|
| Ticker plant tombé | Arrêt des nouvelles entrées, positions gérées par les ordres serveur |
| Order plant tombé | Arrêt complet, alerte, aucune tentative de contournement |
| Les deux tombés | Arrêt, alerte, intervention humaine |
| Reconnexion | Réconciliation obligatoire avant toute nouvelle décision |
async_rithmic fournit la reconnexion automatique avec backoff configurable,
mais ses auteurs précisent explicitement que la bibliothèque ne traite pas la
gestion des pannes ni la correction sous charge — c'est votre travail.
La réconciliation¶
async def reconcilier(executeur, etat_local):
"""À exécuter après CHAQUE reconnexion, avant toute nouvelle décision."""
positions = await executeur.get_positions()
ordres = await executeur.get_ordres_actifs()
if positions != etat_local.positions:
journal.write({"alerte": "divergence_position",
"local": etat_local.positions,
"courtier": positions})
etat_local.positions = positions # le courtier fait autorité
etat_local.pause() # supervision humaine requise
etat_local.ordres = ordres
Le courtier fait toujours autorité
En cas de divergence entre votre état local et celui du courtier, le courtier a raison. Toujours. Un bot qui « corrige » l'état du courtier pour le faire correspondre au sien envoie des ordres sur la base d'une fiction, et transforme un désaccord d'affichage en position réelle non voulue.
Les protections côté serveur¶
R|API+ propose des stops suiveurs, brackets et OCO qui restent actifs même si la connexion du client tombe (Ironbeam).
À utiliser systématiquement
Une coupure internet domestique de trois minutes avec une position ouverte et un stop géré côté client est un scénario de perte totale. Le stop côté serveur transforme ce scénario en perte bornée et connue.
Si votre voie d'accès ne permet pas de stop côté serveur, réduisez la taille de vos positions en conséquence. La contrainte est réelle et se paie quelque part.
L'arrêt d'urgence¶
Trois niveaux, du plus doux au plus radical :
| Niveau | Action | Déclencheur |
|---|---|---|
| Pause | Plus de nouvelles entrées, positions gérées | Plafond atteint, doute |
| Flatten | Fermeture de toutes les positions au marché | Perte journalière, anomalie |
| Kill | Flatten + arrêt du processus | Bug détecté, panne réseau |
Le déclenchement doit être possible de l'extérieur du bot : un fichier sentinelle suffit et ne dépend d'aucune infrastructure.
import os
def verifier_arret():
if os.path.exists("/tmp/bot.kill"):
return "kill"
if os.path.exists("/tmp/bot.pause"):
return "pause"
return None
Un touch doit suffire
touch /tmp/bot.kill depuis un téléphone en SSH arrête le bot. Pas d'API
d'administration, pas d'interface web, pas de service de messagerie qui peut
tomber. Un fichier. C'est laid, et c'est ce qui fonctionne quand tout le
reste est cassé.
Les langages¶
| Langage | Quand |
|---|---|
| Python | Par défaut. Suffisant pour tout signal d'horizon > 1 s |
| C# | Si vous développez dans NinjaTrader ou Quantower |
| C++ | Si vous développez dans Sierra Chart (ACSIL), ou < 1 ms |
| Rust | Si vous aimez Rust ; rithmic-rs existe |
Le débat sur la performance de Python est un faux débat
Avec 50 ms de latence réseau, les 2 ms de traitement Python représentent 4 % du budget total. Réécrire en C++ pour gagner 1,9 ms est un mauvais investissement tant que la latence réseau domine.
Python devient un problème dans un seul cas : si votre boucle prend plus de temps que l'intervalle entre deux messages, vous accumulez du retard sans borne. C'est un problème d'architecture (traitement synchrone dans la boucle d'événements), pas de langage — et il se règle en déplaçant le travail lourd hors du chemin critique.
Résumé¶
- Cinq propriétés : déterminisme, idempotence, réconciliation, observabilité, arrêt sûr.
- Le moteur de signaux est une fonction pure : testable, backtestable, déterministe.
- Journaliser avant de décider ; le journal est le seul débogueur qui marche en production.
- Le courtier fait autorité sur l'état ; réconcilier après chaque reconnexion.
- Utiliser les ordres de protection côté serveur.
- Arrêt d'urgence par fichier sentinelle.
- Python suffit tant que la latence réseau domine.
Chapitre suivant : Connexion Rithmic en Python