Analyser le backtest d'un portefeuille de trading en Python grâce à Pandas

Nous avons franchi une étape cruciale en maîtrisant le backtesting de nos stratégies de trading.

Cependant, la création d'une stratégie n'est que la première étape. L'analyse approfondie des résultats est là où se joue la distinction entre une bonne et une excellente stratégie.

Aujourd'hui, nous abordons cette phase critique avec une précision et une rigueur scientifiques. Nous allons explorer les outils analytiques et les techniques d'évaluation qui transforment des données brutes en informations précises, permettant une prise de décision réfléchie et éclairée.

Chaque détail, chaque donnée, chaque signal sera scruté afin d'affiner et de perfectionner nos interventions sur le marché. Cette étape est décisive, elle marque le passage de la théorie à une pratique affinée, où chaque décision est soutenue par une analyse méthodique et précise. Bienvenue dans cette leçon essentielle, où la qualité de notre analyse définira la robustesse et l'efficacité de nos stratégies de trading.

Préparer l’analyse de notre stratégie de trading

Pour commencer, reprenons le code de notre séance précédente.

class BacktestCrossMA:
    def __init__(self) -> None:
        self.df = pd.DataFrame()

    def load_data(self, path):
        self.df = pd.read_csv(path)
        self.df["date"] = pd.to_datetime(self.df["date"], unit="ms")
        self.df = self.df.set_index(self.df["date"])
        del self.df["date"]

    def populate_indicators(self):
        self.df['ma50'] = self.df['close'].rolling(50).mean()
        self.df['ma200'] = self.df['close'].rolling(200).mean()

    def populate_signals(self):
        self.df['buy_signal'] = False
        self.df['sell_signal'] = False
        self.df.loc[(self.df['ma50'] > self.df['ma200']), 'buy_signal'] = True
        self.df.loc[(self.df['ma50'] < self.df['ma200']), 'sell_signal'] = True

    def run_backtest(self):
        balance = 1000
        position = None
        asset = "BTC"

        for index, row in self.df.iterrows():

            if position is None and row['buy_signal']:
                open_price = row['close']
                position = {
                    'open_price': open_price,
                    'open_size': balance,
                }
                print(f"{index} - Buy for {balance}$ of {asset} at {open_price}$")

            elif position and row['sell_signal']:
                close_price = row['close']
                trade_result = (close_price - position['open_price']) / position['open_price']
                balance = balance + trade_result * position['open_size']
                position = None
                print(f"{index} - Sell for {balance}$ of {asset} at {close_price}$")      
            
        
        print(f"Final balance: {balance}$")

Prendre en compte les frais de trading

Il est essentiel de reconnaître qu'aucune stratégie de trading n'est complète sans prendre en compte les frais associés aux transactions. Nous avons élaboré un modèle solide avec notre classe BacktestCrossMA, mais aujourd'hui, nous allons l'affiner en intégrant les frais de transaction. Ce détail apparemment mineur peut avoir un impact considérable, et sa prise en compte rendra nos résultats de backtesting plus précis et nos stratégies plus robustes dans un environnement de trading réel.

Pour ce faire, nous allons commencer par initialiser une variable fees à 0.001 pour signifier qu’à chaque transaction, nous allons payer 0.1% de frais. Cela est une estimation qui peut grandement varier en fonction des brokers avec lesquels vous interagirez.

    def run_backtest(self):
        balance = 1000
        position = None
        asset = "BTC"
        fees = 0.001

        for index, row in self.df.iterrows():

            if position is None and row['buy_signal']:
                open_price = row['close']
                open_size = balance
                fee = open_size * fees
                open_size = open_size - fee
                balance = balance - fee
                position = {
                    'open_price': open_price,
                    'open_size': open_size,
                }
                print(f"{index} - Buy for {balance}$ of {asset} at {open_price}$")

            elif position and row['sell_signal']:
                close_price = row['close']
                trade_result = (close_price - position['open_price']) / position['open_price']
                close_size = (position["open_size"] + position["open_size"] * trade_result)
                fee = close_size * fees
                close_size = close_size - fee
                balance = balance + close_size - position["open_size"]
                position = None
                print(f"{index} - Sell for {balance}$ of {asset} at {close_price}$")

Pour prendre en compte les frais, au moment de l’achat et de la vente, nous avons défini une variable fee représentant les frais associé à la transaction. Les frais payés sont égaux au montant de la transaction fois notre variable fees. Nous avons également rajouté les variables open_size et close_size afin de pouvoir y appliquer nos frais puis, qu’ils se répercutent sur notre balance finale.

Si nous exécutions le code maintenant, nous remarquons que notre balance finale est maintenant de 10 458 dollars au lieu des 12 756 dollars initiaux, ce qui reste très bon, mais qui n’est pas à négliger.

Suivre notre portefeuille chaque jour

Afin de faire un suivi complet de notre stratégie, nous allons chaque jour enregistrer notre balance dans une liste. Au début de notre fonction **run_backtest()**, nous allons donc initialiser notre liste days et par la même occasion, initialiser une liste trades qui nous permettra d’enregistrer ensuite chacun de nos trades.

self.trades = []
self.days = []

Nous utilisons self afin que ces listes soient accessibles partout dans notre classe.

Pour rappel, notre DataFrame df représente le cours OHLCV du Bitcoin heure par heure. Cependant, nous souhaitons récupérer l’état de notre portefeuille une fois par jour.

Pour cela, nous pouvons à chaque heure regarder le jour associé et le comparer au jour précédant. Si le jour actuel est différent du jour précédent, alors c’est un nouveau jour et nous pouvons donc enregistrer une donnée dans notre liste self.days. Voici le code pour faire ceci :

	def run_backtest(self):
        balance = 1000
        position = None
        fees = 0.0007
        asset = "BTC"
        self.trades = []
        self.days = []
        previous_day = -1

        for index, row in self.df.iterrows():
            current_day = index.day
            if previous_day != current_day:
                temp_balance = balance
                if position:
                    close_price = row["close"]
                    trade_result = (close_price - position["open_price"]) / position[
                        "open_price"
                    ]
                    close_size = (
                        position["open_size"] + position["open_size"] * trade_result
                    )
                    fee = close_size * fees
                    close_size = close_size - fee
                    temp_balance = temp_balance + close_size - position["open_size"]
                self.days.append({
                    "day":index.date(),
                    "balance":temp_balance,
                    "price":row['close']
                })
            previous_day = current_day

Nous initialisons la variable previous_day à -1, lors de notre itération, nous vérifions le jour actuel grâce à index.day (pour rappel, nous avions mis les dates en index de notre DataFrame).

Si nous avons une position en cours, nous simulons le fait que nous la fermions pour avoir son impact sur notre balance. Enfin, nous stockons la date, notre balance temporaire et le prix de l’actif dans un dictionnaire que nous ajoutons à notre liste self.days.

Stocker chacun des trades de la stratégie

Afin de pouvoir réaliser une analyse complète par la suite, nous devons stocker dans notre liste self.trades l’ensemble de nos trades avec les informations nécessaires. Pour commencer, nous allons ajouter lors de l’entrée en position certaines informations :

            if position is None and row["buy_signal"]:
                open_price = row["close"]
                open_size = balance
                fee = open_size * fees
                open_size = open_size - fee
                balance = balance - fee
                position = {
                    "open_price": open_price,
                    "open_size": balance,
                    "open_date": index,
                    "open_fee": fee,
                    "open_reason": "Market Buy",
                    "open_balance": balance,
                }

Par rapport au code initial, nous ajoutons la date d’ouverture de la position, les frais associés, la raison de l’ouverture et la balance à l’ouverture de la position.

Maintenant, lors de la fermeture de la position, comme lorsque nous ajoutions les informations liées à la journée en cours, créons un dictionnaire avec les différentes informations liées à notre trade:

            elif position and row["sell_signal"]:
                close_price = row["close"]
                trade_result = (close_price - position["open_price"]) / position[
                    "open_price"
                ]
                close_size = (
                    position["open_size"] + position["open_size"] * trade_result
                )
                fee = close_size * fees
                close_size = close_size - fee
                balance = balance + close_size - position["open_size"]
                self.trades.append(
                    {
                        "open_date": position["open_date"],
                        "close_date": index,
                        "open_price": position["open_price"],
                        "close_price": close_price,
                        "open_size": position["open_size"],
                        "close_size": close_size,
                        "open_fee": position["open_fee"],
                        "close_fee": fee,
                        "open_reason": position["open_reason"],
                        "close_reason": "Market Sell",
                        "open_balance": position["open_balance"],
                        "close_balance": balance,
                    }
                )
                position = None

Grâce à ce code, lorsque nous clôturons une position, nous ajoutons à notre liste self.trades:

  • open_date
  • close_date
  • open_price
  • close_price
  • open_size
  • close_size
  • open_fee
  • close_fee
  • open_reason
  • close_reason
  • open_balance
  • close_balance

Vous retrouverez ci-dessous, le code de la fonction run_backtest() avec toutes les nouvelles modifications :

    def run_backtest(self):
        balance = 1000
        position = None
        fees = 0.0007
        self.trades = []
        self.days = []
        previous_day = -1

        for index, row in self.df.iterrows():
            current_day = index.day
            if previous_day != current_day:
                temp_balance = balance
                if position:
                    close_price = row["close"]
                    trade_result = (close_price - position["open_price"]) / position[
                        "open_price"
                    ]
                    close_size = (
                        position["open_size"] + position["open_size"] * trade_result
                    )
                    fee = close_size * fees
                    close_size = close_size - fee
                    temp_balance = temp_balance + close_size - position["open_size"]
                self.days.append({
                    "day":index.date(),
                    "balance":temp_balance,
                    "price":row['close']
                })
            previous_day = current_day
            
            if position is None and row["buy_signal"]:
                open_price = row["close"]
                open_size = balance
                fee = open_size * fees
                open_size = open_size - fee
                balance = balance - fee
                position = {
                    "open_price": open_price,
                    "open_size": balance,
                    "open_date": index,
                    "open_fee": fee,
                    "open_reason": "Market Buy",
                    "open_balance": balance,
                }
                # print(f"{index} - Buy for {balance}$ of {asset} at {open_price}$")

            elif position and row["sell_signal"]:
                close_price = row["close"]
                trade_result = (close_price - position["open_price"]) / position[
                    "open_price"
                ]
                close_size = (
                    position["open_size"] + position["open_size"] * trade_result
                )
                fee = close_size * fees
                close_size = close_size - fee
                balance = balance + close_size - position["open_size"]
                self.trades.append(
                    {
                        "open_date": position["open_date"],
                        "close_date": index,
                        "open_price": position["open_price"],
                        "close_price": close_price,
                        "open_size": position["open_size"],
                        "close_size": close_size,
                        "open_fee": position["open_fee"],
                        "close_fee": fee,
                        "open_reason": position["open_reason"],
                        "close_reason": "Market Sell",
                        "open_balance": position["open_balance"],
                        "close_balance": balance,
                    }
                )
                position = None
                # print(f"{index} - Sell for {balance}$ of {asset} at {close_price}$")

Analyser les résultats de notre stratégie grâce à Pandas

Avec les données de nos journées et transactions de trading soigneusement enregistrées dans self.days et self.trades, nous sommes maintenant prêts à avancer vers une étape cruciale de notre processus d'évaluation: l'analyse des métriques de performance.

Cette phase est fondamentale pour décrypter et évaluer la validité et l'efficacité de notre stratégie de trading dans un contexte opérationnel.

Dans le monde complexe et dynamique du trading, une stratégie ne peut être considérée comme viable que si elle a été rigoureusement testée et validée à l'aide de critères quantitatifs précis.

Ces métriques, allant des rendements ajustés au risque jusqu'aux drawdowns maximaux, fournissent des insights précieux sur la robustesse, la rentabilité et la résilience de notre approche.

Chaque transaction et variation quotidienne sera scrutée pour assurer que notre stratégie non seulement génère des rendements positifs, mais le fait aussi de manière consistante et fiable, tout en minimisant le risque.

Cela garantira que la stratégie est bien équipée pour naviguer dans les diverses conditions de marché, en assurant une performance optimale.

Pour ce faire, nous allons créer une nouvelle fonction à l’intérieur de notre classe intitulée backtest_analysis().

Initialisation et Vérification des Données

df_trades = pd.DataFrame(self.trades)
df_days = pd.DataFrame(self.days)

if df_trades.empty:
    raise Exception("No trades found")
if df_days.empty:
    raise Exception("No days found")

Dans cette première section de la fonction backtest_analysis, nous initialisons deux DataFrames, df_trades et df_days, à partir des données de trades et de jours stockées lors du backtest. Nous effectuons également une vérification essentielle pour nous assurer que ces DataFrames ne sont pas vides. Si l'une d'elles est vide, une exception est levée, indiquant l'absence de trades ou de jours, ce qui est crucial pour éviter des erreurs d'exécution ultérieures dans l'analyse.

Calcul des Retours et Résultats des Trades

df_days['evolution'] = df_days['balance'].diff()
df_days['daily_return'] = df_days['evolution']/df_days['balance'].shift(1)

df_trades["trade_result"] = df_trades["close_size"] - df_trades["open_size"]
df_trades["trade_result_pct"] = (df_trades["trade_result"] / df_trades["open_size"])
df_trades["trades_duration"] = df_trades["close_date"] - df_trades["open_date"]

Nous passons ensuite au calcul des retours quotidiens et des résultats des trades. Nous déterminons l'évolution du solde jour après jour et calculons le retour quotidien.

Pour les trades, nous calculons le résultat de chaque trade ainsi que le pourcentage de ce résultat par rapport à la taille d'ouverture du trade. Nous calculons par ailleurs la durée de chaque trade pour avoir une vue détaillée de la rapidité avec laquelle les trades ont été conclus.

Analyse des Drawdowns et Performance

df_days["balance_ath"] = df_days["balance"].cummax()
df_days["drawdown"] = df_days["balance_ath"] - df_days["balance"]
df_days["drawdown_pct"] = df_days["drawdown"] / df_days["balance_ath"]

L'analyse des drawdowns est un aspect essentiel pour évaluer le risque associé à notre stratégie de trading. Nous calculons la balance maximale atteinte à ce jour (All Time High - ATH) et utilisons cette information pour déterminer le drawdown en valeur et en pourcentage. Ces métriques fournissent un aperçu précieux de la perte maximale subie pendant la période de backtest.

Détails des Trades

total_trades = len(df_trades)
total_days = len(df_days)

good_trades = df_trades.loc[df_trades["trade_result"] > 0]
total_good_trades = len(good_trades)
avg_profit_good_trades = good_trades["trade_result_pct"].mean()
mean_good_trades_duration = good_trades["trades_duration"].mean()
global_win_rate = total_good_trades / total_trades

bad_trades = df_trades.loc[df_trades["trade_result"] < 0]
total_bad_trades = len(bad_trades)
avg_profit_bad_trades = bad_trades["trade_result_pct"].mean()
mean_bad_trades_duration = bad_trades["trades_duration"].mean()

max_days_drawdown = df_days["drawdown_pct"].max()
initial_balance = df_days.iloc[0]["balance"]
final_balance = df_days.iloc[-1]["balance"]
balance_evolution = (final_balance - initial_balance) / initial_balance
mean_trades_duration = df_trades["trades_duration"].mean()
avg_profit = df_trades["trade_result_pct"].mean()
mean_trades_duration = df_trades['trades_duration'].mean()
mean_trades_per_days = total_trades/total_days

best_trade = df_trades.loc[df_trades["trade_result_pct"].idxmax()]
worst_trade = df_trades.loc[df_trades["trade_result_pct"].idxmin()]

Dans cette section, une analyse approfondie des trades est effectuée. Nous calculons le nombre total de trades gagnants et perdants, le profit moyen pour chaque catégorie et la durée moyenne des trades. Ces informations sont cruciales pour comprendre la qualité de la stratégie et identifier les domaines potentiels d'amélioration.

Synthèse et Affichage

print(f"Period: [{df_days.iloc[0]['day']}] -> [{df_days.iloc[-1]['day']}]")
print(f"Initial balance: {round(initial_balance,2)} $")

print("\n--- General Information ---")
print(f"Final balance: {round(final_balance,2)} $")
print(f"Performance: {round(balance_evolution*100,2)} %")
print(f"Worst Drawdown: -{round(max_days_drawdown*100, 2)}%")
print(f"Total trades on the period: {total_trades}")
print(f"Average Profit: {round(avg_profit*100, 2)} %")
print(f"Global Win rate: {round(global_win_rate*100, 2)} %")

print("\n--- Trades Information ---")
print(f"Mean Trades per day: {round(mean_trades_per_days, 2)}")
print(f"Mean Trades Duration: {mean_trades_duration}")
print(f"Best trades: +{round(best_trade['trade_result_pct']*100, 2)} % the {best_trade['open_date']} -> {best_trade['close_date']}")
print(f"Worst trades: {round(worst_trade['trade_result_pct']*100, 2)} % the {worst_trade['open_date']} -> {worst_trade['close_date']}")
print(f"Total Good trades on the period: {total_good_trades}")
print(f"Total Bad trades on the period: {total_bad_trades}")
print(f"Average Good Trades result: {round(avg_profit_good_trades*100, 2)} %")
print(f"Average Bad Trades result: {round(avg_profit_bad_trades*100, 2)} %")
print(f"Mean Good Trades Duration: {mean_good_trades_duration}")
print(f"Mean Bad Trades Duration: {mean_bad_trades_duration}")

print("\n--- Trades reasons ---")
print(df_trades["open_reason"].value_counts().to_string())
print(df_trades["close_reason"].value_counts().to_string())

La fonction affiche enfin des informations détaillées sur la performance globale de la stratégie, y compris la balance finale, la performance globale, le pire drawdown et d'autres métriques clés. Cette sortie structurée offre un résumé complet des résultats du backtest, permettant une évaluation rapide et approfondie de la stratégie de trading.

Petit plus : Le Sharpe Ratio

Le ratio de Sharpe mesure la performance ajustée au risque d'un investissement ou d'une stratégie de trading, en comparant le rendement par rapport à sa volatilité. Un ratio de Sharpe plus élevé indique une performance ajustée au risque plus favorable, offrant des rendements plus élevés pour un niveau de risque donné. Ce ratio est un des plus pertinents pour comparer deux stratégies entre elles.

backtest-result Voici comment le calculer grâce aux informations journalières de notre stratégie

sharpe_ratio = (365**(0.5) * df_days['daily_return'].mean())/df_days['daily_return'].std()
print(f"Sharpe Ratio: {round(sharpe_ratio,2)}")

Nous annualisons notre ratio de Sharpe en multipliant notre rendement journalier par la racine carré de 365 et nous divisons le tout par l’écart type.

Bonus : Ajouter un Stop Loss au backtest

Un stop loss est un ordre de vente automatique utilisé par les traders pour limiter leurs pertes en cas de mouvement défavorable du marché. Il est déclenché lorsque le prix d'un actif atteint un niveau prédéterminé, forçant ainsi la vente de l'actif pour éviter des pertes supplémentaires. En d'autres termes, c'est un mécanisme de protection qui permet de gérer et de minimiser les risques associés à la volatilité du marché.

Pour ajouter un stop loss à notre stratégie, il faudra lors de l’achat définir son seuil. Pour l’exemple mettons notre stop loss à -10 pourcent.

stop_loss = open_price - (open_price * 0.1)
position = {
    "open_price": open_price,
    "open_size": balance,
    "open_date": index,
    "open_fee": fee,
    "open_reason": "Market Buy",
    "open_balance": balance,
    "stop_loss": stop_loss,
}

Dans ce cas, nous ajoutons à notre dictionnaire position, le prix auquel nous souhaitons couper la position si l’on passe en dessous.

Ensuite, nous allons ajouter une nouvelle condition lors de notre itération:

elif position and row["low"] < position["stop_loss"]:
    close_price = position["stop_loss"]
    trade_result = (close_price - position["open_price"]) / position"open_price"]
    close_size = (position["open_size"] + position["open_size"] * trade_result)
    fee = close_size * fees
    close_size = close_size - fee
    balance = balance + close_size - position["open_size"]
    self.trades.append(
        {
            "open_date": position["open_date"],
            "close_date": index,
            "open_price": position["open_price"],
            "close_price": close_price,
            "open_size": position["open_size"],
            "close_size": close_size,
            "open_fee": position["open_fee"],
            "close_fee": fee,
            "open_reason": position["open_reason"],
            "close_reason": "Stop Loss",
            "open_balance": position["open_balance"],
            "close_balance": balance,
        }
    )
    position = None

Nous entrons dans cette condition si le point le plus bas d’une bougie (le low) passe en dessous de la valeur de notre stop loss. Dans ce cas nous vendons nos Bitcoin au prix du stop loss sur le même principe que la vente “classique”.

Les graphiques pour évaluer notre stratégie

Il existe des centaines de graphique à raliser à partir des informations récoltées sur la stratégie. Nous allons ici nous concentrer sur les informations les plus importantes qui sont le P&L (Pofit and Lose) qui représente simplement l’évolution de la balance de notre stratégie et le Drawdown (Perte par rapport au dernier plus haut).

def plot_equity_vs_asset(self):
	  df_days = self.df_days.copy()
		df_days = df_days.set_index('day')
	  fig, ax_left = plt.subplots(figsize=(15, 20), nrows=4, ncols=1)
	
	  ax_left[0].title.set_text("Profit and Lose curve")
	  ax_left[0].plot(df_days['balance'], color='royalblue', lw=1)
	  ax_left[0].fill_between(df_days['balance'].index, df_days['balance'], alpha=0.2, color='royalblue')
	  ax_left[0].axhline(y=df_days.iloc[0]['balance'], color='black', alpha=0.3)
	  ax_left[0].legend(['Wallet evolution (equity)'], loc ="upper left")
	
	  ax_left[1].title.set_text("Asset evolution")
	  ax_left[1].plot(df_days['price'], color='sandybrown', lw=1)
	  ax_left[1].fill_between(df_days['price'].index, df_days['price'], alpha=0.2, color='sandybrown')
	  ax_left[1].axhline(y=df_days.iloc[0]['price'], color='black', alpha=0.3)
	  ax_left[1].legend(['Asset evolution'], loc ="upper left")
	
	  ax_left[2].title.set_text("Drawdown curve")
	  ax_left[2].plot(-df_days['drawdown_pct']*100, color='indianred', lw=1)
	  ax_left[2].fill_between(df_days['drawdown_pct'].index, -df_days['drawdown_pct']*100, alpha=0.2, color='indianred')
	  ax_left[2].axhline(y=0, color='black', alpha=0.3)
	  ax_left[2].legend(['Drawdown in %'], loc ="lower left")
	
	  ax_right = ax_left[3].twinx()
	
	  ax_left[3].title.set_text("P&L VS Asset (not on the same scale)")
	  ax_left[3].plot(df_days['balance'], color='royalblue', lw=1)
	  ax_right.plot(df_days['price'], color='sandybrown', lw=1)
	  ax_left[3].legend(['Wallet evolution (equity)'], loc ="lower right")
	  ax_right.legend(['Asset evolution'], loc ="upper left")
	
	  plt.show()

Nous ne retrerons pas en détail sur ce code dans cette séance mais voici les quelque clés pour le comprendre :

  • Nous commençons par définir notre graphique comme étant découper en 4 sous graphiques grâce à la ligne fig, ax_left = plt.subplots(figsize=(15, 20), nrows=4, ncols=1)
  • Nous affichone ensuite le premier graphique avec ax_left[0] puis le deuxième avec ax_left[1] etc…
  • Pour le dernier graphique nous créons un deuxième axe à droite qui est une copie de l’axe gauche du dernier sous graphique grâce à ax_right = ax_left[3].twinx() , cet axe permettra d’afficher sur le même graphique, le cours de notre actif (le Bitcoin ici) et l’évolution de notre balance. Cela permet notamment de voir si les 2 courbes ont des variations en même temps et donc si elles sont corrélées.

equity-chart

Notre stratégie avec Stop Loss qui a commencé à 1000 dollars fin 2017 finit à près de 8000 dollars en 2023. On remarque que la stratégie au plus haut est montée à une balance de 12 000 dollars début 2021. Depuis nous ne sommes jamais remonté à ce niveau et nous avons eu un drawdown maximum de plus de -50%.

On trouve également que l’on a une très forte corrélation entre la performance de notre stratégie et la performance du Bitcoin ce qui est au final plutôt logique car en ne faisant que acheter (parier à la hausse) nous ne pouvons gagner de l’argent que si le Bitcoin monte.

Nous avons abordé et approfondi des éléments clés pour la conception, l'exécution et l'analyse de stratégies de trading efficaces. Avec la capacité de créer des stratégies sophistiquées, l'intégration de stop loss pour gérer et minimiser les risques, et des outils d'analyse approfondie, nous sommes désormais équipés pour faire des évaluations précises.

Que ce soit par le biais de données textuelles ou de visualisations graphiques, nous pouvons disséquer chaque élément de notre stratégie, nous permettant d'affiner et d'optimiser chaque aspect pour maximiser la performance. Ainsi, nous sommes non seulement préparés pour comprendre les mécanismes sous-jacents de nos opérations, mais aussi pour choisir et appliquer la meilleure stratégie de trading possible dans des conditions de marché variées.

Code complet de la classe et comment l’utiliser :

import pandas as pd
import matplotlib.pyplot as plt

class BacktestCrossMA:
    def __init__(self) -> None:
        self.df = pd.DataFrame()
        self.trades = []
        self.days = []
        self.df_days = pd.DataFrame()
        self.df_trades = pd.DataFrame()

    def load_data(self, path):
        self.df = pd.read_csv(path)
        self.df["date"] = pd.to_datetime(self.df["date"], unit="ms")
        self.df = self.df.set_index(self.df["date"])
        del self.df["date"]

    def populate_indicators(self):
        self.df["ma100"] = self.df["close"].rolling(100).mean()
        self.df["ma200"] = self.df["close"].rolling(200).mean()

    def populate_signals(self):
        self.df["buy_signal"] = False
        self.df["sell_signal"] = False
        self.df.loc[(self.df["ma100"] > self.df["ma200"]), "buy_signal"] = True
        self.df.loc[(self.df["ma100"] < self.df["ma200"]), "sell_signal"] = True

    def run_backtest(self):
        balance = 1000
        position = None
        fees = 0.0007
        self.trades = []
        self.days = []
        previous_day = -1

        for index, row in self.df.iterrows():
            current_day = index.day
            if previous_day != current_day:
                temp_balance = balance
                if position:
                    close_price = row["close"]
                    trade_result = (close_price - position["open_price"]) / position[
                        "open_price"
                    ]
                    close_size = (
                        position["open_size"] + position["open_size"] * trade_result
                    )
                    fee = close_size * fees
                    close_size = close_size - fee
                    temp_balance = temp_balance + close_size - position["open_size"]
                self.days.append({
                    "day":index.date(),
                    "balance":temp_balance,
                    "price":row['close']
                })
            previous_day = current_day
            
            if position is None and row["buy_signal"]:
                open_price = row["close"]
                open_size = balance
                fee = open_size * fees
                open_size = open_size - fee
                balance = balance - fee
                stop_loss = open_price - (open_price * 0.1)
                position = {
                    "open_price": open_price,
                    "open_size": balance,
                    "open_date": index,
                    "open_fee": fee,
                    "open_reason": "Market Buy",
                    "open_balance": balance,
                    "stop_loss": stop_loss,
                }
                # print(f"{index} - Buy for {balance}$ of {asset} at {open_price}$")

            elif position and row["low"] < position["stop_loss"]:
                close_price = position["stop_loss"]
                trade_result = (close_price - position["open_price"]) / position[
                    "open_price"
                ]
                close_size = (
                    position["open_size"] + position["open_size"] * trade_result
                )
                fee = close_size * fees
                close_size = close_size - fee
                balance = balance + close_size - position["open_size"]
                self.trades.append(
                    {
                        "open_date": position["open_date"],
                        "close_date": index,
                        "open_price": position["open_price"],
                        "close_price": close_price,
                        "open_size": position["open_size"],
                        "close_size": close_size,
                        "open_fee": position["open_fee"],
                        "close_fee": fee,
                        "open_reason": position["open_reason"],
                        "close_reason": "Stop Loss",
                        "open_balance": position["open_balance"],
                        "close_balance": balance,
                    }
                )
                position = None
                # print(f"{index} - Sell for {balance}$ of {asset} at {close_price}$")

            elif position and row["sell_signal"]:
                close_price = row["close"]
                trade_result = (close_price - position["open_price"]) / position[
                    "open_price"
                ]
                close_size = (
                    position["open_size"] + position["open_size"] * trade_result
                )
                fee = close_size * fees
                close_size = close_size - fee
                balance = balance + close_size - position["open_size"]
                self.trades.append(
                    {
                        "open_date": position["open_date"],
                        "close_date": index,
                        "open_price": position["open_price"],
                        "close_price": close_price,
                        "open_size": position["open_size"],
                        "close_size": close_size,
                        "open_fee": position["open_fee"],
                        "close_fee": fee,
                        "open_reason": position["open_reason"],
                        "close_reason": "Market Sell",
                        "open_balance": position["open_balance"],
                        "close_balance": balance,
                    }
                )
                position = None
                # print(f"{index} - Sell for {balance}$ of {asset} at {close_price}$")

    def backtest_analysis(self):
        df_trades = pd.DataFrame(self.trades)
        df_days = pd.DataFrame(self.days)

        if df_trades.empty:
            raise Exception("No trades found")
        if df_days.empty:
            raise Exception("No days found")

        df_days['evolution'] = df_days['balance'].diff()
        df_days['daily_return'] = df_days['evolution']/df_days['balance'].shift(1)

        df_trades["trade_result"] = df_trades["close_size"] - df_trades["open_size"]
        df_trades["trade_result_pct"] = (
            df_trades["trade_result"] / df_trades["open_size"]
        )
        df_trades["trades_duration"] = df_trades["close_date"] - df_trades["open_date"]

        df_days["balance_ath"] = df_days["balance"].cummax()
        df_days["drawdown"] = df_days["balance_ath"] - df_days["balance"]
        df_days["drawdown_pct"] = df_days["drawdown"] / df_days["balance_ath"]

        total_trades = len(df_trades)
        total_days = len(df_days)

        good_trades = df_trades.loc[df_trades["trade_result"] > 0]
        total_good_trades = len(good_trades)
        avg_profit_good_trades = good_trades["trade_result_pct"].mean()
        mean_good_trades_duration = good_trades["trades_duration"].mean()
        global_win_rate = total_good_trades / total_trades

        bad_trades = df_trades.loc[df_trades["trade_result"] < 0]
        total_bad_trades = len(bad_trades)
        avg_profit_bad_trades = bad_trades["trade_result_pct"].mean()
        mean_bad_trades_duration = bad_trades["trades_duration"].mean()

        max_days_drawdown = df_days["drawdown_pct"].max()
        initial_balance = df_days.iloc[0]["balance"]
        final_balance = df_days.iloc[-1]["balance"]
        balance_evolution = (final_balance - initial_balance) / initial_balance
        mean_trades_duration = df_trades["trades_duration"].mean()
        avg_profit = df_trades["trade_result_pct"].mean()
        mean_trades_duration = df_trades['trades_duration'].mean()
        mean_trades_per_days = total_trades/total_days

        best_trade = df_trades.loc[df_trades["trade_result_pct"].idxmax()]
        worst_trade = df_trades.loc[df_trades["trade_result_pct"].idxmin()]

        sharpe_ratio = (365**(0.5) * df_days['daily_return'].mean())/df_days['daily_return'].std()

        print(f"Period: [{df_days.iloc[0]['day']}] -> [{df_days.iloc[-1]['day']}]")
        print(f"Initial balance: {round(initial_balance,2)} $")

        print("\n--- General Information ---")
        print(f"Final balance: {round(final_balance,2)} $")
        print(f"Sharpe Ratio: {round(sharpe_ratio,2)}")
        print(f"Performance: {round(balance_evolution*100,2)} %")
        print(f"Worst Drawdown: -{round(max_days_drawdown*100, 2)}%")
        print(f"Total trades on the period: {total_trades}")
        print(f"Average Profit: {round(avg_profit*100, 2)} %")
        print(f"Global Win rate: {round(global_win_rate*100, 2)} %")

        print("\n--- Trades Information ---")
        print(f"Mean Trades per day: {round(mean_trades_per_days, 2)}")
        print(f"Mean Trades Duration: {mean_trades_duration}")
        print(f"Best trades: +{round(best_trade['trade_result_pct']*100, 2)} % the {best_trade['open_date']} -> {best_trade['close_date']}")
        print(f"Worst trades: {round(worst_trade['trade_result_pct']*100, 2)} % the {worst_trade['open_date']} -> {worst_trade['close_date']}")
        print(f"Total Good trades on the period: {total_good_trades}")
        print(f"Total Bad trades on the period: {total_bad_trades}")
        print(f"Average Good Trades result: {round(avg_profit_good_trades*100, 2)} %")
        print(f"Average Bad Trades result: {round(avg_profit_bad_trades*100, 2)} %")
        print(f"Mean Good Trades Duration: {mean_good_trades_duration}")
        print(f"Mean Bad Trades Duration: {mean_bad_trades_duration}")

        print("\n--- Trades reasons ---")
        print(df_trades["open_reason"].value_counts().to_string())
        print(df_trades["close_reason"].value_counts().to_string())

        self.df_days = df_days
        self.df_trades = df_trades

    def plot_equity_vs_asset(self):
        df_days = self.df_days.copy()
        df_days = df_days.set_index('day')
        fig, ax_left = plt.subplots(figsize=(15, 20), nrows=4, ncols=1)

        ax_left[0].title.set_text("Profit and Lose curve")
        ax_left[0].plot(df_days['balance'], color='royalblue', lw=1)
        ax_left[0].fill_between(df_days['balance'].index, df_days['balance'], alpha=0.2, color='royalblue')
        ax_left[0].axhline(y=df_days.iloc[0]['balance'], color='black', alpha=0.3)
        ax_left[0].legend(['Balance evolution'], loc ="upper left")

        ax_left[1].title.set_text("Asset evolution")
        ax_left[1].plot(df_days['price'], color='sandybrown', lw=1)
        ax_left[1].fill_between(df_days['price'].index, df_days['price'], alpha=0.2, color='sandybrown')
        ax_left[1].axhline(y=df_days.iloc[0]['price'], color='black', alpha=0.3)
        ax_left[1].legend(['Asset evolution'], loc ="upper left")

        ax_left[2].title.set_text("Drawdown curve")
        ax_left[2].plot(-df_days['drawdown_pct']*100, color='indianred', lw=1)
        ax_left[2].fill_between(df_days['drawdown_pct'].index, -df_days['drawdown_pct']*100, alpha=0.2, color='indianred')
        ax_left[2].axhline(y=0, color='black', alpha=0.3)
        ax_left[2].legend(['Drawdown in %'], loc ="lower left")

        ax_right = ax_left[3].twinx()

        ax_left[3].title.set_text("P&L VS Asset (not on the same scale)")
        ax_left[3].plot(df_days['balance'], color='royalblue', lw=1)
        ax_right.plot(df_days['price'], color='sandybrown', lw=1)
        ax_left[3].legend(['Wallet evolution (equity)'], loc ="lower right")
        ax_right.legend(['Asset evolution'], loc ="upper left")

        plt.show()

bt = BacktestCrossMA()
bt.load_data('BTC-USDT.csv')
bt.populate_indicators()
bt.populate_signals()
bt.run_backtest()
bt.backtest_analysis()
bt.plot_equity_vs_asset()

Mise en pratique

Ceci marque le dernier épisode de notre formation "Python pour la Finance". Par conséquent, nous ne fournirons pas de mise en pratique structurée comme auparavant. C'est désormais à vous d'appliquer vos compétences acquises pour répondre à vos besoins spécifiques et de pousser vos limites pour explorer de nouveaux horizons.

Cependant, pour continuer sur le même thème, nous vous suggérons d'expérimenter avec de nouvelles stratégies de trading, d'intégrer des mécanismes tels que les Take Profit - qui fonctionnent, en essence, comme l'inverse des Stop Loss - pour sécuriser vos gains lorsque les marchés évoluent en votre faveur.

En outre, enrichissez votre analyse en intégrant des mesures supplémentaires telles que le nombre de jours en gain, en perte, la plus longue série de gains ou de pertes, entre autres. Explorez l’incorporation de nouveaux graphiques et visualisations pour offrir des perspectives plus nuancées et détaillées de vos données et résultats de trading. Une suggestion de correction se trouve ici : https://github.com/RobotTraders/Python_For_Finance/blob/main/exercise_correction_chapter_10.ipynb.

Rappelez-vous, les possibilités sont infinies; chaque ajout et ajustement est une étape vers l'affinement de votre approche pour naviguer avec précision dans l'environnement dynamique et complexe de la finance. Bonne exploration !