In questa lezione descriviamo le modifiche che abbiamo effettuato al motore di backtest sul forex per il sistema di trading event driven open-source TQforex. Abbiamo migliorato la gestione del backtest per il trading algoritmico sul mercato forex.
In particolare, in questa lezione trattiamo le seguenti nuove funzionalità:
- Modifichiamo l’oggetto
Position
per correggere un errore nella gestione delle aperture e chiusure di una posizione - Aggiungiamo la funzionalità di gestione dei dati storici tramite il download di file di dati tick da DukasCopy
- Implementiamo la prima versione di un backtester basato su eventi utilizzando dati di tick giornalieri
Correzione degli errori di gestione della posizione
La prima modifica che introduciamo nel backtest sul forex riguarda una nuova logica per gestire gli ordini di acquisto e vendita nell’oggetto Position
.
Inizialmente avevamo progettato l’oggetto Position
in modo molto snello, delegando all’oggetto Portfolio
la maggior parte del lavoro per il calcolo dei prezzi di posizione.
Questo approccio aumentava inutilmente la complessità della classe Portfolio
, rendendo il codice difficile da leggere e complicando la comprensione della logica. Inoltre, risultava particolarmente problematico quando volevamo implementare una gestione personalizzata del portafoglio senza occuparci della gestione delle posizioni “standard”.
Abbiamo anche riscontrato un errore concettuale nella logica implementata: confondevamo l’acquisto e la vendita di ordini con l’essere in una posizione long o short. Questa confusione portava a un calcolo errato del P&L alla chiusura di una posizione.
Abbiamo quindi modificato l’oggetto Position
per accettare direttamente i prezzi bid e ask, evitando di “aggiungere” e “rimuovere” i prezzi come previsto inizialmente attraverso il Portfolio
. In questo modo, l’oggetto Position
traccia se siamo long o short e utilizza correttamente il prezzo bid o ask per gli acquisti o le chiusure.
Abbiamo aggiornato anche gli unit test per allinearli alla nuova interfaccia. Nonostante queste modifiche richiedano tempo, ci permettono di ottenere una maggiore fiducia nei risultati, soprattutto quando sviluppiamo strategie più sofisticate.
Di seguito presentiamo il codice del nuovo file position.py
:
from decimal import Decimal, getcontext, ROUND_HALF_DOWN
class Position(object):
def __init__(
self, position_type, market,
units, exposure, bid, ask
):
self.position_type = position_type # Long or short
self.market = market
self.units = units
self.exposure = Decimal(str(exposure))
# Long or short
if self.position_type == "long":
self.avg_price = Decimal(str(ask))
self.cur_price = Decimal(str(bid))
else:
self.avg_price = Decimal(str(bid))
self.cur_price = Decimal(str(ask))
self.profit_base = self.calculate_profit_base(self.exposure)
self.profit_perc = self.calculate_profit_perc(self.exposure)
def calculate_pips(self):
getcontext.prec = 6
mult = Decimal("1")
if self.position_type == "long":
mult = Decimal("1")
elif self.position_type == "short":
mult = Decimal("-1")
return (mult * (self.cur_price - self.avg_price)).quantize(
Decimal("0.00001"), ROUND_HALF_DOWN
)
def calculate_profit_base(self, exposure):
pips = self.calculate_pips()
return (pips * exposure / self.cur_price).quantize(
Decimal("0.00001"), ROUND_HALF_DOWN
)
def calculate_profit_perc(self, exposure):
return (self.profit_base / exposure * Decimal("100.00")).quantize(
Decimal("0.00001"), ROUND_HALF_DOWN
)
def update_position_price(self, bid, ask, exposure):
if self.position_type == "long":
self.cur_price = Decimal(str(bid))
else:
self.cur_price = Decimal(str(ask))
self.profit_base = self.calculate_profit_base(exposure)
self.profit_perc = self.calculate_profit_perc(exposure)
Gestione dei dati storici dei tick
La prossima importante funzionalità che dobbiamo includere in un sistema di trading completo è l’abilità di effettuare un backtest sul forex ad alta frequenza.
Dobbiamo prima creare un archivio per i dati di tick delle coppie di valute, che possono raggiungere dimensioni piuttosto grandi. Ad esempio, i dati di tick di un giorno per una singola coppia di valute da DukasCopy, in formato CSV, occupano circa 3,3 Mb.
Comprendiamo facilmente come il backtest intraday di oltre 20 coppie di valute, su più anni, con significative variazioni dei parametri, porti rapidamente a generare gigabyte di dati da elaborare.
Gestiamo questi dati in modo speciale, creando un database di titoli ad alte prestazioni e completamente automatizzato. Approfondiremo questo sistema in una futura lezione, ma per ora useremo i file CSV per i nostri scopi.
Per allineare i dati storici di backtest e quelli di live streaming, creiamo una classe astratta di gestione dei prezzi chiamata PriceHandler
.
PriceHandler
rappresenta una classe base astratta che richiede alle classi ereditate di sovrascrivere i metodi “puramente virtuali”. L’unico metodo obbligatorio è stream_to_queue
, che attiviamo tramite il thread dei prezzi quando avviamo il sistema (live trading o backtest). La funzione stream_to_queue
recupera i dati sui prezzi dalla sorgente specifica dell’implementazione della classe e utilizza il metodo .put()
della libreria queue per aggiungere un oggetto TickEvent
.
Così permettiamo a tutte le sottoclassi di PriceHandler
di interfacciarsi con il resto del sistema di trading, senza che gli altri componenti conoscano o si preoccupino di come otteniamo i dati sui prezzi.
In questo modo possiamo collegare facilmente file flat, archivi di file come HDF5, database relazionali come PostgreSQL o persino risorse esterne come siti Web al motore di backtesting o di trading live.
Implementazione
Di seguito riportiamo il codice dell’oggetto PriceHandler
:
from abc import ABCMeta, abstractmethod
..
..
class PriceHandler(object):
"""
PriceHandler è una classe base astratta che fornisce un'interfaccia per
tutti i successivi gestori di dati (ereditati) (sia live che storici).
L'obiettivo di un oggetto PriceHandler (derivato) è produrre un insieme di
bid / ask / timestamp "tick" per ogni coppia di valute e inserirli
una coda di eventi.
Questo replicherà il modo in cui una strategia live funzionerebbe con i dati
dei tick che sarebbero trasmessi in streaming tramite un broker.
"""
__metaclass__ = ABCMeta
@abstractmethod
def stream_to_queue(self):
"""
Trasmette una sequenza di eventi di dati tick (timestamp, bid, ask)
come tuple alla coda degli eventi.
"""
raise NotImplementedError("Should implement stream_to_queue()")
Abbiamo bisogno inoltre di una sottoclasse chiamata HistoricCSVPriceHandler
, che preveda due metodi.
Il primo è chiamato _open_convert_csv_files
e utilizza Pandas per caricare un file CSV in un DataFrame e formare le colonne Bid e Ask. Il secondo metodo, stream_to_queue
scorre attraverso questo DataFrame e ad ogni iterazione aggiunge un oggetto TickEvent
alla coda degli eventi.
Inoltre, i prezzi correnti di bid/ask correnti impostati a livello di classe, e vengono successivamente interrogati tramite l’oggetto Portfolio
.
Di seguito il codice di HistoricCSVPriceHandler
:
class HistoricCSVPriceHandler(PriceHandler):
"""
HistoricCSVPriceHandler è progettato per leggere un file CSV di
dati tick per ciascuna coppia di valute richiesta e trasmetterli in streaming
alla coda degli eventi.
"""
def __init__(self, pairs, events_queue, csv_dir):
"""
Inizializza il gestore dati storici richiedendo
la posizione dei file CSV e un elenco di simboli.
Si presume che tutti i file siano nella forma
'pair.csv', dove " pair " è la coppia di valute. Per
EUR/USD il nome del file è EURUSD.csv.
Parametri:
pairs - L'elenco delle coppie di valute da ottenere.
events_queue - La coda degli eventi a cui inviare i tick.
csv_dir: percorso di directory assoluto per i file CSV.
"""
self.pairs = pairs
self.events_queue = events_queue
self.csv_dir = csv_dir
self.cur_bid = None
self.cur_ask = None
def _open_convert_csv_files(self):
"""
Apre i file CSV dalla directory su disco, converte i dati
in un DataFrame di pandas con un dizionario di coppie.
"""
pair_path = os.path.join(self.csv_dir, '%s.csv' % self.pairs[0])
self.pair = pd.io.parsers.read_csv(
pair_path, header=True, index_col=0, parse_dates=True,
names=("Time", "Ask", "Bid", "AskVolume", "BidVolume")
).iterrows()
def stream_to_queue(self):
self._open_convert_csv_files()
for index, row in self.pair:
self.cur_bid = Decimal(str(row["Bid"])).quantize(
Decimal("0.00001", ROUND_HALF_DOWN)
)
self.cur_ask = Decimal(str(row["Ask"])).quantize(
Decimal("0.00001", ROUND_HALF_DOWN)
)
tev = TickEvent(self.pairs[0], index, row["Bid"], row["Ask"])
self.events_queue.put(tev)
Ora che abbiamo sviluppato una funzionalità per gestire i dati storici di base, possiamo creare un backtester sul forex completamente guidato dagli eventi.
Funzionalità di BackTest sul Forex
Nel trading algoritmico risulta fondamentale utilizzare un motore di backtest che riproduca il più fedelmente possibile il funzionamento di un motore di trading live. Una gestione sofisticata dei costi di transazione, soprattutto nelle operazioni ad alta frequenza, spesso determina il successo o il fallimento di una strategia.
Simuliamo efficacemente questa gestione dei costi solo utilizzando un motore di esecuzione basato su eventi multi-thread. Sebbene un sistema di questo tipo risulti molto più complesso rispetto a un semplice backtester vettorializzato per l’analisi del P&L, riusciamo a riprodurre con maggiore fedeltà il comportamento reale e a prendere decisioni più efficaci nella selezione delle strategie.
Inoltre, riusciamo a iterare più rapidamente nel tempo, senza dover passare continuamente dalla strategia di “livello di ricerca” a quella di “livello di implementazione”, perché le rendiamo la stessa cosa. Gli unici componenti che modifichiamo sono la classe di streaming dei prezzi e la classe di esecuzione; tutto il resto rimane identico tra i sistemi di backtesting e di live trading.
Di fatto, il nuovo codice backtest.py
risulta quasi identico al codice trading.py
che utilizziamo per il trading reale o per il trading practice con OANDA. Dobbiamo soltanto importare le classi HistoricPriceCSVHandler
e SimulatedExecution
al posto delle classi StreamingPriceHandler
e OANDAExecutionHandler
. Tutto il resto rimane invariato.
Di seguito riportiamo il codice di backtest.py
per il backtest sul forex:
import copy, sys
import queue
import threading
import time
from decimal import Decimal, getcontext
from execution import SimulatedExecution
from portfolio import Portfolio
from settings import settings
from strategy import TestStrategy
from data.price import HistoricCSVPriceHandler
def trade(events, strategy, portfolio, execution, heartbeat):
"""
Esegue un ciclo while infinito che esegue il polling
della coda degli eventi e indirizza ogni evento al
componente della strategia del gestore di esecuzione.
Il ciclo si fermerà quindi per "heartbeat" secondi
e continuerà.
"""
while True:
try:
event = events.get(False)
except queue.Empty:
pass
else:
if event is not None:
if event.type == 'TICK':
strategy.calculate_signals(event)
elif event.type == 'SIGNAL':
portfolio.execute_signal(event)
elif event.type == 'ORDER':
execution.execute_order(event)
time.sleep(heartbeat)
if __name__ == "__main__":
# Imposta il numero di decimali a 2
getcontext().prec = 2
heartbeat = 0.0 # mezzo secondo tra ogni polling
events = queue.Queue()
equity = settings.EQUITY
# Carica il file CSV dei dati storici
pairs = ["EURUSD"]
csv_dir = settings.CSV_DATA_DIR
if csv_dir is None:
print("No historic data directory provided - backtest terminating.")
sys.exit()
# Crea la classe di streaming dei dati storici di tick
prices = HistoricCSVPriceHandler(pairs, events, csv_dir)
# Crea il generatore della strategia/signale, passando lo
# strumento e la coda degli eventi
strategy = TestStrategy(pairs[0], events)
# Crea l'oggetto portfolio per tracciare i trade
portfolio = Portfolio(prices, events, equity=equity)
# Crea il gestore di esecuzione simulato
execution = SimulatedExecution()
# Crea due thread separati: uno per il ciclo di trading
# e un'altro per la classe di streaming dei prezzi di mercato
trade_thread = threading.Thread(
target=trade, args=(
events, strategy, portfolio, execution, heartbeat
)
)
price_thread = threading.Thread(target=prices.stream_to_queue, args=[])
# Avvia entrambi i thread
trade_thread.start()
price_thread.start()
Esecuzione multi-thread
L’utilizzo di un sistema di esecuzione multi-thread per il backtest sul forex presenta come principale svantaggio il fatto di non essere deterministico. Questo significa che, eseguendo più volte il backtest degli stessi dati, otteniamo risultati differenti, anche se di poco.
Questo accade perché non possiamo garantire lo stesso ordine delle istruzioni eseguite dai thread durante esecuzioni differenti della stessa simulazione. Ad esempio, quando inseriamo elementi nella coda, possiamo ottenere nove oggetti TickEvent
inseriti nel backtest n.1, ma undici nel backtest n.2.
Poiché l’oggetto Strategy
esegue il polling della coda degli oggetti TickEvent
, osserviamo prezzi bid/ask diversi nelle due serie e apriamo una posizione a prezzi bid/ask diversi. Questo genera (piccole) differenze nei rendimenti.
Questo rappresenta un grosso problema? Non pensiamo proprio. Non solo il sistema live funzionerà così, ma possiamo anche valutare quanto la nostra strategia sia sensibile alla velocità di ricezione dei dati. Ad esempio, calcolando la varianza dei rendimenti in tutti i backtest eseguiti con gli stessi dati, otteniamo un’idea della sensibilità della strategia alla latenza dei dati.
Idealmente, vogliamo una strategia con una piccola varianza in ciascuna delle nostre serie. Tuttavia, se riscontriamo una varianza elevata, dobbiamo fare molta attenzione prima di mettere live questa strategia.
Possiamo persino eliminare completamente il problema del determinismo utilizzando un singolo thread nel nostro codice di backtest (come nel backtester event-driven per le azioni di TradingQuant). Tuttavia, questa scelta riduce il realismo rispetto al sistema live. Questi sono i dilemmi della simulazione di trading ad alta frequenza!
Conclusione
Un altro problema che dobbiamo risolvere nel backtest sul forex riguarda la gestione di una sola valuta di base, l’EUR, e una singola coppia di valute, EUR/USD.
Ora che abbiamo sostanzialmente modificato la gestione di Position
, possiamo estenderla più facilmente per gestire più coppie di valute. Questo rappresenta il prossimo passaggio.
A quel punto potremo provare strategie su più coppie di valute ed eventualmente introdurre Matplotlib per rappresentare graficamente i risultati.
Il codice completo presentato in questa lezione, basato sul sistema di trading automatico sul Forex TQforex, è disponibile nel seguente repository GitHub: https://github.com/tradingquant-it/TQforex.”