Yesterday I published some important changes to the QSForex software. These changes have increased the usefulness of the system significantly to the point where it is nearly ready for multi-day tick-data backtesting over a range of currency pairs.
The following changes have been posted to Github:
- Further modification to both the
Position
andPortfolio
objects in order to allow multiple currency pairs to be traded as well as currencies that are not denominated in the account currency. Hence a GBP-deonominated account can now trade EUR/USD, for instance. - Complete overhaul of how the
Position
andPortfolio
calculate opens, closes, additions and removals of units. ThePosition
object now carries out the "heavy lifting" leaving a relatively leanPortfolio
object. - Addition of the first non-trivial strategy, namely the well-known Moving Average Crossover strategy with a pair of simple moving averages (SMA).
- Modification to
backtest.py
to make it single-threaded and deterministic. Despite my optimism that a multi-threaded approach wouldn't be too detrimental to simulation accuracy, I found it difficult to obtain satisfactory backtesting results with a multi-threaded approach. - Introduced a very basic Matplotlib-based output script for viewing the equity curve of the portfolio. The equity curve generation is at an early stage and still requires a lot of work.
As I mentioned in the previous entry, for those of you who are unfamiliar with QSForex and are coming to this forex diary series for the first time, I strongly suggest having a read of the following diary entries to get up to speed with the software:
- Forex Trading Diary #1 - Automated Forex Trading with the OANDA API
- Forex Trading Diary #2 - Adding a Portfolio to the OANDA Automated Trading System
- Forex Trading Diary #3 - Open Sourcing the Forex Trading System
- Forex Trading Diary #4 - Adding a Backtesting Capability
As well as the Github page for QSForex:
Multiple Currency Support
A feature that I have continually been discussing in these diary entries is the capability to support multiple currency pairs.
At this stage I've now modified the software to allow differing account denominations, since previously GBP was the hardcoded currency. It is also now possible to trade in other currency pairs, except those that consist of a base or quote in Japanese Yen (JPY). The latter is due to how tick sizes are caclulated in JPY currencies.
In order to achieve this I have modified how the profit is calculated when units are removed or the position is closed. Here is the current snippet for calculating pips, in the position.py
file:
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
If we close the position in order to realise a gain or loss, we need to use the following snippet for close_position
, also in the position.py
file:
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()
# Calculate PnL
pnl = self.calculate_pips() * qh_close * self.units
return pnl.quantize(Decimal("0.01", ROUND_HALF_DOWN))
Firstly we obtain the bid and ask prices for both the currency pair being traded as well as the "quote/home" currency pair. For instance, for an account denominated in GBP, where we are trading EUR/USD, we must obtain prices for "USD/GBP", since EUR is the base currency and USD is the quote.
At this stage we check if the position itself is a long or short position and then calculate the appropriate "remove price" and quote/home "remove price", which are given by remove_price
and qh_close
respectively.
We then update the current and average prices within the position and finally calculate the P&L by multiplying the pips, the quote/home removal price and then number of units we're closing out.
We have completely eliminated the need to discuss "exposure", which was a redundant variable. This formula then correctly provides the P&L against any (non-JPY denominated) currency pair trade.
You can view the full listing for position.py at Github.
Overhaul of Position and Portfolio Handling
In addition to the ability to trade in multiple currency pairs I've also refined how the Position
and Portfolio
"share" the responsibility of opening and closing positions, as well as adding and subtracting units.
In particular, I've moved a lot of the position-handling code that was in portfolio.py
into position.py
. This is more natural since the position should be taking care of itself and not delegating it to the portfolio!
In particular, the add_units
, remove_units
and close_position
methods have been created or enhanced:
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()
# Calculate 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()
# Calculate PnL
pnl = self.calculate_pips() * qh_close * self.units
return pnl.quantize(Decimal("0.01", ROUND_HALF_DOWN))
In the latter two you can see how the new formula for calculating profit is implemented.
A lot of the functionality of the Portfolio
class has thus been correspondingly reduced. In particular the methods add_new_position
, add_position_units
, remove_position_units
and close_position
have been modified to take account of the fact that the calculation work is being done in the Position
object:
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 essence they all (apart from add_new_position
) simply check if the position exists for that currency pair and then call the corresponding Position
method, taking account of profit if necessary.
You can view the full listing for portfolio.py at Github.
Moving Average Crossover Strategy
We've discussed the Moving Average Crossover strategy before on QuantStart, in the context of equities trading. It's a very useful test-bed indicator strategy because it is easy to replicate the calculations by hand (at least at lower frequencies!), in order to check that the backtester is behaving as it should.
The basic idea of the strategy is as follows:
- Two separate simple moving average filters are created, with varying lookback periods, of a particular time series.
- Signals to purchase the asset occur when the shorter lookback moving average exceeds the longer lookback moving average.
- If the longer average subsequently exceeds the shorter average, the asset is sold back.
The strategy works well when a time series enters a period of strong trend and then slowly reverses the trend.
The implementation is straightforward. Firstly, we provide a method calc_rolling_sma
that allows us to more efficiently utilise the previous time period SMA calculation in order to generate the new one, without having to fully recalculate the SMA at every step.
Secondly, we generate signals in two cases. In the first case we generate a signal if the short SMA exceeds the long SMA and we're not long the currency pair. In the second case we generate a signal if the long SMA exceeds the short SMA and we are already long.
I have set the default window to be 500 ticks for the short SMA and 2,000 ticks for the long SMA. Obviously in a production setting these parameters would be optimised, but they work well for our testing purposes.
class MovingAverageCrossStrategy(object):
"""
A basic Moving Average Crossover strategy that generates
two simple moving averages (SMA), with default windows
of 500 ticks for the short SMA and 2,000 ticks for the
long SMA.
The strategy is "long only" in the sense it will only
open a long position once the short SMA exceeds the long
SMA. It will close the position (by taking a corresponding
sell order) when the long SMA recrosses the short SMA.
The strategy uses a rolling SMA calculation in order to
increase efficiency by eliminating the need to call two
full moving average calculations on each 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
)
# Only start the strategy when we have created an accurate short window
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
You can view the full listing for strategy.py at Github.
Single-Threaded Backtester
Another major change was to modify the backtesting component to be single-threaded, rather than multi-threaded.
I made this change because I was having a very hard time synchronising the threads to execute in a manner that would occur in a live environment. It basically meant that the entry and exit prices were very unrealistic, often occuring (virtual) hours after the actual tick had been received.
Hence I incorporated the streaming of TickEvent
objects into the backtesting loop, as you can see in the following snippet of backtest.py
:
def backtest(
events, ticker, strategy, portfolio,
execution, heartbeat, max_iters=200000
):
"""
Carries out an infinite while loop that polls the
events queue and directs each event to either the
strategy component of the execution handler. The
loop will then pause for "heartbeat" seconds and
continue unti the maximum number of iterations is
exceeded.
"""
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()
Notice the line ticker.stream_next_tick()
. This is called prior to a polling of the events queue and as such will always guarantee that a new tick event will have arrived before the queue is polled again.
In particular it means that a signal is executed as new market data arrives, even if there is some lag in the ordering process due to slippage.
I've also set a max_iters
value that controls how long the backtesting loop continues. In practice this will need to be quite large when dealing with multiple currencies across multiple days, but I've set it to a default value that allows for a single day's data of one currency pair.
The stream_next_tick
method of the price handler class is similar to stream_to_queue
except that it calls the iterator next()
method manually, rather than carrying out the tick streaming in a for loop:
def stream_next_tick(self):
"""
The Backtester has now moved over to a single-threaded
model in order to fully reproduce results on each run.
This means that the stream_to_queue method is unable to
be used and a replacement, called stream_next_tick, is
used instead.
This method is called by the backtesting function outside
of this class and places a single tick onto the queue, as
well as updating the current bid/ask and inverse 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)
Notice that it stops upon receipt of a StopIteration
exception. This allows the code to resume rather than crashing at the exception.
Matplotlib Output
I've also created a very basic Matplotlib output script to display the equity curve. output.py
currently lives in the backtest
directory of QSForex and is given below:
import os, os.path
import pandas as pd
import matplotlib.pyplot as plt
from qsforex.settings import OUTPUT_RESULTS_DIR
if __name__ == "__main__":
"""
A simple script to plot the balance of the portfolio, or
"equity curve", as a function of time.
It requires OUTPUT_RESULTS_DIR to be set in the project
settings.
"""
equity_file = os.path.join(OUTPUT_RESULTS_DIR, "equity.csv")
equity = pd.io.parsers.read_csv(
equity_file, header=True,
names=["time", "balance"],
parse_dates=True, index_col=0
)
equity["balance"].plot()
plt.show()
Notice that there is a new settings.py
variable now called OUTPUT_RESULTS_DIR
, which must be set in your settings. I have it pointing to a temporary directory elsewhere on my file system as I don't want to accidentally add any equity backtest results to the code base!
The equity curve works by having a balance value added to a list of dictionaries, with one dictionary corresponding to a time-stamp.
Once the back-test is complete the list of dictionaries is converted into a Pandas DataFrame and the to_csv
method is used to output equity.csv
.
This output script then simply reads in the file and plots the balance
column of the subsequent DataFrame.
You can see the snippet for the append_equity_row
and output_results
methods of the Portfolio
class below:
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
Every time execute_signal
is called, the former method is called and appends the timestamp/balance value to the equity
member.
At the end of the backtest output_results
is called which simply converts the list of dictionaries to a DataFrame and then outputs to the specified OUTPUT_RESULTS_DIR
directory.
Unfortunately, this is not a particularly appropriate way of creating an equity curve as it only occurs when a signal is generated. This means that it does not take into account unrealised P&L.
While this is how actual trading occurs (you haven't actually made any money until you close a position!) it means that the equity curve will remain completely flat between balance updates. Worse, Matplotlib will default to linearly interpolating between these points, thus providing the false impression of the unrealised P&L.
The solution to this problem is to create an unrealised P&L tracker for the Position
class that correctly updates on every tick. This is a little more computationally expensive, but does allow a more useful equity curve. This feature is planned for a later date!
Next Steps
The next major task for QSForex is to allow multi-day backtesting. Currently the HistoricCSVPriceHandler
object only loads a single day's worth of DukasCopy tick data for any specified currency pairs.
In order to allow multi-day testing it will be necessary to load and stream each day sequentially to avoid filling RAM with the entire history of tick data. This will require a modification to how the stream_next_tick
method works. Once that is complete it will allow long-term strategy backtesting across multiple pairs.
Another task is to improve the output of the equity curve. In order to calculate any of the usual performance metrics (such as the Sharpe Ratio) we will need to calculate percentage returns across a particular time period. However, this requires that we bin the tick data into bars in order to calculate a return for a particular time period.
Such binning must occur on a sampling frequency that is similar to the trading frequency or the Sharpe Ratio will not be reflective of the true risk/reward of the strategy. This binning is not a trivial exercise as there are lots of assumptions that go into generating a "price" for each bin.
Once these two tasks are complete, and sufficient data has been acquired, we will be in a position to backtest a wide-range of tick-data based forex strategies and produce equity curves net of the majority of transaction costs. In addition, it will be extremely straightforward to test these strategies on the practice paper-trading account provided by OANDA.
This should allow you to make much better decisions about whether to run a strategy compared to a more "research oriented" backtesting system.