Building an AI Trading Bot - Part 4: How Your AI Model Reads the Market
In Part 3, you learned every line of bot.py. You understand the loop, the error handling, the prediction placeholder. But the bot has a problem: it feeds a single number (price) into predict(). No AI model can make useful predictions from one number. Models need features - multiple data points that describe the market from different angles.
Today we fix that. You’ll build a feature pipeline that fetches 100 hourly candles from Binance and transforms them into 11 features your model can learn from. When Part 5 arrives with the trained model, this pipeline will already be running, already stable, already producing exactly the right input format.
What you need: Your VPS from Part 2, or any computer with Python. The code runs anywhere ccxt can reach Binance. Total time: about 20 minutes if you follow along and run the commands.
Why One Number Is Not Enough
Your current bot fetches ticker["last"] and calls it a day. That’s the price right now. Here’s what the model doesn’t know:
- Was the price $63,000 five minutes ago and $64,110 now? That’s upward momentum.
- Was it $64,110 five minutes ago and still $64,110? That’s consolidation.
- Is volume 3x normal right now? That’s conviction behind the move.
- Is the RSI at 78? That’s overbought territory, regardless of price level.
Same price. Four completely different market conditions. A model that sees only the price has no way to distinguish them. It will treat a breakout and a flatline identically. That’s not AI. That’s a coin flip.
Features are the difference between a model that guesses and a model that learns patterns. They convert raw market data into numbers that capture what changed, how fast, and whether it matters.
A feature vector is just an array of numbers:
[0.0006, -0.0012, 0.0184, 64080, 63750, 1.005, 0.0003, 148.7, 0.83, 0.0020, 52.4]
Each number describes one aspect of the market at one point in time. The model’s job is to learn which combinations of these numbers predict future price movement - and with what probability.
Step 1: Pull the Updated Code
The repo has been updated with the feature pipeline. On your VPS (or local machine if you’re not using a VPS yet):
cd ~/ai-trading-bot
git pull origin main
You’ll see a new file: features.py. Open it. Don’t worry about understanding everything at once. This post walks through each section.
cat features.py
You’ll also notice bot.py has changed. It now imports get_latest_features from features.py and passes real feature data into predict(). The main loop structure from Part 3 is unchanged. The bot still fetches, predicts, prints, waits. Only the data going into predict() has become richer.
Step 2: Install the New Dependencies
The feature pipeline uses two new Python libraries:
pip install pandas numpy --break-system-packages
Or update from the updated requirements.txt:
pip install -r requirements.txt --break-system-packages
pandas is the standard library for working with structured data in Python. Think of it as Excel inside your code. You load data into a DataFrame (like a spreadsheet with rows and columns), filter it, transform it, extract subsets. Every quant, every data scientist, every algorithmic trader uses pandas. When you see df["close"].rolling(10).mean() later, that’s pandas computing a 10-period moving average across every row simultaneously.
numpy handles numerical operations under the hood. Pandas uses numpy arrays internally. You won’t call numpy directly in this pipeline, but pandas depends on it for vectorized math - running the same calculation on thousands of data points in one operation instead of looping through them one at a time.
Step 3: Test the Feature Pipeline
Before we walk through the code, run it and see what it produces:
python3 features.py
You should see output like this:
Fetching 100 1h candles for BTC/USDT...
Shape: 100 rows x 17 columns
Columns: ['open', 'high', 'low', 'close', 'volume', 'returns_1h', ...]
Most recent candle (2026-08-25 14:00:00+00:00):
open : 64110.000000
high : 64180.000000
low : 64050.000000
close : 64150.000000
volume : 123.450000
returns_1h : 0.000624
returns_4h : -0.001230
returns_24h : 0.018400
sma_10 : 64080.200000
sma_50 : 63750.800000
sma_ratio : 1.005170
volatility_24h : 0.000312
volume_sma_10 : 148.700000
volume_ratio : 0.830000
high_low_ratio : 0.002028
rsi_14 : 52.400000
Seventeen columns. Six are raw market data (the OHLCV candles plus the timestamp). Eleven are features derived from that raw data. The model in Part 5 will use those 11 numbers - the feature vector - as its input.
One hundred rows means 100 hours of market history. Each row is one 1-hour candle with all 17 values computed. The model doesn’t need all 100 rows for a single prediction. It needs the latest row - the feature vector describing the market right now. But to compute that vector, you need history. You can’t compute a 50-period moving average from one candle. You need 50 candles before the average means anything.
How the Feature Pipeline Works
Open features.py in sections. We’ll walk through each function the way we walked through bot.py in Part 3.
fetch_ohlcv(): Getting the Raw Candles
def fetch_ohlcv(exchange, symbol=SYMBOL, timeframe=TIMEFRAME, limit=LOOKBACK):
raw = exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
df = pd.DataFrame(
raw,
columns=["timestamp", "open", "high", "low", "close", "volume"],
)
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df.set_index("timestamp", inplace=True)
return df
This is the data intake. exchange.fetch_ohlcv() calls Binance’s /api/v3/klines endpoint, which returns candlestick data in the same format as the chart you’re looking at on TradingView right now. Each candle is an array: [timestamp, open, high, low, close, volume].
CCXT returns this as a list of lists. Pandas wraps it in a DataFrame - a table with labeled columns and a time index. Setting the timestamp as the index means you can reference rows by time, not by number. df.iloc[-1] gets the most recent candle regardless of how many rows exist.
The limit=LOOKBACK parameter matters. We set it to 100. That’s 100 hourly candles, or about 4 days of market data. Why 100 and not 1,000? Three reasons:
- Compute time: every feature function runs across all 100 rows. At 100 rows, it takes milliseconds. For a bot running every 60 seconds, that’s negligible. If you scaled to 100,000 rows, pandas would take a few hundred milliseconds - still fine for a once-per-minute loop.
- Window requirements: the longest rolling window we use is 50 periods (SMA 50). With 100 rows, the 50-period window has 50 prior values to compute from. That’s enough for stable estimates.
- Regime relevance: a model trained on 4 days of recent data adapts faster to changing market conditions than one trained on 6 months of stale data. We’ll expand the training window in Part 5 when we download historical data for model fitting. For live prediction, recent is better.
build_features(): From Raw Data to Model Input
This is the core of the pipeline. It takes the raw DataFrame and adds 11 feature columns. Let’s take each group one at a time, because each captures a different aspect of market behavior.
Price returns: how much the market moved
df["returns_1h"] = df["close"].pct_change()
df["returns_4h"] = df["close"].pct_change(4)
df["returns_24h"] = df["close"].pct_change(24)
pct_change() computes the percentage change from N periods ago. pct_change(1) is the 1-hour return. pct_change(24) is the daily return.
Why returns and not raw prices? Because models learn relationships between numbers. A raw price of $64,110 tells the model nothing about whether that’s high or low. Bitcoin was $16,000 two years ago and $109,000 six months ago. $64,110 relative to what?
Returns normalize the scale. A 0.5 percent hourly move means the same thing at $16,000 and $109,000: moderate upward momentum. The model sees 0.005 and learns “this is normal.” It sees 0.05 (5 percent in an hour) and learns “this is unusual.” Raw prices can’t convey that.
We compute returns at three time horizons on purpose. A model that sees only 1-hour returns misses the trend. A model that sees only daily returns misses the short-term reversal signal. Three horizons give the model context. It can learn patterns like “1-hour return is positive but 24-hour return is negative” (a pullback in a downtrend) versus “all three returns are positive with increasing magnitude” (acceleration).
Moving averages: where is price relative to its history
df["sma_10"] = df["close"].rolling(10).mean()
df["sma_50"] = df["close"].rolling(50).mean()
df["sma_ratio"] = df["sma_10"] / df["sma_50"]
SMA is simple moving average - the average closing price over N periods. You’ve used these on TradingView. The code is the same math.
sma_10 is the 10-hour average. It captures short-term trend. sma_50 is the 50-hour average - roughly two days of price data. It captures the medium-term trend.
sma_ratio is the feature that matters most here. A ratio above 1.0 means the short-term average is above the long-term average - uptrend. Below 1.0 means downtrend. But the distance from 1.0 matters too. 1.02 is a gentle uptrend. 1.08 is an aggressive rally that might be overextended. The model learns these nuances from thousands of examples.
Why not feed the raw SMA values directly? Because the model would have to learn to compare them on its own. By providing the ratio, you’re doing the comparison upfront and giving the model a single number that encodes trend direction and strength. This is called feature engineering: using domain knowledge to create inputs that make the model’s job easier.
Volatility: how unstable is the market right now
df["volatility_24h"] = df["returns_1h"].rolling(24).std()
Standard deviation of returns over 24 periods. In trading terms: how much does price typically swing in an hour, measured over the last day.
A volatility of 0.0003 (0.03 percent) means the market is flat. A volatility of 0.005 (0.5 percent) means price is moving. The model needs this because the same 1 percent return means different things in different volatility regimes. A 1 percent move in a flat market is a breakout. A 1 percent move in a volatile market is noise.
This is why you can’t just feed price changes into a model and expect it to work. Context changes the meaning of every signal. Volatility is context.
Volume features: conviction behind the move
df["volume_sma_10"] = df["volume"].rolling(10).mean()
df["volume_ratio"] = df["volume"] / df["volume_sma_10"]
Volume is the most underrated signal in trading. Price tells you what happened. Volume tells you whether anyone cared.
volume_ratio compares current volume to the 10-period average. A ratio of 2.5 means volume is 2.5x normal right now. High volume on a breakout suggests conviction. High volume on a reversal suggests capitulation. Low volume on any move suggests indifference. The model learns these associations from data.
Normalizing volume by its moving average is essential because volume scales vary wildly between assets. BTC averages 10,000+ BTC per hour on spot markets. A small-cap altcoin might average 50. Without normalization, the model would learn “big numbers = BTC, small numbers = altcoins” instead of “high volume ratio means strong conviction.”
Intra-candle range: how much happened inside this candle
df["high_low_ratio"] = (df["high"] - df["low"]) / df["close"]
A candle’s range tells you about market activity within that period. A candle that opened at $64,000, touched $64,500, dropped to $63,800, and closed at $64,200 had a 1.1 percent range. That’s a volatile hour regardless of where it closed.
The ratio normalizes the range by the closing price, so a $500 range on a $64,000 asset (0.78 percent) is comparable to a $1.50 range on a $200 asset (0.75 percent).
This feature captures something the other features miss: intra-period price action. Returns only compare open to close. A candle could close unchanged while having a 5 percent intra-period swing. Without this feature, the model never knows.
RSI: momentum normalized to a bounded scale
delta = df["close"].diff()
gain = delta.where(delta > 0, 0.0)
loss = (-delta).where(delta < 0, 0.0)
avg_gain = gain.rolling(14).mean()
avg_loss = loss.rolling(14).mean()
rs = avg_gain / avg_loss
df["rsi_14"] = 100.0 - (100.0 / (1.0 + rs))
RSI (Relative Strength Index) measures the speed and magnitude of recent price changes on a 0-100 scale. Above 70 is traditionally overbought. Below 30 is oversold.
The calculation looks complicated, but the logic is straightforward when broken down:
delta: how much did price change from the previous candle? Positive or negative.gain: positive changes only. Negative changes become zero.loss: negative changes turned positive (absolute value). Positive changes become zero.- Average the gains and losses separately over 14 periods.
- RSI = 100 minus (100 divided by (1 + gain/loss ratio)).
When gains consistently outweigh losses, the ratio is high and RSI approaches 100. When losses dominate, RSI approaches 0. When they’re equal, RSI is 50.
RSI is useful for a model because it’s bounded. Returns can spike to any value during a crash. RSI stays between 0 and 100 regardless. The model learns what “extreme” means on a stable scale rather than an unbounded one.
get_latest_features(): The Bridge to bot.py
def get_latest_features(exchange):
df = fetch_ohlcv(exchange)
df = build_features(df)
feature_cols = [
"returns_1h", "returns_4h", "returns_24h",
"sma_10", "sma_50", "sma_ratio",
"volatility_24h",
"volume_sma_10", "volume_ratio",
"high_low_ratio",
"rsi_14",
]
latest = df.iloc[-1]
return {
"price": latest["close"],
"features": latest[feature_cols].to_dict(),
"timestamp": df.index[-1].isoformat(),
}
This is the function bot.py calls every 60 seconds. It orchestrates the entire pipeline: fetch candles, build features, extract the latest row, package it into a clean dictionary.
The feature_cols list explicitly names which columns go to the model. Raw OHLCV data is excluded. Open, high, low, close, volume are useful for computing features, but the model doesn’t need the raw numbers. It needs the derived signals that describe market conditions.
The return value has three keys: price (for display), features (for the model), timestamp (for logging). This separation means bot.py can display the price without touching the feature vector, and the model receives only what it needs without price leaking into its input.
How bot.py Changed
Open the updated bot.py. Three changes from Part 3:
from features import get_latest_features
The bot now imports the feature pipeline instead of using fetch_price(). One import replaced the other.
data = get_latest_features(exchange)
price = data["price"]
feature_vector = data["features"]
prediction = predict(feature_vector)
Instead of passing a single price number to predict(), the bot passes the entire feature vector - 11 numbers describing every aspect of the current market. The predict() function signature changed from predict(price) to predict(features).
The placeholder prediction itself was rewritten to use the feature vector:
def predict(features):
import hashlib
seed = int(hashlib.md5(str(features.values()).encode()).hexdigest()[:8], 16)
direction = "UP" if seed % 2 == 0 else "DOWN"
return {
"direction": direction,
"confidence": CONFIDENCE,
}
It hashes the feature values to produce a deterministic but random-looking direction. This is still a placeholder - the hash has zero predictive power. But it was necessary because the old price % 2 placeholder from Part 3 would produce identical predictions whenever the price crossed an even-dollar boundary but stayed between boundaries for hours. The feature vector changes every 60 seconds even if the price doesn’t, because volume, RSI, volatility, and returns all evolve independently. So the placeholder at least produces varied output while the model is still simulated.
In Part 5, this entire function body gets replaced. The import hashlib goes away. The hash goes away. The function will load a trained model file and call model.predict_proba(feature_vector) to get an actual probability score. Same function name. Same input type. Same output format. Different internals.
The output now includes RSI:
▲ BTC/USDT: $64,150.00 | confidence: 65% | prediction: UP | RSI: 52.4
One extra number on the display. But behind that display line, the bot is now computing 11 features from 100 candles every 60 seconds. That’s the pipeline that will feed the model in Part 5.
What You Can Tweak Right Now
The feature pipeline is configurable. Open features.py and look at the top:
SYMBOL = "BTC/USDT"
TIMEFRAME = "1h"
LOOKBACK = 100
Change the symbol. Set it to "ETH/USDT" and the pipeline fetches Ethereum candles. All 11 features recompute correctly because they’re derived from the data, not hardcoded for Bitcoin.
Change the timeframe. Valid values: "1m", "5m", "15m", "30m", "1h", "4h", "1d". If you switch to "5m", a LOOKBACK of 100 means 500 minutes of history (about 8 hours). For shorter timeframes, increase LOOKBACK to maintain the same calendar coverage. The 50-period SMA on 5-minute candles only looks back 250 minutes. If you want the SMA to represent a similar time span as on 1h candles, increase LOOKBACK proportionally.
Change the lookback. More candles means more stable features (longer rolling windows have more data) but slower fetches and older data. 100 is a good balance. For daily candles, 100 rows is over 3 months of history. For 1-minute candles, 100 rows is less than 2 hours. Adjust based on your timeframe.
Add your own features. The pipeline is intentionally modular. Want Bollinger Bands? Add a few lines to build_features():
df["bb_upper"] = df["sma_20"] + 2 * df["close"].rolling(20).std()
df["bb_lower"] = df["sma_20"] - 2 * df["close"].rolling(20).std()
df["bb_width"] = (df["bb_upper"] - df["bb_lower"]) / df["sma_20"]
Then add "bb_upper", "bb_lower", "bb_width" to the feature_cols list in get_latest_features(). The bot picks them up automatically. The model in Part 5 will use any feature you include in that list.
Why Feature Engineering Matters More Than Algorithm Choice
There’s a saying in machine learning: “garbage in, garbage out.” The best model architecture can’t save bad features. The simplest model with great features often beats a complex model with lazy features.
Consider two traders. Trader A looks at a chart and says “price is $64,110.” Trader B looks at the same chart and says “price is $64,110; it’s up 0.6 percent this hour, down 0.1 percent over the last 4 hours, volume is below average, RSI is neutral at 52, and volatility is declining.” Which trader has a better read on the market?
That’s the difference between one feature and eleven. The model is Trader B. It doesn’t need you to write rules about when to buy or sell. It needs you to give it enough information to discover those rules from data.
In Part 5, we’ll train a model on these exact features. You’ll download historical data, build a training set with thousands of rows, and watch the model learn which combinations of these 11 numbers predict future price movement. The model won’t be smarter than the features you feed it. But with 11 dimensions of market data instead of one, it will be dramatically more capable than anything that operates on price alone.
Running the Bot with Features
If you stopped your bot after Part 3, restart it:
cd ~/ai-trading-bot
screen -S bot
python3 bot.py
Then detach with Ctrl+A, D. The bot will pull 100 hourly candles, compute all 11 features, and print predictions with RSI every 60 seconds. Check on it with screen -r bot.
If you’re running locally without a VPS, just run python3 bot.py and watch the output. Same pipeline. Same features. The VPS is for 24/7 uptime, not for compute power. A $5 DigitalOcean droplet handles this pipeline easily.
What’s Coming in Part 5
Everything we’ve built leads here. Part 5 is where the bot stops guessing and starts learning.
We’ll download historical OHLCV data from Binance (thousands of candles, not just 100), build a training dataset from our features, train an XGBoost model to predict future price direction, save the trained model to disk, and load it into predict() to replace the placeholder. The bot will output real probability scores based on patterns the model discovered in the data.
You already have the pipeline. You already have the architecture. Part 5 adds the brain.
Missed the earlier parts? Start here:
- Part 1: How One Trade Works - the mental model behind everything you’re building.*
- Part 2: Setup Your VPS and Run an AI Bot in 15 Minutes - your bot is already running on a $5 server.*
- Part 3: Understanding Your Bot’s Code - every line of bot.py explained.*
The code: github.com/hovinng/ai-trading-bot