Nella precedente lezione del corso sul trading automatico sul forex abbiamo illustrato alcune importanti modifiche al software TQforex. Questi aggiornamenti hanno ampliato in modo significativo le funzionalità del sistema, tanto che ora risulta quasi pronto per il backtesting con dati storici di tick su una gamma di coppie di valute. In questa lezione descriviamo come effettuare il trading su coppie di valute multiple del forex.
In particolare, descriviamo le seguenti nuove funzionalità del nostro sistema di trading event-driven sul forex:
- Aggiornamento degli oggetti
Position
ePortfolio
per consentire il trading su coppie di valute multiple, comprese quelle non denominate nella valuta del conto. Ora, ad esempio, con un conto in EUR possiamo negoziare anche GBP/USD. - Nuove logiche e modalità con cui
Position
ePortfolio
calcolano apertura, chiusura, aggiunta e rimozione di unità. L’oggettoPosition
gestisce ora la maggior parte della logica operativa, mentre l’oggettoPortfolio
si occupa della gestione ad alto livello. - Introduzione della prima strategia non banale: la nota strategia Moving Average Crossover basata su una coppia di medie mobili semplici (SMA).
- Modifica della codice di
backtest.py
per renderlo single-threaded e deterministico. Nonostante l’iniziale ottimismo sull’approccio multi-thread, abbiamo riscontrato difficoltà nell’ottenere risultati di backtest soddisfacenti utilizzando il multi-threading. - Visualizzazione della curva di equity tramite un semplice script basato su Matplotlib.
Trading su coppie di valute multiple
Una caratteristica del sistema di trading che abbiamo discusso spesso nelle lezioni di questa serie riguarda la capacità di gestire più coppie di valute.
In questa lezione vediamo come modificare il software per gestire conti nominati in valute diverse dall’EUR, che era l’unica valuta supportata in precedenza. Descriviamo anche come negoziare altre coppie di valute, escluse quelle che coinvolgono lo Yen giapponese (JPY). La limitazione sullo Yen deriva dalle modalità particolari di calcolo delle dimensioni dei tick nelle coppie con JPY.
Per ottenere questo risultato, dobbiamo modificare la logica di calcolo del profitto quando rimuoviamo unità o chiudiamo una posizione. Di seguito mostriamo il nuovo codice per il calcolo dei pips nel file position.py
:
def calculate_pips(self):
mult = Decimal("1")
if self.position_type == "long":
mult = Decimal("1")
elif self.position_type == "short":
mult = Decimal("-1")
pips = (mult * (self.cur_price - self.avg_price)).quantize(
Decimal("0.00001"), ROUND_HALF_DOWN
)
return pips
Se chiudiamo la posizione per realizzare un guadagno o una perdita, dobbiamo utilizzare il seguente codice per close_position
, da inserire nel file position.py
:
def close_position(self):
ticker_cp = self.ticker.prices[self.currency_pair]
ticker_qh = self.ticker.prices[self.quote_home_currency_pair]
if self.position_type == "long":
remove_price = ticker_cp["ask"]
qh_close = ticker_qh["bid"]
else:
remove_price = ticker_cp["bid"]
qh_close = ticker_qh["ask"]
self.update_position_price()
# Calcolo dele PnL
pnl = self.calculate_pips() * qh_close * self.units
return pnl.quantize(Decimal("0.01", ROUND_HALF_DOWN))
In primo luogo otteniamo i prezzi denaro e lettera sia per la coppia di valute negoziata sia per la coppia di valute di base (“quote/home”). Ad esempio, per un conto denominato in EUR, quando negoziamo GBP/USD, otteniamo i prezzi per “USD/EUR”, poiché GBP rappresenta la valuta di base e USD la quotazione.
In questa fase verifichiamo se la posizione è long o short, quindi calcoliamo il “prezzo di rimozione” per la coppia negoziata e il “prezzo di rimozione” per la coppia quote/home, utilizzando rispettivamente remove_price
e qh_close
.
Aggiorniamo i prezzi correnti e medi all’interno della posizione e infine calcoliamo il P&L moltiplicando i pip, il prezzo di rimozione per quote/home e il numero di unità che stiamo chiudendo.
Abbiamo eliminato completamente la necessità di valutare l’esposizione, poiché risultava una variabile ridondante. Questa formula calcola correttamente il P&L per qualsiasi scambio di coppie di valute non denominate in JPY.
Revisione della posizione e gestione del portafoglio
Oltre alla possibilità effettuare il trading su coppie di valute multiple, vediamo come perfezionare la logica con cui Position
e Portfolio
condividono la responsabilità di aprire e chiudere le posizioni, oltre ad aggiungere e sottrarre unità. In particolare, spostiamo buona parte del codice di gestione della posizione da portfolio.py
a position.py
.
Questa scelta risulta più naturale, poiché ogni posizione dovrebbe gestirsi autonomamente senza delegare al portafoglio!
In particolare, creiamo o migriamo i metodi add_units
, remove_units
e close_position
.
def add_units(self, units):
cp = self.ticker.prices[self.currency_pair]
if self.position_type == "long":
add_price = cp["ask"]
else:
add_price = cp["bid"]
new_total_units = self.units + units
new_total_cost = self.avg_price * self.units + add_price * units
self.avg_price = new_total_cost / new_total_units
self.units = new_total_units
self.update_position_price()
def remove_units(self, units):
dec_units = Decimal(str(units))
ticker_cp = self.ticker.prices[self.currency_pair]
ticker_qh = self.ticker.prices[self.quote_home_currency_pair]
if self.position_type == "long":
remove_price = ticker_cp["ask"]
qh_close = ticker_qh["bid"]
else:
remove_price = ticker_cp["bid"]
qh_close = ticker_qh["ask"]
self.units -= dec_units
self.update_position_price()
# Calcolo dele PnL
pnl = self.calculate_pips() * qh_close * dec_units
return pnl.quantize(Decimal("0.01", ROUND_HALF_DOWN))
def close_position(self):
ticker_cp = self.ticker.prices[self.currency_pair]
ticker_qh = self.ticker.prices[self.quote_home_currency_pair]
if self.position_type == "long":
remove_price = ticker_cp["ask"]
qh_close = ticker_qh["bid"]
else:
remove_price = ticker_cp["bid"]
qh_close = ticker_qh["ask"]
self.update_position_price()
# Calcolo dele PnL
pnl = self.calculate_pips() * qh_close * self.units
return pnl.quantize(Decimal("0.01", ROUND_HALF_DOWN))
Negli ultimi due metodi possiamo vedere come è implementata la nuova formula per il calcolo del profitto.
Di conseguenza, molte delle funzionalità della classe Portfolio
sono state ridotte. In particolare, i metodi add_new_position
, add_position_units
, remove_position_units
e close_position
sono stati modificati a seguito dello spostamento del calcolo all’interno dell’oggetto Position
:
def add_new_position(self, position_type, currency_pair, units, ticker):
ps = Position(
self.home_currency, position_type,
currency_pair, units, ticker
)
self.positions[currency_pair] = ps
def add_position_units(self, currency_pair, units):
if currency_pair not in self.positions:
return False
else:
ps = self.positions[currency_pair]
ps.add_units(units)
return True
def remove_position_units(self, currency_pair, units):
if currency_pair not in self.positions:
return False
else:
ps = self.positions[currency_pair]
pnl = ps.remove_units(units)
self.balance += pnl
return True
def close_position(self, currency_pair):
if currency_pair not in self.positions:
return False
else:
ps = self.positions[currency_pair]
pnl = ps.close_position()
self.balance += pnl
del[self.positions[currency_pair]]
return True
In sostanza, tutti i metodi (a parte add_new_position
) controllano se esiste una posizione per quella coppia di valute e poi richiamano il corrispondente metodo in Position
, tenendo conto del profitto quando necessario.
Strategia di crossover della media mobile
In TradingQuant abbiamo già presentato una strategia di Moving Average Crossover nel contesto del mercato azionario. Usiamo questa strategia come banco di prova del trading su coppie di valute multiple, poiché possiamo replicare facilmente i calcoli anche a mano (almeno a frequenze più basse!) per verificare che il backtester funzioni correttamente.
L’idea di base della strategia è la seguente:
- Creiamo due filtri separati di media mobile semplice con periodi variabili della finestra di una particolare serie temporale.
- Generiamo segnali di acquisto dell’asset quando la media mobile più breve supera quella più lunga.
- Vendiamo l’asset se la media mobile più lunga supera quella più breve.
Questa strategia funziona bene quando una serie temporale entra in un periodo di forte tendenza e poi inverte lentamente la direzione.
L’implementazione risulta semplice. Prima implementiamo un metodo calc_rolling_sma
che ci permette di calcolare in modo più efficiente l’SMA del nuovo periodo sfruttando quello precedente, senza ricalcolare l’intera media mobile a ogni passo.
Successivamente generiamo segnali in due casi distinti. Nel primo caso creiamo un segnale se la SMA breve supera la SMA lunga e non siamo già long sulla coppia di valute. Nel secondo caso generiamo un segnale se la SMA lunga supera quella breve mentre siamo già long nello strumento.
In questo esempio impostiamo il periodo della finestra a 500 tick per la SMA breve e 2.000 tick per la SMA lunga. Ovviamente in un ambiente di produzione dovremo ottimizzare questi parametri, ma per i nostri test si adattano perfettamente.
class MovingAverageCrossStrategy(object):
"""
Una strategia base di Moving Average Crossover che genera
due medie mobili semplici (SMA), con finestre predefinite
di 500 tick per la SMA breve e 2.000 tick per la SMA
lunga.
La strategia è "solo long" nel senso che aprirà solo una
posizione long una volta che la SMA breve supera la SMA
lunga. Chiuderà la posizione (prendendo un corrispondente
ordine di vendita) quando la SMA lunga incrocia nuovamente
la SMA breve.
La strategia utilizza un calcolo SMA a rotazione per
aumentare l'efficienza eliminando la necessità di chiamare due
calcoli della media mobile completa su ogni tick.
"""
def __init__(
self, pairs, events,
short_window=500, long_window=2000
):
self.pairs = pairs
self.events = events
self.ticks = 0
self.invested = False
self.short_window = short_window
self.long_window = long_window
self.short_sma = None
self.long_sma = None
def calc_rolling_sma(self, sma_m_1, window, price):
return ((sma_m_1 * (window - 1)) + price) / window
def calculate_signals(self, event):
if event.type == 'TICK':
price = event.bid
if self.ticks == 0:
self.short_sma = price
self.long_sma = price
else:
self.short_sma = self.calc_rolling_sma(
self.short_sma, self.short_window, price
)
self.long_sma = self.calc_rolling_sma(
self.long_sma, self.long_window, price
)
# Si avvia la strategia solamente dopo aver creato una accurata
# finestra di breve periodo
if self.ticks > self.short_window:
if self.short_sma > self.long_sma and not self.invested:
signal = SignalEvent(self.pairs[0], "market", "buy", event.time)
self.events.put(signal)
self.invested = True
if self.short_sma < self.long_sma and self.invested:
signal = SignalEvent(self.pairs[0], "market", "sell", event.time)
self.events.put(signal)
self.invested = False
self.ticks += 1
Backtester a thread singolo
Un altro cambiamento importante per gestire il trading su coppie di valute multiple consiste nel modificare il componente del backtest in modo da utilizzare un singolo thread, anziché il multi-thread.
Introduciamo questa modifica perché risulta molto complesso sincronizzare i thread in modo simile a quanto accade nel trading live, senza introdurre errori e bias che comprometterebbero i risultati del backtest. In particolare, con un backtester multi-thread otteniamo prezzi di entrata e uscita molto irrealistici, poiché si verificano tipicamente dopo alcune ore (virtuali) dall’effettiva ricezione del tick.
Per superare questa criticità, incorporiamo lo streaming dell’oggetto TickEvent
direttamente nel ciclo di backtest, come mostrato nel seguente frammento di backtest.py
:
def backtest(events, ticker, strategy, portfolio,
execution, heartbeat, max_iters=200000
):
"""
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à fino a quando si supera il numero massimo
di iterazioni.
"""
iters = 0
while True and iters < max_iters:
ticker.stream_next_tick()
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)
iters += 1
portfolio.output_results()
Facciamo attenzione alla linea ticker.stream_next_tick()
. Questo metodo lo richiamiamo prima di effettuare il polling della coda degli eventi, così ci assicuriamo di elaborare un nuovo evento tick prima di interrogare nuovamente la coda.
In questo modo eseguiamo un segnale non appena arrivano nuovi dati di mercato, anche se il processo di esecuzione degli ordini subisce un certo ritardo a causa dello slippage.
Abbiamo anche impostato un valore max_iters
che controlla per quanto tempo lasciamo continuare il ciclo di backtest. In pratica, lo manteniamo abbastanza grande quando lavoriamo con più valute su più giorni. In questa lezione, lo abbiamo configurato su un valore predefinito che ci permette di elaborare i dati di un singolo giorno per una coppia di valute.
Il metodo stream_next_tick
della classe del price handler funziona in modo simile a stream_to_queue
, con la differenza che richiamiamo manualmente il metodo iterativo next()
invece di eseguire il tick streaming all’interno di un ciclo for.
def stream_next_tick(self):
"""
Il Backtester è ora passato ad un modello a un thread singolo
in modo da riprodurre completamente i risultati su ogni esecuzione.
Ciò significa che il metodo stream_to_queue non può essere usato
ed è sostituito dal metodo stream_next_tick.
Questo metodo viene chiamato dalla funzione di backtesting, esterna
a questa classe e inserisce un solo tick nella coda, ed inoltre
aggiornare l'attuale bid / ask e l'inverso bid / ask.
"""
try:
index, row = self.all_pairs.next()
except StopIteration:
return
else:
self.prices[row["Pair"]]["bid"] = Decimal(str(row["Bid"])).quantize(
Decimal("0.00001", ROUND_HALF_DOWN)
)
self.prices[row["Pair"]]["ask"] = Decimal(str(row["Ask"])).quantize(
Decimal("0.00001", ROUND_HALF_DOWN)
)
self.prices[row["Pair"]]["time"] = index
inv_pair, inv_bid, inv_ask = self.invert_prices(row)
self.prices[inv_pair]["bid"] = inv_bid
self.prices[inv_pair]["ask"] = inv_ask
self.prices[inv_pair]["time"] = index
tev = TickEvent(row["Pair"], index, row["Bid"], row["Ask"])
self.events_queue.put(tev)
Da notare che si interrompe al ricevimento di un’eccezione StopIteration
. Ciò consente al codice di riprendere l’esecuzione anziché bloccarsi.
Visualizzazione risultati con Matplotlib
Dobbiamo anche creare uno script di output, utilizzando Matplotlib in modo molto semplice per visualizzare la curva di equity. Il file output.py
è inserito all’interno della directory backtest
di DTForex ed il codice è riportato di seguito:.
Da notare che settings.py
deve ora prevedere la nuova variabile OUTPUT_RESULTS_DIR
, che deve essere presente e valorizzata nelle impostazioni. In questo esempio abbiamo impostato a una directory temporanea fuori dalla struttura del progetto in modo da non aggiungere accidentalmente nessun risultato di backtest al codice base del progetto!
La curva di equity è costruita aggiungendo un valore di portafoglio (“balance”) a una lista di dizionari, con un dizionario corrispondente a una marca temporale.
Una volta completato il backtest, l’elenco dei dizionari viene convertito in un DataFrame di pandas e il metodo to_csv
viene utilizzato per l’output equity.csv
.
Questo script di output legge semplicemente il file e visualizza il grafico della colonna balance
del DataFrame.
Di seguito il codice per i metodi append_equity_row
e output_results
della classe Portfolio
:
def append_equity_row(self, time, balance):
d = {"time": time, "balance": balance}
self.equity.append(d)
def output_results(self):
filename = "equity.csv"
out_file = os.path.join(OUTPUT_RESULTS_DIR, filename)
df_equity = pd.DataFrame.from_records(self.equity, index='time')
df_equity.to_csv(out_file)
print
"Simulation complete and results exported to %s" % filename
Ogni volta che chiamiamo execute_signal
, richiamiamo il metodo precedente e aggiungiamo il valore di timestamp o saldo al membro equity
.
Alla fine del backtest, chiamiamo output_results
, che converte semplicemente l’elenco dei dizionari in un DataFrame e salva l’output nella directory specificata in OUTPUT_RESULTS_DIR
.
Questo metodo non rappresenta il modo migliore per creare una curva di equity, poiché aggiorniamo i dati solo quando generiamo un segnale. In questo modo non consideriamo il P&L non realizzato.
Anche se durante il trading su coppie di valute in reale realizziamo effettivamente profitti o perdite solo alla chiusura di una posizione, la curva di equity rimane piatta tra un aggiornamento e l’altro del saldo di portafoglio. Peggio ancora, Matplotlib interpola linearmente tra i punti, dando un’impressione errata del P&L non realizzato.
Per risolvere il problema, creiamo un tracker del P&L non realizzato nella classe Position
, che aggiorniamo correttamente ad ogni tick. Anche se questo processo risulta più oneroso dal punto di vista computazionale, otteniamo una curva di equity più utile e realistica. Descriveremo questa funzionalità in una prossima lezione!
Conclusione
Dopo avere introdotto le funzionalità per gestire il trading su coppie di valute multiple, la prossima funzionalità che svilupperemo per TQforex sarà la possibilità di effettuare il backtesting su dati relativi a più giorni. Attualmente l’oggetto HistoricCSVPriceHandler
carica solo il valore di un singolo giorno di dati tick DukasCopy per ogni coppia di valute specificata.
Per gestire backtest su periodi più lunghi, caricheremo e trasmetteremo sequenzialmente un giorno di dati alla volta, evitando così di saturare la RAM con l’intera cronologia dei dati dei tick. Questo richiederà una modifica al funzionamento del metodo stream_next_tick
. Quando completeremo questa modifica, potremo backtestare strategie a lungo termine su più coppie di valute.
Inoltre, miglioreremo l’output della curva di equity. Per calcolare le principali metriche di performance, come lo Sharpe Ratio, dovremo calcolare i rendimenti percentuali su periodi di tempo definiti. Tuttavia, questo richiede di raggruppare i dati tick in barre temporali per determinare il rendimento su ciascun periodo.
Effettueremo il raggruppamento su una frequenza di campionamento simile alla frequenza di negoziazione della strategia; in caso contrario, lo Sharpe Ratio non rifletterà accuratamente il rischio/rendimento. Questo raggruppamento richiederà particolare attenzione, perché implica diversi presupposti per determinare il “prezzo” di ogni campione.
Una volta concluse queste due attività e raccolti dati sufficienti, potremo eseguire il backtest di una vasta gamma di strategie forex basate su dati tick, producendo curve di equity realistiche al netto della maggior parte dei costi di transazione. Inoltre, sarà estremamente semplice testare queste strategie sul conto di paper trading fornito da OANDA.
Così, potremo prendere decisioni più consapevoli sull’esecuzione di una strategia, rispetto ai test condotti con sistemi di backtesting più “orientati alla ricerca”.
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.”