Forex Trading Diary #5 - Trading Multiple Currency Pairs

Forex Trading Diary #5 - Trading Multiple Currency Pairs

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 and Portfolio 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 and Portfolio calculate opens, closes, additions and removals of units. The Position object now carries out the "heavy lifting" leaving a relatively lean Portfolio 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:

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_resultsmethods 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.