05 · Exécution et gestion des ordres¶
Entre l'intention et la position réelle se trouve la couche où l'argent se perd sans qu'aucune stratégie ne soit en cause.
La couche de risque¶
Elle s'intercale entre le moteur de signaux et l'exécution, et elle a le dernier mot.
class Risque:
def __init__(self, config, etat_compte):
self.c = config
self.compte = etat_compte
self.trades_du_jour = 0
self.pertes_consecutives = 0
self.pnl_du_jour = 0.0
def evaluer(self, intent, etat):
"""Retourne (autorise: bool, taille: int, motif: str)."""
if self.compte.en_pause:
return False, 0, "compte_en_pause"
if self.trades_du_jour >= self.c.max_trades_jour:
return False, 0, "plafond_trades"
if self.pertes_consecutives >= self.c.max_pertes_consecutives:
return False, 0, "pertes_consecutives"
if self.pnl_du_jour <= -self.c.perte_max_jour:
return False, 0, "perte_max_jour"
# règle de consistance : plafond = c/(1-c) * profit cumulé
c = self.c.ratio_consistance
plafond = (c / (1 - c)) * self.compte.profit_cycle
if self.pnl_du_jour >= plafond > 0:
return False, 0, "plafond_consistance"
if etat.dans_fenetre_evenement():
return False, 0, "fenetre_evenement"
taille = self.dimensionner(intent, etat)
if taille <= 0:
return False, 0, "taille_nulle"
return True, taille, "ok"
def dimensionner(self, intent, etat):
dd = self.compte.drawdown_disponible
if dd <= 0:
return 0
risque_par_contrat = (abs(intent.stop_price - etat.dernier_prix)
/ etat.tick) * etat.valeur_tick
if risque_par_contrat <= 0:
return 0
brut = int((self.c.fraction_risque * dd) // risque_par_contrat)
# réduction progressive à l'approche du drawdown
ratio = dd / self.compte.drawdown_initial
if ratio <= 0.30:
return 0
if ratio <= 0.50:
return min(brut, 1)
if ratio <= 0.75:
return min(brut, max(1, self.c.taille_base // 2))
return min(brut, self.c.taille_base)
La couche de risque peut refuser, jamais proposer
Elle ne crée pas de trades, elle en supprime. Cette asymétrie est ce qui garantit qu'un bug dans la couche de risque produit au pire une inaction, jamais une position non voulue.
Le choix du type d'ordre¶
| Type | Quand | Coût |
|---|---|---|
| Marché | Signal urgent, confirmation obtenue | Spread + slippage |
| Limite marketable (1 tick au-delà) | Cas général | Spread, slippage borné |
| Limite au prix courant | Signal patient, marché calme | Risque de non-exécution |
| Stop-market | Sortie de protection | Slippage en cas de gap |
| Stop-limit | Jamais pour un stop de protection | Risque de non-exécution |
Ne jamais utiliser de stop-limit comme stop de protection
Un stop-limit ne s'exécute pas si le marché traverse le prix limite d'un coup — précisément la situation où le stop est vital. Vous vous retrouvez avec une position ouverte, sans protection, dans un mouvement violent contre vous.
Le stop de protection est toujours un stop-market. Le slippage éventuel est le prix de la certitude d'être sorti.
La limite marketable¶
Un ordre limité placé au-delà du prix courant : achat limité à
ask + 1 tick. Il s'exécute immédiatement comme un ordre au marché, mais borne
le slippage à 1 tick.
Le meilleur compromis par défaut
Exécution quasi certaine en marché normal, protection contre un slippage catastrophique en marché anormal. Dans le cas rare où l'ordre ne s'exécute pas, c'est que le marché a bougé de plus d'un tick en quelques millisecondes — situation où ne pas être exécuté est probablement la bonne issue.
La machine à états d'une position¶
┌──────────┐
│ IDLE │
└────┬─────┘
│ intent autorisé
┌────▼─────────┐
│ SUBMITTED │──── rejet ───▶ IDLE (+ alerte)
└────┬─────────┘
│ fill
┌────▼─────────┐
│ OPEN │
└──┬───┬───┬───┘
│ │ │
stop │ │ │ temps max
│ │ └──────────┐
cible │ └────────┐ │
│ │ │
┌──▼────────────▼─────▼──┐
│ CLOSING │
└────────────┬───────────┘
│ confirmation
┌────▼─────┐
│ IDLE │
└──────────┘
class Position:
def __init__(self):
self.etat = "IDLE"
self.ouverte_a = None
self.intent = None
def peut_entrer(self):
return self.etat == "IDLE"
def verifier_temps(self, maintenant):
"""Sortie temporelle. Appelée à chaque tick."""
if self.etat != "OPEN":
return False
ecoule = maintenant - self.ouverte_a
return ecoule >= self.intent.max_hold_seconds
L'état SUBMITTED n'est pas facultatif
Sans lui, un signal qui se redéclenche entre l'envoi de l'ordre et la confirmation d'exécution provoque un second envoi. Vous vous retrouvez avec une position double — et un stop dimensionné pour une position simple.
C'est le pendant, côté machine à états, de l'idempotence côté exécuteur. Les deux sont nécessaires : l'idempotence protège contre le rejeu de messages, la machine à états contre le redéclenchement du signal.
La sortie temporelle¶
async def surveiller_temps(positions, executeur, horloge):
while True:
maintenant = horloge()
for pos in positions.ouvertes():
if pos.verifier_temps(maintenant):
journal.write({"type": "sortie_temps", "pos": pos.id})
await executeur.fermer_au_marche(pos)
await asyncio.sleep(1)
Une tâche séparée, cadencée à la seconde. Elle ne dépend pas de l'arrivée de nouveaux ticks — point important : en marché figé, aucun tick n'arrive et une sortie temporelle branchée sur le flux de données ne se déclencherait jamais.
Le suivi du P&L¶
def pnl_position(pos, prix_courant, valeur_tick, tick, commission_ar):
"""P&L latent en dollars, commissions incluses."""
sens = 1 if pos.side is Side.BUY else -1
ticks = sens * (prix_courant - pos.prix_entree) / tick
return ticks * valeur_tick * pos.taille - commission_ar * pos.taille
Toujours inclure la commission dans le P&L latent
Un P&L latent affiché brut est une illusion d'optique permanente : la position paraît à l'équilibre alors qu'elle est en perte du montant des commissions. Sur MES, c'est 1,02 $ par contrat, soit 0,82 tick — plus que ce que beaucoup de stratégies visent en premier objectif.
La gestion des fills partiels¶
def on_fill(pos, fill):
pos.quantite_remplie += fill.size
pos.prix_entree = moyenne_ponderee(pos.fills)
if pos.quantite_remplie < pos.quantite_demandee:
journal.write({"type": "fill_partiel",
"rempli": pos.quantite_remplie,
"demande": pos.quantite_demandee})
ajuster_protections(pos) # stop et cible sur la quantité réelle
else:
pos.etat = "OPEN"
Un fill partiel non géré laisse un stop dimensionné pour 10 contrats sur une position de 3, ou l'inverse — un stop de protection pour 10 contrats alors que vous n'en avez que 3 vous met en position inverse quand il se déclenche.
Le rollover¶
Les contrats à terme expirent. Le passage au contrat suivant se produit tous les trois mois pour ES/MES.
def contrat_actif(aujourdhui, calendrier):
"""Le contrat front-month, avec bascule quelques jours avant expiration."""
for contrat in calendrier:
if aujourdhui < contrat.date_rollover:
return contrat.symbole
raise ValueError("calendrier de contrats à mettre à jour")
Le rollover casse silencieusement les bots
Un symbole codé en dur (MESU6) fonctionne parfaitement jusqu'au jour où le
contrat expire. Le bot souscrit alors à un instrument sans volume, ne reçoit
plus de signaux, et ne signale aucune erreur — il attend simplement des
données qui n'arriveront jamais.
Deux protections : un calendrier de rollover, et une alarme sur absence de données (voir chapitre suivant). La seconde attrape aussi tous les autres cas de flux mort.
Résumé¶
- La couche de risque peut refuser, jamais proposer.
- Le stop de protection est toujours un stop-market, jamais un stop-limit.
- La limite marketable à 1 tick est le meilleur compromis par défaut.
- L'état
SUBMITTEDempêche le doublement de position. - La sortie temporelle tourne dans une tâche indépendante du flux de données.
- Le P&L latent inclut toujours les commissions.
- Gérer les fills partiels et le rollover, sous peine de panne silencieuse.
Chapitre précédent : Moteur de signaux · Chapitre suivant : Données historiques et backtest