Structure d’une stratégie

Les stratégies sont décrites dans des fichiers TOML. TOML organise les paramètres en paires clé-valeur, regroupées en sections entre crochets. Vous modifiez des valeurs et des blocs, sans écrire de code.

Ce manuel se limite aux constructions TOML utilisées ici. Pour une description complète, reportez-vous à la documentation officielle de TOML.

Dans la suite, une section TOML est appelée un bloc. Deux formes de blocs sont utilisées.

Un bloc écrit avec des crochets simples, par exemple [backtest], correspond à une table TOML unique. Ce type de bloc décrit une configuration qui n’existe qu’en un seul exemplaire dans le fichier.

Un bloc écrit avec des doubles crochets, par exemple [[moving_average]], correspond à un tableau de tables. Il représente une liste de blocs du même type, par exemple pour définir plusieurs moyennes mobiles dans une même stratégie.

Organisation générale d’une stratégie

Une stratégie écrite en TOML repose sur plusieurs ensembles de blocs.

  • Les blocs de configuration regroupent les options générales de la stratégie et les paramètres du backtest, comme les symboles, les timeframes, la période et le capital initial. Parmi les blocs de configuration, seul [backtest] est obligatoire.

  • Les blocs d’indicateurs transforment les données de marché en séries temporelles nommées. Une série contient une valeur par bougie, par exemple celle d’une moyenne mobile. Vous consultez une série avec une notation entre crochets : fast ou fast[0] pour la bougie courante, fast[1] pour la précédente. Chaque bloc indicateur possède un id pour référencer sa série.

  • Le bloc [filters] définit des critères de validation du backtest, par exemple un nombre minimal de positions, un ROI minimal ou un drawdown maximal. Si un critère n’est pas respecté, le résultat est écarté. Certains filtres peuvent aussi interrompre le backtest pendant son exécution.

  • Les blocs [[objective]] définissent un ou plusieurs objectifs, qui utilisent les métriques du backtest pour classer les combinaisons. Le moteur conserve les meilleurs résultats pour chaque objectif. Si aucun objectif n’est fourni, un objectif par défaut basé sur le net profit est ajouté.

  • La logique d’exécution combine des blocs conditionnels, comme [[crossover]], et des blocs d’action, comme [[entry]] ou [[close]]. Chaque bloc conditionnel possède un id et indique quel bloc exécuter ensuite lorsqu’il est validé. Sur un bloc conditionnel, la condition est évaluée à chaque bougie. Tant qu’elle est fausse, l’exécution reste sur ce bloc. Dès qu’elle devient vraie, le bloc est validé et l’exécution passe au bloc suivant. Les blocs d’action déclenchent une opération de trading, comme ouvrir ou fermer une position, puis passent au bloc suivant.

Les blocs de configuration

Plusieurs blocs de configuration contrôlent l’exécution et le stockage des résultats.

  • Le bloc [general] regroupe les options globales du moteur, comme le nombre de threads ou le répertoire de sortie.
  • Le bloc [backtest] définit le cadre du backtest : période de test, symboles et timeframes à charger, capital initial.
  • Le bloc [logging] contrôle la journalisation : destination des logs et affichage des résultats.
  • Le bloc [database] configure les bases SQLite qui stockent les résultats.

Voici un exemple de configuration :

[backtest]
symbol          = "KUCOIN:BTCUSDT"
timeframe       = "240"
start_date      = 2020-01-01
end_date        = 2026-01-01
initial_capital = 1000

[logging]
file_logging_enabled = true
include_trades       = true
objective_limit      = 5

[database]
backtest_export_enabled = true
objective_limit         = 100

Les blocs indicateurs

Les blocs indicateurs décrivent des séries calculées à partir des prix ou d’autres séries. Ils ont deux objectifs :

  • exposer des séries nommées (par exemple fast, slow, rsi_14) qui pourront être utilisées dans les expressions des blocs conditionnels
  • définir les hyperparamètres à explorer : longueurs d’indicateurs, seuils, facteurs, etc.

Structure type d’un bloc indicateur

La plupart des blocs indicateurs suivent la même logique :

  • id est l’identifiant de la série, par exemple "fast", "slow" ou "rsi_14".
  • source désigne les séries d’entrée utilisées : prix standards ("open", "high", "low", "close", etc.) ou sorties d’autres indicateurs.
  • symbol et timeframe sont optionnels. Ils permettent de surcharger, pour ce bloc, le symbole ou le timeframe définis dans [backtest].
  • Un ou plusieurs paramètres numériques sont exprimés sous forme de plages :
    • souvent avec des sous-clés de la forme length.start, length.stop et length.step pour une longueur de fenêtre,
    • parfois sous d’autres noms, mais toujours avec la même structure start / stop / step.
  • Certains paramètres sont exprimés sous forme de listes de valeurs, par exemple type = ["sma", "ema", "wma", "zlema"] pour tester plusieurs types de moyenne mobile.

Exemple de blocs indicateurs : deux moyennes mobiles (rapide/lente) pour un signal de croisement, avec plusieurs types :

[[moving_average]]
id           = "fast"
type         = ["sma", "ema", "wma", "zlema"]
length.start = 2
length.stop  = 100

[[moving_average]]
id           = "slow"
type         = ["sma", "ema", "wma", "zlema"]
length.start = 2
length.stop  = 100

Validation des combinaisons d’hyperparamètres

Le bloc [constraints] permet d’écarter les combinaisons d’hyperparamètres qui n’ont pas de sens pour votre stratégie. Sa condition est évaluée avant chaque backtest : si elle est fausse, la combinaison est ignorée.

La clé condition contient cette expression logique. Elle peut référencer les hyperparamètres des blocs indicateurs et des blocs [[constant]].

Par exemple, dans un croisement de deux moyennes mobiles (fast et slow), fast.length < slow.length garantit que fast reste la plus réactive. Si plusieurs types de moyenne mobile sont explorés, fast.type = slow.type garantit aussi que les deux moyennes restent du même type :

[constraints]
condition = "fast.length < slow.length and fast.type = slow.type"

Constantes optimisées

Le bloc [[constant]] déclare une constante scalaire nommée, indépendante du symbole et du timeframe. Pendant l’optimisation, sa valeur est explorée sur une plage, puis vous pouvez utiliser cette constante dans les expressions.

Chaque bloc [[constant]] contient les éléments suivants :

  • id est le nom unique de la constante.
  • Une plage numérique est décrite par start, stop et éventuellement step, sur laquelle le moteur va itérer.

Voici un exemple avec deux constantes optimisées, utilisées typiquement pour un take profit et un stop loss :

[[constant]]
id    = "take_profit_pct"
start = 1
stop  = 10

[[constant]]
id    = "stop_loss_pct"
start = 1
stop  = 10

Une fois ces blocs déclarés, vous pouvez utiliser take_profit_pct et stop_loss_pct dans les expressions. Dans chaque grille, un backtest est exécuté pour chaque combinaison de valeurs de ces constantes.

Filtres sur les résultats de backtest

Le bloc [filters] définit des critères de validation appliqués aux backtests. Selon le filtre utilisé, ces critères sont vérifiés soit sur le résultat final, soit pendant l’exécution du backtest.

Des filtres comme min_required_positions ou min_roi_pct sont évalués à la fin du backtest. Ils permettent d’écarter les combinaisons dont le résultat final ne répond pas à vos critères.

D’autres filtres servent à interrompre un backtest dès qu’il devient inutile de le poursuivre. C’est notamment le cas de max_allowed_drawdown : si le drawdown maximal dépasse la limite définie, l’exécution s’arrête immédiatement.

Une protection s’applique aussi sur la perte maximale par rapport au capital initial, pilotée par max_initial_capital_loss. Par défaut, un backtest est interrompu dès que la perte dépasse 80 % du capital initial, même si vous ne déclarez pas cette valeur dans [filters].

Voici un exemple :

[filters]
min_required_positions   = 30    # Le backtest doit ouvrir au moins 30 positions.
min_roi_pct              = 20.0  # Le ROI minimal est de 20 %.
max_allowed_drawdown     = 35.0  # Le backtest s’arrête si le drawdown dépasse 35 %.
max_initial_capital_loss = 80.0  # Par défaut, le backtest s’arrête au-delà de 80 % de perte du capital initial.

En combinant [constraints] et [filters], vous réduisez l’espace de recherche, vous évitez d’exécuter jusqu’au bout des backtests déjà invalidés, et vous conservez uniquement les résultats réellement pertinents.

Objectifs

Les blocs [[objective]] décrivent les objectifs utilisés pour classer les combinaisons de paramètres testées lors de la recherche exhaustive.

Chaque bloc définit une formule, via la clé formula. Cette formule s’appuie sur les métriques du backtest, comme le profit, le drawdown, certains ratios, ou encore le nombre de trades. La clé ascending indique le sens du tri : false place les scores les plus élevés en tête, true les plus faibles.

Chaque objectif produit un classement indépendant, par exemple par profit net ou par drawdown minimal.

Si vous ne déclarez aucun bloc [[objective]], les combinaisons sont classées par défaut par profit net décroissant :

[[objective]]
id        = "netprofit"
formula   = "netprofit"
ascending = false

L’exemple suivant déclare deux objectifs :

  • return_over_dd trie les combinaisons par score (grossprofit_percent - grossloss_percent) / max_drawdown_percent : le rendement est favorisé et le drawdown est pénalisé.
  • max_profit trie les combinaisons par profit net décroissant.
[[objective]]
id        = "return_over_dd"
formula   = "(grossprofit_percent - grossloss_percent) / max_drawdown_percent"
ascending = false

[[objective]]
id        = "max_profit"
formula   = "netprofit"
ascending = false

Graphe conditionnel et logique d’exécution

Principe général

Les blocs conditionnels forment un graphe orienté qui est parcouru bougie par bougie.
Le moteur suit la logique suivante :

  • Il commence par le bloc identifié dans [start], puis passe au bloc référencé par next_block_id.
  • A chaque bougie, un seul bloc conditionnel est considéré comme le bloc courant et est évalué.
  • Tant que ce bloc n’est pas validé, le moteur avance d’une bougie et réévalue le même bloc.
  • Dès que le bloc est validé, le moteur se déplace vers un autre bloc, déterminé par next_block_id, par then_block_id ou else_block_id pour un bloc [[if]], ou par la configuration des blocs [[and]] et [[or]].

Ce mouvement se répète jusqu’à la fin de la période de backtest ou jusqu’à ce qu’un arrêt explicite soit rencontré.

En pratique, le chemin principal de votre stratégie forme une boucle.
Après avoir ouvert, géré et éventuellement clôturé des positions, le graphe revient vers un bloc déjà visité, souvent [start] ou un bloc de « setup ».

Point d’entrée du graphe

Le bloc [start] identifie précisément le point d’entrée du graphe conditionnel.
Ce bloc est unique et minimal :

  • id est l’identifiant logique du point de départ.
  • next_block_id indique le premier bloc conditionnel effectivement évalué.
[start]
id            = "origin"
next_block_id = "first_condition"

Identifiants et liens entre blocs

Chaque bloc conditionnel possède un id unique.
Les liens entre blocs s’expriment de plusieurs manières :

  • La plupart des blocs utilisent next_block_id pour indiquer le bloc suivant lorsque le bloc courant est validé.

    Exemple :

    [[crossover]]
    id            = "golden_cross"
    reference     = "fast"
    comparison    = "slow"
    next_block_id = "enter_long"
  • Les blocs [[if]] utilisent then_block_id et else_block_id pour différencier la branche vraie de la branche fausse.

    Exemple :

    [[if]]
    id            = "trend_switch"
    condition     = "rsi_fast > rsi_slow"
    then_block_id = "enter_long"
    else_block_id = "enter_short"
  • Les blocs [[and]] regroupent une liste de blocs enfants qui doivent tous être vrais sur la même bougie.

    Exemple :

    [[and]]
    id            = "entry_filters"
    conditions    = ["ma_filter", "rsi_filter"]
    next_block_id = "entry"
  • Les blocs [[or]] regroupent plusieurs blocs enfants. Dès qu’au moins un enfant devient vrai, le bloc [[or]] est validé et suit son propre next_block_id.

    Exemple :

    [[or]]
    id            = "monitor_exits"
    conditions    = ["take_profit_hit", "stop_loss_hit", "death_cross"]
    next_block_id = "close_position"

Principales familles de blocs conditionnels

Les blocs conditionnels peuvent se regrouper en trois grandes familles.

Tests simples

Ces blocs évaluent une condition locale sur les séries ou la position :

  • [[condition]] évalue une expression booléenne libre sur les séries disponibles.
  • [[trend]] compare une expression numérique entre la bougie courante et la bougie précédente.
  • [[threshold]], [[position]] et [[trailing]] comparent des séries à des niveaux ou détectent des retournements relatifs.
  • [[crossover]], [[crossunder]] et [[cross]] détectent un croisement entre deux séries ou expressions.
  • [[wait]] introduit un délai explicite en attendant un certain nombre de bougies.
  • [[variable]] met à jour une variable à chaque passage dans le bloc à partir d’une formule.

Remarque : [[condition]] est le bloc générique. Les blocs [[trend]], [[threshold]] et [[position]] sont des blocs de commodité et peuvent souvent être réécrits avec un bloc [[condition]] qui exprime explicitement la comparaison voulue. En revanche, [[trailing]], [[crossover]], [[crossunder]] et [[cross]] encapsulent une logique d’état inter-bougies et ne sont pas toujours remplaçables par une simple condition statique.

Composition logique

Ces blocs organisent plusieurs conditions entre elles :

  • [[and]] regroupe plusieurs blocs enfants qui doivent être tous vrais sur la même bougie.
  • [[or]] teste plusieurs blocs enfants dans l’ordre et passe à son propre next_block_id dès qu’au moins un enfant est validé.
  • [[if]] choisit entre deux blocs cibles selon la vérité d’une condition.

Actions sur la position

Ces blocs ouvrent, gèrent ou ferment des positions, et peuvent aussi créer, mettre à jour ou annuler des ordres :

  • [[entry]] planifie une ouverture ou un renforcement de position.
  • [[order]] planifie un ordre brut d’achat ou de vente.
  • [[exit]] place, met à jour ou annule des ordres de sortie basés sur le prix (take profit, stop loss, trailing).
  • [[close]] ferme tout ou partie de la position.
  • [[close_all]] ferme intégralement la position (long et short).
  • [[cancel]] annule tous les ordres en attente associés à un order_id.
  • [[cancel_all]] annule tous les ordres en attente.

Délai et validation différée

Certains blocs n’enchaînent pas immédiatement vers le bloc suivant lorsqu’ils sont atteints.
Ils introduisent un délai : l’action est déclenchée sur une bougie donnée, puis le bloc reste actif pendant un certain nombre de bougies avant d’être considéré comme validé.

  • Le bloc [[wait]] attend un nombre fixe de bougies indiqué par wait_candles (1 par défaut) avant de se valider et de passer au bloc suivant.
    Pendant cette attente, aucun autre bloc n’est évalué : le moteur avance simplement d’une bougie à la fois dans la série de prix.

  • Les blocs [[entry]] et [[order]] proposent le champ wait_candles (valeur par defaut 1) :

    • Sur la bougie ou le moteur arrive sur le bloc, l’ordre est mis en file d’execution.
    • En backtest, ce delai laisse un cycle au simulateur pour ouvrir la position et mettre a jour position_avg_price.
    • De maniere generale, le bloc reste actif pendant wait_candles bougies apres la bougie de declenchement, puis il passe au bloc suivant.
    • Ce delai ne modifie pas l’execution des ordres, il ne fait que retarder la transition vers le bloc suivant.
    • Si vous definissez wait_candles = 0, il n’y a pas de delai : apres la mise en file de l’ordre, le bloc se valide sur la bougie courante et le bloc suivant est evalue immediatement.
  • Les blocs [[exit]], [[close]], [[close_all]], [[cancel]] et [[cancel_all]] gardent wait_candles à 0 par défaut pour une réactivité immédiate.

Exemple complet

Voici un exemple de stratégie crossover classique : elle ouvre une position quand la moyenne mobile « rapide » croise à la hausse la moyenne mobile « lente » (golden cross), puis la ferme lorsque le croisement inverse se produit (death cross). Le type et la longueur des deux moyennes mobiles ne sont pas fixés à l’avance : l’optimisation explore plusieurs types (sma, ema, wma, zlema) et des longueurs entre 2 et 100 périodes, sous la contrainte fast.length < slow.length and fast.type = slow.type.

# Exemple illustratif.
# Ne pas utiliser en trading réel.
# Domaine public (CC0 1.0).

# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------

[backtest]
symbol          = "KUCOIN:BTCUSDT"
timeframe       = "240"
start_date      = 2022-01-01
end_date        = 2026-01-01
initial_capital = 1000

[logging]
file_logging_enabled = true
include_trades       = true

[database]
backtest_export_enabled = true
objective_limit         = 100

# -----------------------------------------------------------------------------
# Indicateurs
# -----------------------------------------------------------------------------

# Ces deux blocs définissent deux moyennes mobiles (rapide/lente).
# Ici, l’exploration couvre plusieurs types (sma, ema, wma, zlema) et plusieurs longueurs.
# Pour chaque bloc :
# - id donne le nom de l’indicateur, qui servira dans le reste de la stratégie
# - type liste les types de moyenne mobile à tester
# - length.start et length.stop définissent la plage de longueurs à tester.
[[moving_average]]
id           = "fast"
type         = ["sma", "ema", "wma", "zlema"]
length.start = 2
length.stop  = 100

[[moving_average]]
id           = "slow"
type         = ["sma", "ema", "wma", "zlema"]
length.start = 2
length.stop  = 100

# Indique quelles combinaisons d’hyperparamètres sont valides.
# Ici, on impose que la moyenne "fast" soit plus courte que la moyenne "slow"
# et que les deux blocs utilisent le même type de moyenne mobile.
[constraints]
condition = "fast.length < slow.length and fast.type = slow.type"

# -----------------------------------------------------------------------------
# Constantes optimisées
# -----------------------------------------------------------------------------

[[constant]]
id    = "take_profit_pct"
start = 1
stop  = 10

[[constant]]
id    = "stop_loss_pct"
start = 1
stop  = 10

# -----------------------------------------------------------------------------
# Filtres
# -----------------------------------------------------------------------------

[filters]
min_required_positions = 30    # Le backtest doit ouvrir au moins 30 positions.
min_roi_pct            = 20.0  # Le ROI minimal est de 20 %.
max_allowed_drawdown   = 35.0  # Le drawdown ne doit pas dépasser 50%.

# -----------------------------------------------------------------------------
# Objectifs
# -----------------------------------------------------------------------------

[[objective]]
id        = "return_over_dd"
formula   = "(grossprofit_percent - grossloss_percent) / max_drawdown_percent"
ascending = false

[[objective]]
id        = "max_profit"
formula   = "netprofit"
ascending = false

# -----------------------------------------------------------------------------
# Logique d’exécution : blocs conditionnels
# -----------------------------------------------------------------------------

# Le bloc [start] est le point d’entrée de la stratégie.
# C’est un bloc "auto-validé" : il est considéré comme toujours vrai
# et sert simplement à rediriger vers un premier bloc conditionnel.
[start]
id            = "origin"
next_block_id = "golden_cross"  # Après origin, on passe au bloc "golden_cross"

# Attend que la moyenne "fast" croise à la hausse la moyenne "slow". Tant que
# le croisement n’a pas eu lieu, l’exécution passe simplement à la bougie
# suivante en restant sur ce bloc. Dès que le croisement est détecté, la
# stratégie passe au bloc "enter_long".
[[crossover]]
id            = "golden_cross"
reference     = "fast"
comparison    = "slow"
next_block_id = "enter_long"

# Ouvre une position long. Une fois la position ouverte, la stratégie surveille
# plusieurs conditions de sortie via le bloc "monitor_exits".
[[entry]]
id            = "enter_long"
order_id      = "main"
direction     = "long"
next_block_id = "monitor_exits"

# Surveille les conditions de sortie. A chaque bougie, ce bloc teste la prise de
# profit, puis le stop loss, puis le croisement inverse (death cross). Dès
# qu'un de ces blocs est validé, la stratégie passe au bloc "close_position".
[[or]]
id            = "monitor_exits"
conditions    = ["take_profit_hit", "stop_loss_hit", "death_cross"]
next_block_id = "close_position"

# Prise de profit : se valide si le prix de clôture atteint un multiple du prix
# moyen d’entrée basé sur take_profit_pct.
[[condition]]
id        = "take_profit_hit"
condition = "close >= position_avg_price * (1 + take_profit_pct / 100)"

# Stop loss : se valide si le prix de clôture atteint un seuil sous le prix
# moyen d’entrée basé sur stop_loss_pct.
[[condition]]
id        = "stop_loss_hit"
condition = "close <= position_avg_price * (1 - stop_loss_pct / 100)"

# Croisement inverse (death cross) : si la moyenne "fast" repasse sous "slow",
# la stratégie ferme la position.
[[crossunder]]
id         = "death_cross"
reference  = "fast"
comparison = "slow"

# Ferme la position ouverte, puis renvoie l’exécution vers le bloc "golden_cross".
# La stratégie retourne alors à l’état initial et attend un nouveau signal.
[[close]]
id            = "close_position"
order_id      = "main"
next_block_id = "golden_cross"