Cet article est la suite de celui-ci : Backtrader - Développement de Stratégies. Si vous ne l'avez pas lu, je vous recommande donc fortement de le lire, à moins que vous ne connaissiez déjà les concepts basiques de Backtrader.
Aujourd'hui, nous allons d'abord apprendre à implémenter différents types d'ordres dans nos stratégies, puis nous verrons comment peut-on jouer sur la rentabilité en optimisant les paramètres de nos stratégies.
La maîtrise des ordres stop et des ordres limite est essentielle afin de développer un système de trading profitable. Pour une simulation réaliste de vos stratégies, il faut donc savoir implémenter ces éléments en Python, c'est ce que nous allons voir !
Lorsque vous exécutez un ordre dans Backtrader, par défaut cet ordre est un ordre "Market", c'est à dire un ordre "au marché". Il est possible de créer un ordre limite en précisant le type d'ordre lorsqu'on appelle self.buy()
ou self.sell()
, ainsi qu'en précisant son prix d'exécution.
Voici donc comment créer un ordre limite :
self.order = self.buy(exectype=bt.Order.Limit,
price=self.data.close[0] * 0.98)
Ici, on crée un ordre limite d'achat, qui sera exécuté lorsque le prix du marché sera inférieur à 2% du prix actuel.
Pour un ordre limite d'achat, le prix renseigné doit toujours être inférieur au prix actuel, sinon ça n'a pas d'intérêt. Et inversement pour un ordre de vente.
On peut aussi préciser un paramètre additionnel : valid
, qui permet de définir un instant à partir duquel l'ordre ne sera plus valide et sera annulé. On doit donc passer en paramètre une Datetime ou un Timedelta
self.order = self.buy(exectype=bt.Order.Limit,
price=self.data.close[0] * 0.98,
valid=dt.timedelta(days=3))
Ici on crée un ordre qui ne sera valide que 3 jours.
Leur fonctionnement est similaire à ceux des ordres limite, la différence étant qu'ici le prix doit être supérieur au prix actuel pour un ordre d'achat, et inférieur pour un ordre de vente :
self.order = self.buy(exectype=bt.Order.Stop,
price=self.data.close[0] * 1.02,
valid=dt.timedelta(days=3))
Ici, on crée un ordre stop d'achat qui se déclenchera lorsque le prix du marché sera supérieur à 2% du prix actuel.
On peut aussi créer des ordres Stop-Limite, pour cela il faut préciser le prix limite en plus :
self.order = self.buy(exectype=bt.Order.StopLimit,
price=self.data.close[0] * 1.02,
plimit=self.data.close[0] * 1.01,
valid=dt.timedelta(days=3))
Un ordre bracket est un ordre qui est en fait constitué de trois ordres. Prenons l'exemple d'un ordre d'achat. Il sera constitué :
C'est un ordre constitué d'un StopLoss et d'un TakeProfit en gros.
Vous savez maintenant créer des ordres stop et limite, donc vous pouvez en théorie réaliser un tel ordre actuellement. La subtilité est qu'il faut annuler l'ordre stop si l'ordre limite se déclenche, est inversement.
Heureusement, Backtrader nous permet de gérer ça plus facilement en nous fournissant les fonctions buy_bracket
et sell_bracket
. Ces fonctions nous renvoient une liste comprenant les 3 ordres, et prennent en paramètres le prix de l'ordre principal, le prix du TakeProfit, et celui du StopLoss.
orders = self.buy_bracket(limitprice=14.00, price=13.50, stopprice=13.00)
Maintenant, il faut que lorsque le StopLoss ou le TakeProfit se déclenchent, l'autre ordre soit annulé. On va donc stocker les références de ces ordres, et vérifier lorsqu'un autre sera mis à jour qu'il existe encore. S'il n'existe plus, alors on peut le supprimer.
On commence par récupérer les références de nos ordres dans une liste lorsque notre condition d'ouverture est remplie (dans la méthode next
) :
orders = self.buy_bracket(limitprice=14.00, price=13.50, stopprice=13.00)
self.orefs = [order.ref for order in orders]
Ensuite, il faut vérifier que nos ordres n'existent plus lorsqu'ils sont actualisés :
def notify_order(self, order):
print('{}: Order ref: {} / Type {} / Status {}'.format(
self.data.datetime.date(0),
order.ref, 'Buy' * order.isbuy() or 'Sell',
order.getstatusname()))
if order.status == order.Completed:
self.holdstart = len(self)
if not order.alive() and order.ref in self.orefs:
self.orefs.remove(order.ref)
On utilise pour ça order.alive()
qui nous renvoie True si l'ordre a toujours lieu d'exister, et False sinon.
Ce sont des ordres stop suiveurs, c'est à dire que leur prix est réajusté à chaque itération. (NB: si je n'explique pas en détail le fonctionnement de ces ordres, que ce soit limite, stop, stoplimit, etc... c'est car j'estime que vous avez des bases en trading, le but de l'article n'est pas d'expliquer comment ils fonctionnent, mais simplement de les implémenter en Python).
On les utilise de la même manière que les ordres stop, sauf qu'on précise en plus la distance entre le prix et le stop, soit en pourcentage, soit en unité de prix (ex: je place un StopTrail sur la paire BTC/EUR, et je souhaite que la distance entre le stop et le prix soit de 1000€. J'achète à 40000€, mon StopTrail est donc initialement placé à 39000€. Si le prix passe à 41000€, mon StopTrail passera à 40000€. Si ensuite le prix baisse, mon StopTrail ne bougera pas, il ne bouge que lors d'une hausse).
self.buy(exectype=bt.Order.StopTrail, trailamount=1000)
self.buy(exectype=bt.Order.StopTrail, trailpercent=0.02)
Vous maîtrisez maintenant de nouveaux types d'ordres, on va donc pouvoir passer à la suite et implémenter des paramètres dans nos stratégies.
Lorsque vous développez une stratégie, vous aimeriez peut-être y intégrer des éléments variables. La période d'un RSI par exemple, ou encore le pourcentage d'un StopLoss... C'est pourquoi il est essentiel d'utiliser des paramètres dans vos stratégies. Ceux ci permettent aussi d'optimiser votre stratégie en trouvant les meilleurs paramètres possibles. Déjà, commençons par voir comment intégrer des paramètres. Nous allons nous baser sur la stratégie vue dans l'article précédent, dont voici le code complet :
import backtrader as bt
import backtrader.feeds as btfeeds
import backtrader.indicators as btind
class MyFirstStrat(bt.Strategy):
def __init__(self):
self.rsi = btind.RSI(period=14)
self.order = None
def log(self, txt):
dt_formatted = self.datas[0].datetime.date(0).isoformat()
print(dt_formatted+" - "+txt)
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return
if order.status in [order.Completed]:
if order.isbuy():
self.log("ACHAT EXECUTE : " + str(order.executed.price))
if order.issell():
self.log("VENTE EXECUTE : " + str(order.executed.price))
self.time_executed = len(self)
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log('Problème lors du traitement de l\'ordre')
self.order = None
def next(self):
self.log("RSI : " + str(self.rsi[0]))
# Si nous n'avons pas encore pris de position
if not self.position:
if self.rsi[0] < 30:
# On achète !
self.order = self.buy()
self.log("ACHAT SOUMIS : " + str(self.datas[0].close[0]))
if self.rsi[0] > 70:
# On vend !
self.order = self.sell()
self.log("VENTE SOUMISE : " + str(self.datas[0].close[0]))
else:
if len(self) == self.time_executed + 5:
if self.position.size > 0:
self.order = self.sell()
elif self.position.size < 0:
self.order = self.buy()
data = btfeeds.GenericCSVData(dataname="BNB-BTC_2020-01-01_2021-12-01_1h.csv", timeframe=bt.TimeFrame.Minutes, compression=60, openinterest=-1)
cerebro = bt.Cerebro()
cerebro.broker.set_cash(100000)
cerebro.adddata(data)
cerebro.addsizer(bt.sizers.FixedSize)
cerebro.addstrategy(MyFirstStrat)
cerebro.run()
Pour ajouter des paramètres, il faut ajouter un tuple contenant nos paramètres sous forme de tuple dans la classe, en dehors du constructeur. Les paramètres sont donc modélisés comme tel : (nom_parametre, valeur_par_defaut)
. Le tuple contenant les tuples de paramètres doit s'appeler params
. Par exemple, si l'on souhaite ajouter un paramètre pour la période du RSI de notre stratégie, on va le faire de cette façon :
params = (
("rsi_period", 14), # <= Ne pas oublier la virgule !
)
Le paramètre s'appelle rsi_period
et sa valeur par défaut est 14. Attention à bien mettre la virgule après notre tuple de paramètre pour préciser qu'il s'agit bien d'un tuple, sinon Backtrader vous générera une erreur.
Maintenant, il faut préciser à notre RSI que sa période doit être celle passée en paramètre. On récupère un paramètre dans la classe via self.params.nom_parametre
, ou self.p.nom_parametre
. On modifie donc l'initialisation de notre RSI comme tel :
self.rsi = btind.RSI(period=self.p.rsi_period)
Et voilà ! Maintenant, lorsque l'on ajoute notre stratégie au cerebro, on peut préciser les paramètres à lui passer, on modifie donc la ligne d'ajout de la stratégie comme tel :
cerebro.addstrategy(MyFirstStrat, rsi_period=20)
Notre stratégie s'exécutera donc avec un RSI dont la période est 20, ou par défaut 14 si le paramètre n'est renseigné !
Bien sûr, les paramètres peuvent correspondre à tout et n'importe quoi. Dans notre exemple, on affiche le RSI à chaque itération de notre stratégie. On peut créer un paramètre pour préciser si l'on souhaite afficher ou pas ce RSI.
params = (
("rsi_period", 14),
("log_rsi", True),
)
Puis il suffit de modifier la méthode next
en ajoutant une simple condition :
if self.p.log_rsi:
self.log("RSI : " + str(self.rsi[0]))
Parfait ! Maintenant nous allons voir comment trouver les paramètres les plus optimisés pour notre stratégie.
Avant de commencer, analysons un peu le résultat de cerebro.run()
. En effet, ceci nous renvoie un résultat, étudions un peu sa forme.
result = cerebro.run()
print(result)
# [<__main__.MyFirstStrat object at 0x000002195ECA6AF0>]
C'est une liste contenant la stratégie qu'on a exécuté.
Mais pourquoi c'est une liste si elle ne contient qu'un objet, ça sert un peu à rien ?
En fait, on a la possibilité d'exécuter plusieurs stratégies en une fois avec Backtrader. Par exemple, ajoutez deux fois votre stratégie comme tel :
cerebro.addstrategy(MyFirstStrat, rsi_period=20, log_rsi=False)
cerebro.addstrategy(MyFirstStrat, rsi_period=20, log_rsi=False)
Vous verrez que votre liste contient maintenant deux stratégies, correspondant aux deux stratégies ajoutées. Bien sûr ici ça n'a aucun intérêt, on ajoute deux fois exactement la même stratégie, on ne peut donc pas comparer les résultats puisqu'ils proviennent de la même stratégie, on pourrait éventuellement changer les paramètres pour rendre les stratégies différentes, mais ça risque de générer une erreur puisque Backtrader n'est pas conçu pour optimiser les paramètres de cette façon.
En effet, pour optimiser des stratégies, Backtrader nous fournit la méthode optstrategy
à la place de addstrategy
. Cette méthode permet de renseigner les paramètres sous forme d'itérables, afin d'exécuter une stratégie pour chaque valeur de l'itérable. Voyons un exemple :
cerebro.optstrategy(MyFirstStrat, rsi_period=range(10,20), log_rsi=False)
Ici, on précise que Backtrader doit exécuter notre stratégie en changeant à chaque fois la période du RSI, de sorte qu'elle parcourt les valeurs entre 10 et 20.
Pour qu'il n'y ait pas d'erreurs, il faut préciser le nombre max de cœurs de votre processeur à utiliser lorsque vous exécuterez la simulation.
result = cerebro.run(maxcpus=1)
Personnellement je mets toujours 1, car lorsque je mets plus ça me génère une erreur, je ne saurais dire pourquoi.
Maintenant, on peut exécuter notre programme et afficher notre résultat. Le programme sera un peu plus long à s'exécuter, c'est normal, il y a 10 stratégies à simuler ici.
result = cerebro.run(maxcpus=1)
print(result)
# [[<backtrader.cerebro.OptReturn object at 0x000002362B30BF70>], ...]
Nos stratégies sont stockées sous la forme d'une liste de listes, chaque sous-liste contenant un élément correspondant à notre stratégie. Remarquez également que chaque objet n'est plus un objet MyFirstStrat
, mais un OptReturn
, qui correspond à un résultat de stratégie optimisé par Backtrader (puisqu'on a jamais vraiment besoin de s'intéresser à la stratégie en elle même, on ne s'intéresse qu'à ses résultats, d'ou l'intérêt du OptReturn
).
Maintenant, on aimerait analyser nos résultats, on va donc utiliser des analyzers.
Ce sont des objets utilisés afin d'analyser les résultats d'une stratégie. Ils s'utilisent très simplement. Voici comment ajouter un analyzer :
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer)
Ici, on ajoute un TradeAnalyzer
, qui va nous permettre d'obtenir des détails sur les trades effectués avec notre stratégie.
Pour récupérer un analyzer à partir d'un objet Strategy
ou OptReturn
, ou écrit simplement :
strat.analyzers.nom_analyzer
Pour le TradeAnalyzer
, son nom par défaut est tradeanalyzer
(original), on le récupère donc de cette façon :
strat.analyzers.tradeanalyzer
Si on souhaite personnaliser son nom, on le précise lorsqu'on l'ajoute à notre cerebro avec le paramètre _name
:
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="my_analyzer")
Bon, maintenant qu'on sait récupérer un analyzer à partir d'une stratégie, on aimerait obtenir ses données, car l'objet en tant que tel ne nous intéresse pas. Un analyzer dispose donc généralement d'une méthode get_analysis
qui nous renvoie ses données, son résultat.
Testez avec cet exemple (data
est toujours la même chose qu'à l'article précédent, et j'ai changé le sizer en un sizer de pourcentage, qui place ici 50% de notre cash sur chaque position) :
cerebro = bt.Cerebro()
cerebro.broker.set_cash(100000)
cerebro.adddata(data)
cerebro.addsizer(bt.sizers.PercentSizer, percents=50)
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="my_analyzer")
cerebro.addstrategy(MyFirstStrat, rsi_period=14, log_rsi=False)
result = cerebro.run()
print(result[0].analyzers.my_analyzer.get_analysis())
# AutoOrderedDict([('total', AutoOrderedDict([('total', 458), ('open', 0), ('closed', 458)])), ...
On obtient un AutoOrderedDict
qui est un type de dictionnaire spécial implémenté par Backtrader, mais se comportant à peu de choses prêt comme un dictionnaire normal. Il contient les infos suivantes :
Ainsi, si on souhaite récupérer le PNL net total (ce qui correspond au profit total ou à la perte totale réalisée sur la période en appliquant notre stratégie), on fait comme ceci :
pnl = result[0].analyzers.my_analyzer.get_analysis().pnl.net.total
print(pnl)
# -64302.50242399527
Ici, on a réalisé une perte de 64302 (ouch).
L'idée est maintenant d'appliquer ça à notre optimisation de stratégie afin de trouver celle avec le meilleur rendement (ou le moins pire dans notre cas).
On va donc reprendre notre code de toute à l'heure :
cerebro.optstrategy(MyFirstStrat, rsi_period=range(10,20), log_rsi=False)
result = cerebro.run(maxcpus=1)
Maintenant, pour chaque OptReturn
stocké, on va récupérer le résultat de notre analyzer, et stocker le PNL net total dans un tuple, avec en plus la période de RSI correspondant à notre stratégie, histoire d'associer un paramètre avec une valeur de profit ou de perte, et ajouter ce tuple à une liste :
pnls = []
for strat in result:
pnls.append((
strat[0].params.rsi_period,
strat[0].analyzers.my_analyzer.get_analysis().pnl.net.total
))
print(pnls)
# [(10, -62805.90766598114), (11, -63089.57228916429), ...]
Voilà, cela nous permet de voir quel paramètre de RSI nous permet d'avoir le moins de pertes.
Mais imaginez qu'on ait des centaines de stratégies (avec une stratégie par changement dans un paramètre, on arrive vite à des grands nombres), il nous faut un moyen plus efficace d'afficher nos résultats. On va donc simplement les trier en fonction des PNL décroissants, et ne garder que les 3 premiers, pour obtenir notre top 3 paramètres :
sorted_pnls = sorted(pnls, key=lambda x: x[1], reverse=True)
print(sorted_pnls[:3])
# [(19, -49661.743587509125), (16, -50463.16704087634), (17, -50652.975186920725)]
On trouve que pour la période de RSI nous générant le moins de pertes est 19, ensuite 16, et finalement 17. C'est pas ouf tout ça, il faudra penser à changer de stratégie !
Vous savez maintenant comment optimiser une stratégie en jouant sur ses paramètres !
Nous avons vu dans cet article comment peut-on créer des stratégies plus complexes, en ajoutant de nouveaux types d'ordres et des paramètres, puis comment optimiser ces stratégies afin de trouver les meilleurs paramètres possibles. Nous sommes loin d'avoir fait le tour de Backtrader, mais en tous cas vous disposez déjà de suffisamment d'outils pour tester vos stratégies avant de les utiliser réellement sur les marchés. Avec un peu de jugeotte et de détermination, vous pouvez même à ce stade quasiment créer un bot de trading je pense.
Si vous avez des questions, où qu'il y a des choses que vous ne comprenez pas, n'hésitez pas à laisser un commentaire ou à me contacter, par mail ou via l'onglet disponible sur le site !
A très bientôt pour la suite de cette série sur Backtrader !