Artificial Intelligence and Machine Learning for Foreign Exchange (Fx) Trading Part 2 — Extending Hello World

ml bull
7 min readMay 16, 2023

--

This series of articles is dedicated to understanding AI/ML and how it relates to Fx trading. Most articles focus on predicting a price and are almost useless when it comes to finding profitable trading strategies and hence, that’s the focus here.

Introduction

Last article we did the “hello world” example and created a pretty useless algorithm to trade Fx. This week we will look two major failings in that algorithm:

- Unbalanced Dataset
- Measuring Success

Disclaimer

This is in no way financial advice and does not advocate for any specific trading strategy but instead is designed to help understand some of the details of the Fx market and how to apply ML techniques to it.

Last Week

The github has the complete code and first two sections are the same as last week (importing data from github and charting that data). Note we use the same repository but a different google colab project.

#
# IMPORT DATA From github
#

import pandas as pd
from datetime import datetime

url = 'https://raw.githubusercontent.com/the-ml-bull/02-Building_on_hello_world/main/Fx60.csv'
dateparse = lambda x: datetime.strptime(x, '%d/%m/%Y %H:%M')

df = pd.read_csv(url, parse_dates=['date'], date_parser=dateparse)

df.head(n=10)
#
# Chart data
#

!pip install mplfinance
import mplfinance as mpf

# create DF and copy values from main df
mpf_df = pd.DataFrame()
mpf_df[['date', 'Open', 'High', 'Low', 'Close', 'Volume']] = df[['date', 'audusd_open', 'audusd_high', 'audusd_low', 'audusd_close', 'audusd_volume']].to_numpy()

# set index to datetime index date
mpf_df['date'] = pd.to_datetime(mpf_df['date'])
mpf_df = mpf_df.set_index('date')

# set OHLC as float
mpf_df = mpf_df[['Open', 'High', 'Low', 'Close', 'Volume']].astype(float)

# Chart
#mpf.plot(mpf_df, volume=True, datetime_format='%Y', type='line')

from matplotlib.ticker import FormatStrFormatter
fig, axlist = mpf.plot(mpf_df['2022-12-01':'2022-12-02'], volume=True, datetime_format='%Y-%m-%d %H:%M', type='candle', returnfig=True)
axlist[0]

The next section (Create Time Shifted Data) we will add a line of code at the end to see how many 1’s and 0’s are in our result or Y dataset. We have 5640 1’s and 44894 0’s so 5640 / (5640 + 44894) = 11.2% of our data is a 1. This is the definition of an unbalanced dataset. Its important to review this in any dataset asit does skew the results (make the decision boundary far to low — next week)

#
# Create time shifted data as basis for model
#

import numpy as np

df = df[['date', 'audusd_open', 'audusd_close']].copy()

# x is the last 4 values so create x for each
df['x_t-4'] = df['audusd_close'].shift(4)
df['x_t-3'] = df['audusd_close'].shift(3)
df['x_t-2'] = df['audusd_close'].shift(2)
df['x_t-1'] = df['audusd_close'].shift(1)

# y is points 4 periods into the future - the open price now (not close)
df['y_future'] = df['audusd_close'].shift(-3)
df['y_change_price'] = df['y_future'] - df['audusd_open']
df['y_change_points'] = df['y_change_price'] * 100000
df['y'] = np.where(df['y_change_points'] >= 200, 1, 0)

print(df.head(n=10))

print('\nnum of 1s {} and 0s {}'.format(df['y'].sum(), len(df)-df['y'].sum()))

We still have to create our Train and Val datasets so lets go ahead with that. Note its slightly different this week with the y_points variable being introduced that we will use later. It tracks the actual points moved and not just a 1 or 0.

#
# Create Train and Val datasets
#
from sklearn.linear_model import LogisticRegression

x = df[['x_t-4', 'x_t-3', 'x_t-2', 'x_t-1']]
y = df['y']
y_points = df['y_change_points'] # we will use this later

# Note Fx "follows" (time series) so randomization is NOT a good idea
# create train and val datasets.
no_train_samples = int(len(x) * 0.7)
x_train = x[4:no_train_samples]
y_train = y[4:no_train_samples]
y_train_change_points = y_points[4:no_train_samples]

x_val = x[no_train_samples:-3]
y_val = y[no_train_samples:-3]
y_val_change_points = y_points[no_train_samples:]

Unbalanced Dataset

When Logistic Regress runs and fits the data to a curve it moves its weights based upon the y variable being a 1 or 0 and assigns equal importance to a 1 or 0. However, since we have vastly more 0’s it will balance towards that and is unlikely to make any predictions (which is what we saw in the last article).

We can correct this a number of ways:

- Sample Weights: Assign each sample (each row) a separate weight assigning the 1’s a higher value so the weights shift move heavily when it finds a 1.

- Shift decision boundary: We will look more into this in another article but logistic regression makes a probability predictions and, by default > 50% is a 1 and < 50% is a 0. We can shift that 50% threshold to a lower value to adjust for the unbalanced data.

- Class Weights: What we will focus on here and a nice easy way to rebalance things. It just adjusts the importance higher if its sees a 1 or lower if its sees a 0.

Luckily SciKit has some easy libraries to calculate this for us but the math isn't complex if you want to do it yourself (many articles describe this in detail). Note the Fit function expects the weights in dictionary format for each class. We have two classes (1 and 0) but in future articles we will use multiple classes.

#
# Create class weights
#
from sklearn.utils.class_weight import compute_class_weight

num_ones = np.sum(y_train)
num_zeros = len(y_train) - num_ones
print('In the training set we have 0s {} ({:.2f}%), 1s {} ({:.2f}%)'.format(num_zeros, num_zeros/len(df)*100, num_ones, num_ones/len(df)*100))

classes = np.unique(y_train)
class_weight = compute_class_weight(class_weight='balanced', classes=classes, y=y_train)
class_weight = dict(zip(classes, class_weight))

print('class weights {}'.format(class_weight))

Note we are doing the calculation on the Training dataset only. We train on this dataset so it makes sense to caclulate the class weights from it. If you use the entire data frame (Train + Val) and they arent in balance you may have very mixed results.

You can see we will reduce the importance of a 0 to 0.56:1 and increate the importance of a 1 to 4.70:1. This should balance our datasets and get the decision boundary closer to where we need it. So lets now train our model as we did last week but notice the class weight parameter

#
# fit the model
#
lr = LogisticRegression(verbose=1, class_weight=class_weight)
lr.fit(x_train, y_train)

Measurement

Now the model is fitted we can see how it compares to last week but running the same measurement criteria

#
# Simple error and success measurement
#
from sklearn.metrics import log_loss, confusion_matrix, precision_score, recall_score, f1_score

# predict from teh val set meas we have predictions and true values as binaries
y_pred = lr.predict(x_val)

#basic error types
log_loss_error = log_loss(y_val, y_pred)
score = lr.score(x_val, y_val)
tn, fp, fn, tp = confusion_matrix(y_val, y_pred).ravel()
precision = precision_score(y_val, y_pred, zero_division=0)
recall = recall_score(y_val, y_pred, zero_division=0)
f1 = f1_score(y_val, y_pred, zero_division=0)

# output the errors
print('Errors Loss: {:.4f}'.format(log_loss_error))
print('Errors Score: {:.2f}%'.format(score*100))
print('Errors tp: {} ({:.2f}%)'.format(tp, tp/len(y_val)*100))
print('Errors fp: {} ({:.2f}%)'.format(fp, fp/len(y_val)*100))
print('Errors tn: {} ({:.2f}%)'.format(tn, tn/len(y_val)*100))
print('Errors fn: {} ({:.2f}%)'.format(fn, fn/len(y_val)*100))
print('Errors Precision: {:.2f}%'.format(precision*100))
print('Errors Recall: {:.2f}%'.format(recall*100))
print('Errors F1: {:.2f}'.format(f1*100))

You can see our loss has increased and score reduced (not as good) but we are now predicting some events and event getting some of them right. The critical variables are the True Positives (we predict and get it right — make money) and False Positives (we predict and get it wrong — loose money), but that’s not strictly true.

The TP/FP is measured upon going over our “sudden movement” threshold of 200 points. However, we only need the actual movement to be > 0 to make money. To adjust for that we have to change our calculations for TP to be “if we predict it, and the actual value > 0.” We can do this using the y_pred variable and y_change_points variable we created earlier.

#
# Customized metrics
#
tp = np.where((y_pred == 1) & (y_val_change_points >= 0), 1, 0).sum()
fp = np.where((y_pred == 1) & (y_val_change_points < 0), 1, 0).sum()
tn = np.where((y_pred == 0) & (y_val_change_points < 0), 1, 0).sum()
fn = np.where((y_pred == 0) & (y_val_change_points >= 0), 1, 0).sum()

precision = 0
if (tp + fp) > 0:
precision = tp / (tp + fp)

recall = 0
if (tp + fn) > 0:
recall = tp / (tp + fn)

f1 = 0
if (precision + recall) > 0:
f1 = 2 * precision * recall / (precision + recall)

# output the errors
print('Errors Loss: {:.4f}'.format(log_loss_error))
print('Errors Score: {:.2f}%'.format(score*100))
print('Errors tp: {} ({:.2f}%)'.format(tp, tp/len(y_val)*100))
print('Errors fp: {} ({:.2f}%)'.format(fp, fp/len(y_val)*100))
print('Errors tn: {} ({:.2f}%)'.format(tn, tn/len(y_val)*100))
print('Errors fn: {} ({:.2f}%)'.format(fn, fn/len(y_val)*100))
print('Errors Precision: {:.2f}%'.format(precision*100))
print('Errors Recall: {:.2f}%'.format(recall*100))
print('Errors F1: {:.2f}'.format(f1*100))

The comparison now shows a healthier regime. Note, its still not quiet right and we will make another adjustment to this in another article. However, as an intermediate step this shows a precision of almost 52% on over 4.5K predictions. i.e.. we win more than we loose but is this enough? Well, no, this system will almost certainly loose money.

  • The numbers are statistically not significant (more on this in another article). Meaning we can’t confidently say we beat guessing (50% precision).
  • Your broker takes a commission with each trade. Either in the form of fees, some type of commission or from the the gap in the spread (difference between Bid and Ask). It can be significant in some scenarios (especially during high volatility)
  • These numbers don’t take into account an accurate trade scenario (Take Profit and Stop Loss over multiple periods) so still plenty of errors and room for improvement. We can adjust for this in another article but what we are looking to do here is identify systems that “look promising” so we can then optimize them but with a few tweaks we will be there.

Summary

So we corrected for the class imbalance and make some measurement changes and now we are in good shape to start to explore the features we are using and the effect of normalization.

Next article will lift the lid on what’s happening depths of the Logistic Regression algorithm before we look at normalization and new features we can introduce.

Links and References

Previous Article:
https://medium.com/@the.ml.ai.bull/artificial-intelligence-and-machine-learning-for-foreign-exchange-fx-trading-f1e3c3efef78
github:
https://github.com/the-ml-bull/hello_world
twitter:
@the_ml_bull
youtube:
https://youtu.be/ceXPQLAcByU

--

--