Aller au contenu

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 SUBMITTED empê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