Building an AI-Powered Forex Trading Bot with Python
- Nikhil Adithyan
- 4 days ago
- 13 min read
A hands-on guide to building a bot from scratch

Introduction
What if you could build a real-time forex trading bot that reacts to the market as it moves, not after the fact?
This project walks through the creation of a live, AI-powered trading system built for active traders. Using minute-level price updates streamed via TraderMade’s WebSocket API, the bot processes incoming data in real time, calculates technical indicators on the fly, feeds those signals into a trained machine learning model, and places trades through a broker, all in one continuous loop.
The goal isn’t to backtest with static historical data. It’s to build a trading engine that runs live, powered by real-time infrastructure, actionable signals, and execution-ready logic including stop-loss and take-profit controls. If you’re looking to move from experimentation to real-world deployment, this system gives you a working foundation.
Step 1: Getting Forex Price Data Using TraderMade
To build a trading system that works in real-time, I needed two things:
Historical intraday data for training the AI model
Live streaming data for real-time signal generation and execution
TraderMade supports both, and I used them together to bridge the training and execution layers.
Using REST for Historical Intraday Data
TraderMade’s Timeseries API supports this through its minute interval, allowing you to fetch minute-level OHLC data with fine granularity, ideal for short-term, signal-based models like this one.
Here’s how I used it to extract recent GBP/CAD 1-minute candles:
import requests
import pandas as pd
api_key = 'YOUR API KEY'
url = 'https://marketdata.tradermade.com/api/v1/timeseries'
params = {
'currency': 'GBPCAD',
'start_date': '2025-05-05-09:00',
'end_date': '2025-05-06-16:00',
'interval': 'minute',
'period': '1',
'format': 'records',
'api_key': api_key
}
response = requests.get(url, params=params).json()
data = response['quotes']
df = pd.DataFrame(data)
df['date'] = pd.to_datetime(df['date'])
df.set_index('date', inplace=True)
df = df[['open', 'high', 'low', 'close']]
df.head()
This gave me a clean, structured dataset of minute-by-minute candles, built by looping through the historical API. It’s ideal for training the AI model on short-term market behavior.

Using WebSocket for Real-Time Streaming
Once the model is trained, it needs to operate in a live environment, and that’s where the WebSocket feed comes in. TraderMade’s WebSocket API delivers real-time forex tick data (bid/ask/mid prices) with minimal latency.
To connect:
import websocket
import json
def on_message(ws, message):
data = json.loads(message)
print(data)
def on_open(ws):
payload = {
"userKey": "YOUR_API_KEY",
"symbol": "GBPCAD"
}
ws.send(json.dumps(payload))
ws = websocket.WebSocketApp(
"wss://marketdata.tradermade.com/feedadv",
on_message=on_message,
on_open=on_open
)
ws.run_forever()
Each incoming message contains a real-time price update. These ticks can be used to build 1-minute candles on the fly:
Start a timer (e.g., 60 seconds)
Track the first tick (Open), highest tick (High), lowest tick (Low), and last tick (Close)
At the end of 60 seconds, emit a candle and start building the next one
This gives you a rolling, up-to-date price feed that mirrors real market movement.
Why Use Both?
The Minutes Historical endpoint helps build precise training data
The WebSocket API lets the bot run live, stream candles, and respond to new prices instantly
Together, they bridge model development and production using clean, high-resolution intraday data both offline and online.
Step 2: Calculating Technical Indicators in Real Time
Raw price data doesn’t offer much on its own. For the model to detect patterns and make predictions, it needs a structured view of market behavior. Technical indicators help convert short-term price action into meaningful signals like momentum, volatility, and trend direction.
In this bot, I used a handful of well-known indicators:
Relative Strength Index (RSI) to measure momentum
Simple Moving Averages (SMA) to capture trend direction
Bollinger Bands to gauge volatility and price deviation
These indicators are recalculated every time a new candle is formed. Whether it’s during model training or live execution, they form the feature set that feeds into the AI model.
Recalculating Indicators on Minute-Level Candles
Using the ta library, I computed the indicators over a rolling window of 1-minute candles, both during training and in the real-time loop.
import ta
def calculate_indicators(df):
df = df.copy()
df['rsi'] = ta.momentum.RSIIndicator(close=df['close'], window=14).rsi()
df['sma_20'] = ta.trend.SMAIndicator(close=df['close'], window=20).sma_indicator()
df['sma_50'] = ta.trend.SMAIndicator(close=df['close'], window=50).sma_indicator()
bb = ta.volatility.BollingerBands(close=df['close'], window=20, window_dev=2)
df['bb_upper'] = bb.bollinger_hband()
df['bb_lower'] = bb.bollinger_lband()
return df
These indicators are updated as soon as a new 1-minute bar is built, giving the model an up-to-date snapshot of the market at every step.
What Each Indicator Brings to the Table
RSI (14-period): Identifies whether the pair is overbought or oversold
SMA 20 & SMA 50: Show short-term and medium-term trend direction
Bollinger Bands: Reflect recent volatility and how far price is stretched from the average
Together, these indicators form a compact feature vector that describes the state of the market at any given moment.
Handling Missing Values
Indicators like RSI or SMA require a minimum number of data points to compute. That means the first few rows of the dataset may contain NaN values. These are dropped before feeding the data into the model.
df = calculate_indicators(df)
df.dropna(inplace=True)
Output:

Step 3: Labeling the Data
Before training a model, it needs a clear target to predict. In this case, that target is short-term price direction, whether the market is likely to move up, down, or stay neutral in the next few minutes.
To create labels, I calculated the return over a short lookahead window and assigned a class based on the magnitude and direction of the price change.
Labeling Logic
For this system, I used a 5-minute lookahead and a directional threshold of 0.02 percent. The logic is:
Buy (1) if the price increases by more than 0.02 percent in the next 5 minutes
Sell (-1) if the price drops by more than 0.02 percent
Hold (0) if the change stays within that range
This range filters out noise while still capturing meaningful intraday movement.
lookahead = 5 # 5 minutes
threshold = 0.0002 # ~0.02%
df['future_return'] = (df['close'].shift(-lookahead) - df['close']) / df['close']
def label_direction(x):
if x > threshold:
return 1
elif x < -threshold:
return -1
else:
return 0
df['signal'] = df['future_return'].apply(label_direction)
Output:

This transforms the dataset into a classification problem. Each row now includes a label indicating what direction the price is expected to move within the next few minutes.
Final Cleanup
Since the last few rows won’t have a defined future return due to the lookahead window, they are dropped before training.
df.dropna(inplace=True)
At this point, every row in the dataset includes:
Technical indicators (from Step 2)
A directional label for the next 5 minutes
This completes the setup for supervised learning. The next step is to train a model that can take these indicators as input and predict the signal class.
Step 4: Training the AI Model Using Random Forest
With the features and labels ready, it’s time to train the AI model that powers the bot’s decision engine.
The goal is to classify each moment in the market as a buy, sell, or hold signal based on the real-time behavior of technical indicators. For this, I used the Random Forest algorithm, a tried-and-tested model that works well for structured, tabular data like ours.
Why Random Forest?
Random Forest is an ensemble learning method that combines multiple decision trees to improve prediction accuracy and reduce overfitting. Each tree is trained on a random subset of the data and features, and the final prediction is made by aggregating the outputs of all trees (typically by majority vote in classification problems).
It has a few advantages for this use case:
Works well out of the box without heavy tuning
Handles non-linear patterns, useful when market indicators don’t follow simple relationships
Can rank feature importance, helping interpret what the model is actually learning
Splitting the Data
To evaluate how well the model performs, I split the dataset into training and test sets. I used the last 20% of the data for testing, this simulates a real-world scenario where the model is trained on past data and then applied to future, unseen conditions.
from sklearn.model_selection import train_test_split
feature_cols = ['rsi', 'sma_20', 'sma_50', 'bb_upper', 'bb_lower']
X = df[feature_cols]
y = df['signal']
split_index = int(len(df) * 0.8)
X_train, X_test = X[:split_index], X[split_index:]
y_train, y_test = y[:split_index], y[split_index:]
Notice that I didn’t shuffle the data. That’s intentional. In trading, data is time-series — shuffling would leak future data into the past, which would make the test results unreliable.
Training the Model
Next, I trained the Random Forest model using 100 trees. More trees generally improve stability and reduce variance. I also set a fixed random_state to make results reproducible.
from sklearn.ensemble import RandomForestClassifier
model = RandomForestClassifier(
n_estimators=100,
max_depth=5,
random_state=42
)
model.fit(X_train, y_train)
I limited the max_depth to 5 to avoid the model overfitting on small patterns that won’t generalize. This creates a more conservative model — one that’s less likely to flip-flop on minor indicator noise.
Evaluating Model Accuracy
Once trained, I evaluated how well the model performed on the unseen test set. The metric used here is accuracy, how often the model correctly predicted the direction.
from sklearn.metrics import accuracy_score, classification_report
y_pred = model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f'Model Accuracy: {accuracy:.2%}')
print(classification_report(y_test, y_pred, zero_division=0))
Here’s what the model produced on the test data:
Overall Accuracy: 30.47%
Sell Class (-1): Precision = 0.16, Recall = 0.32
Hold Class (0): Precision = 0.46, Recall = 0.46
Buy Class (1): Precision = 0.00, Recall = 0.00
The performance is clearly unsatisfactory. The model completely failed to detect Buy signals and struggled even with Hold predictions, capturing less than half correctly. While the Sell class had slightly better recall, the precision was too low to be trusted.
This kind of imbalance is common when one class dominates the dataset or when the indicators fail to offer distinct separation across target labels. In its current form, the model is not suitable for live execution. Structural tuning is essential before it can be considered for deployment.
Step 5: Improving Model Performance
The initial evaluation of the model on GBPCAD data revealed significant shortcomings. It failed to recognize any Buy signals, struggled with Hold predictions, and showed inconsistent behavior on Sell signals. The overall accuracy of just 30.47% confirmed that the model wasn’t capturing meaningful patterns and was far from reliable for real-time execution.
What Went Wrong
A closer look pointed to a few critical issues:
Severe class imbalance: The training data was dominated by Hold signals, encouraging the model to default to that class at the expense of Buy and Sell predictions.
Lack of weighted penalties: Without class weighting, the model treated all errors equally, ignoring the cost of misclassifying the rarer but more valuable directional signals.
Short lookahead window: A 5-minute lookahead didn’t provide enough separation between classes, making the labels noisy and the prediction task ambiguous.
To address these problems, I made three focused and justifiable changes, each aimed at improving label clarity, class balance, and the model’s ability to generalize across directional setups.
Change 1: Extending Lookahead to 15 Minutes
Why this matters:
Short prediction windows (like 5 minutes) tend to capture market noise rather than real direction. This makes it hard for any model to find consistent patterns that correlate with a future move.
What this fixes:
A 15-minute lookahead gives each signal more room to “prove itself”, making price moves more distinguishable and less sensitive to micro-fluctuations.
Implementation:
lookahead = 15 # 15 minutes
threshold = 0.0002 # ~0.02%
df['future_return'] = (df['close'].shift(-lookahead) - df['close']) / df['close']
df['signal'] = df['future_return'].apply(label_direction)
Change 2: Balancing the Dataset
Why this matters:
Even after tweaking the lookahead, class distribution often remains skewed — especially in forex where sideways price action is common. If the model sees mostly Hold examples, it learns to avoid risk by always predicting Hold.
What this fixes:
By manually undersampling the Hold class, we ensure the training set is not dominated by one behavior. This helps the model give equal attention to directional moves.
Implementation:
# Create separate DataFrames for each class
df_hold = df[df['signal'] == 0]
df_buy = df[df['signal'] == 1]
df_sell = df[df['signal'] == -1]
# Undersample Hold to match Buy/Sell size
min_class_size = min(len(df_buy), len(df_sell))
df_hold_sampled = df_hold.sample(min_class_size, random_state=42)
# Combine and shuffle
df_balanced = pd.concat([df_buy, df_sell, df_hold_sampled]).sample(frac=1, random_state=42)
df_balanced
Now the dataset is structured in a way that gives each class a fair chance to be learned.
Change 3: Applying Balanced Class Weights in the Model
Why this matters:
Even in a manually balanced dataset, real-world deployment might still encounter class skew. Adding class_weight='balanced' ensures the model penalizes mistakes on underrepresented classes more heavily.
What this fixes:
This forces the model to take Buy and Sell signals seriously, and not just optimize for Hold accuracy.
Implementation:
from sklearn.model_selection import train_test_split
# Data split with balanced dataframe
feature_cols = ['rsi', 'sma_20', 'sma_50', 'bb_upper', 'bb_lower']
X = df_balanced[feature_cols]
y = df_balanced['signal']
split_index = int(len(df_balanced) * 0.8)
X_train, X_test = X[:split_index], X[split_index:]
y_train, y_test = y[:split_index], y[split_index:]
# Applying balanced class weights
from sklearn.ensemble import RandomForestClassifier
model = RandomForestClassifier(
n_estimators=100,
max_depth=5,
class_weight='balanced',
random_state=42
)
model.fit(X_train, y_train)
This change improves the model’s sensitivity to directional classes, while still keeping Hold in the loop.
Evaluation After Optimization
After applying all three changes, the model was retrained and evaluated using the same test set approach.
from sklearn.metrics import accuracy_score, classification_report
y_pred = model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f'Model Accuracy: {accuracy:.2%}')
print(classification_report(y_test, y_pred, zero_division=0))
Classification report:

This is a significant improvement over the earlier model. All three classes are being predicted reliably, with reasonably balanced precision and recall.
The model no longer collapses into predicting Hold only
It can distinguish upward, downward, and sideways market phases
Accuracy is meaningful, not inflated by imbalance
Feature Importance
A final look at feature importance confirms that the model isn’t just guessing, it’s leaning on trend and volatility signals in a structured way.
import matplotlib.pyplot as plt
importances = model.feature_importances_
feature_ranks = pd.Series(importances, index=feature_cols)
feature_ranks.sort_values().plot(kind='barh', title='Feature Importance')
plt.show()

SMA 50 stood out as the most influential feature, reinforcing the idea that medium-term trends are the strongest signal for directional movement.
Bollinger Bands, especially the lower band, ranked next in importance, highlighting their value in detecting price extremes and volatility compression.
SMA 20 and Bollinger Upper Band followed closely, both contributing meaningfully to the trend and volatility profile.
RSI had the least impact, indicating that short-term momentum alone wasn’t as useful for classifying price direction in this setup.
Overall, this distribution suggests the model is learning from structurally reliable indicators rather than random noise, focusing on trend and volatility patterns over short-term oscillators.
Step 6: Executing Trades via OANDA’s API
Now that the model is trained and tuned to perform well across all signal types, it’s time to bring everything to life. This final stage is where predictions translate into real trades, automatically, reliably, and with risk controls in place.
To make this work in a real-world setting, the execution engine needs to do a few things really well:
Receive live price updates without delay
Keep updating technical indicators in sync with market movement
Use the trained model to make predictions in real time
Place trades through a broker API, while managing risk with stop-loss and take-profit
Prevent trade stacking or conflicts by maintaining open position checks
This final step will show you how to do exactly that, using TraderMade’s WebSocket stream and OANDA’s paper trading API.
Receiving Live Price Data with WebSocket
Instead of polling for prices using the REST API every minute, we use TraderMade’s WebSocket stream to receive data in real time. This stream gives us a continuous feed of the latest price ticks.
import threading
from datetime import datetime
price_buffer = []
def on_message(ws, message):
global price_buffer
data = json.loads(message)
timestamp = datetime.utcnow()
mid_price = data['mid']
price_buffer.append({'timestamp': timestamp, 'close': mid_price})
if len(price_buffer) > 100:
price_buffer = price_buffer[-100:]
def on_open(ws):
payload = {
"userKey": "YOUR_TRADERMADE_API_KEY",
"symbol": "GBPCAD"
}
ws.send(json.dumps(payload))
def start_stream():
ws = websocket.WebSocketApp("wss://marketdata.tradermade.com/feedadv",
on_message=on_message,
on_open=on_open)
thread = threading.Thread(target=ws.run_forever)
thread.daemon = True
thread.start()
start_stream() # sample run
This function listens to price updates and maintains a rolling buffer of the most recent 100 ticks. The buffer is used to emulate a 1-minute candle dataset, which is necessary for updating technical indicators and generating features for the model.
The reason we use a rolling buffer rather than saving to disk or waiting for exact time intervals is to ensure speed. In live trading, decisions often need to be made quickly and continuously, not just once per candle close.
Updating Indicators and Making Predictions
As the buffer fills, we can convert it into a DataFrame and compute the latest indicators. These are the same indicators the model was trained on, so this step ensures consistency between training and live prediction.
def update_indicators(df):
df['rsi'] = ta.momentum.RSIIndicator(close=df['close'], window=14).rsi()
df['sma_20'] = ta.trend.SMAIndicator(close=df['close'], window=20).sma_indicator()
df['sma_50'] = ta.trend.SMAIndicator(close=df['close'], window=50).sma_indicator()
bb = ta.volatility.BollingerBands(close=df['close'], window=20, window_dev=2)
df['bb_upper'] = bb.bollinger_hband()
df['bb_lower'] = bb.bollinger_lband()
return df.dropna()
We recompute these indicators using the most recent price data. The .dropna() at the end ensures that the model never receives partially filled rows, which can happen if we don't yet have enough data for a given indicator to calculate (like a 50-period SMA on only 40 prices).
Once the indicators are updated, we use the trained model to generate a signal.
def predict_signal(df):
features = df.iloc[-1][['rsi', 'sma_20', 'sma_50', 'bb_upper', 'bb_lower']]
return model.predict([features])[0]
This prediction will return one of three values: 1 for Buy, -1 for Sell, and 0 for Hold. It uses only the most recent row, which is the current market snapshot, to make the call.
Placing Orders with Stop-Loss and Take-Profit
Next, we move to execution. This is where the prediction turns into a trade. But instead of sending bare market orders, we add both a stop-loss and a take-profit level to manage risk.
import requests
OANDA_API_KEY = 'YOUR_OANDA_API_KEY'
OANDA_ACCOUNT_ID = 'YOUR_OANDA_ACCOUNT_ID'
OANDA_URL = 'https://api-fxpractice.oanda.com/v3'
headers = {
'Authorization': f'Bearer {OANDA_API_KEY}',
'Content-Type': 'application/json'
}
def place_order(units, sl_price, tp_price, instrument='GBP_CAD'):
url = f'{OANDA_URL}/accounts/{OANDA_ACCOUNT_ID}/orders'
order_data = {
"order": {
"instrument": instrument,
"units": str(units),
"type": "MARKET",
"timeInForce": "FOK",
"positionFill": "DEFAULT",
"stopLossOnFill": {"price": str(sl_price)},
"takeProfitOnFill": {"price": str(tp_price)}
}
}
response = requests.post(url, headers=headers, json=order_data)
return response.json()
Every order placed includes a stopLossOnFill and takeProfitOnFill, which are set dynamically based on current price. This ensures each trade has a defined exit strategy from the moment it is placed.
We calculate those levels here:
def calculate_sl_tp(price, direction, sl_pips=0.0010, tp_pips=0.0015):
if direction == 1:
return price - sl_pips, price + tp_pips
elif direction == -1:
return price + sl_pips, price - tp_pips
return None, None
This function defines a stop-loss of 10 pips and a take-profit of 15 pips. These values can be adjusted based on volatility or strategy goals, but the structure ensures that every trade has both a floor and a ceiling.
Preventing Overlapping Positions
Before placing any new order, we check if there’s already an open trade. This prevents situations where multiple signals stack on top of each other and lead to overexposure.
def get_open_positions():
url = f'{OANDA_URL}/accounts/{OANDA_ACCOUNT_ID}/openPositions'
response = requests.get(url, headers=headers)
return response.json().get('positions', [])
We use OANDA’s /openPositions endpoint to determine if the bot already has an active trade. If it does, we skip placing a new order.
This logic is combined in the trade execution handler:
def execute_trade(signal, current_price):
positions = get_open_positions()
if positions:
print("Already in a trade. Waiting...")
return
sl, tp = calculate_sl_tp(current_price, signal)
if signal == 1:
place_order(units=1000, sl_price=sl, tp_price=tp)
print("Buy order placed.")
elif signal == -1:
place_order(units=-1000, sl_price=sl, tp_price=tp)
print("Sell order placed.")
else:
print("Hold signal. No action.")
This structure ensures that the bot is cautious by default. It doesn’t overtrade and doesn’t flip-flop between signals without first closing its current position.
Running the Trading Bot
To tie everything together, the bot runs continuously in a loop. Each cycle checks the price buffer, updates indicators, makes a prediction, and places a trade if conditions are met.
import time
while True:
if len(price_buffer) >= 30:
df_live = pd.DataFrame(price_buffer)
df_live = update_indicators(df_live)
if not df_live.empty:
signal = predict_signal(df_live)
current_price = df_live.iloc[-1]['close']
execute_trade(signal, current_price)
time.sleep(60) # Run every 1 minute
Conclusion
This project walked through the complete pipeline of building a real-time AI-powered forex trading bot, from streaming price data and generating technical indicators, to training a machine learning model and executing trades live via OANDA’s API. With TraderMade’s reliable timeseries data and WebSocket feed, the system stays in sync with the market, reacting to new information as it arrives.
That said, this is just the beginning. The current setup works with a Random Forest model and basic indicators, but there’s plenty of room to evolve. You could experiment with more sophisticated ML models, use cross-pair signals, layer in volatility-based position sizing, or even introduce reinforcement learning. On the execution side, integrating advanced order types, trailing stops, or portfolio-level logic would push this closer to a production-grade system.
For anyone looking to move beyond backtests and into the live trading space, this framework offers a real foundation. Thanks to platforms like TraderMade and OANDA, the infrastructure is no longer the bottleneck; your ideas are. With that being said, you’ve reached the end of the article. Hope you learned something new and useful.