07. 67 Lenses, One Market
67 Lenses, One Market
What Matters More Than the Model
I'll be honest: for quite a long time, I was obsessed with model architecture. Stack more LSTM layers, add a Transformer layer on top, ensemble XGBoost with LightGBM, and performance will improve -- that's what I believed. From v2.1 through v2.5, I kept changing model structures while flailing around at 60% accuracy.
Then in v2.7, I completely redesigned the feature engineering, and accuracy jumped to 88%. The model actually got simpler -- I dropped LSTM and kept only XGBoost + LightGBM.
"What you feed the model" matters more than "how you build the model."
This is something I'd read a hundred times in textbooks, but it never truly sank in until I experienced it firsthand. There's a fact that gets repeated throughout the machine learning literature: most model performance improvements come from feature design, not algorithm changes. To borrow Andrew Ng's words, "Applied ML is basically feature engineering." I now agree with this 100%.
4 Timeframes, 67 Features
The core philosophy of v2.7 is independent multi-timeframe features. Instead of looking at just one 5-minute candle, I resampled 15-minute, 1-hour, and 4-hour candles separately and computed independent indicators for each.
timeframes = {
'5min': df, # Original (fast momentum)
'15min': df.resample('15T').agg(ohlcv_rules), # 3-bar aggregation
'1h': df.resample('1H').agg(ohlcv_rules), # 12-bar aggregation
'4h': df.resample('4H').agg(ohlcv_rules), # 48-bar aggregation
}
Dissecting the Indicator Formulas: Why These Equations?
Before listing the features, I want to walk through what each technical indicator means mathematically. It's easy to call ta.RSI(close, 14) in code, but if you don't understand why that number is meaningful, you can't make good decisions in feature design.
RSI (Relative Strength Index)
RSI is a momentum oscillator that normalizes the ratio of recent gains to losses on a 0-100 scale over $n$ periods.
$$RS = \frac{SMA(U, n)}{SMA(D, n)}$$
$$RSI = 100 - \frac{100}{1 + RS}$$
Where $U$ is the gain on up candles (0 on down candles), $D$ is the loss on down candles (0 on up candles), and $SMA(U, n)$ is the average gain over the last $n$ candles.
The heart of this formula is the ratio (RS). When the average gain exceeds the average loss, RS > 1 and RSI rises above 50. The reason it's mapped to a 0-100 range is to allow comparing assets at different price levels on the same scale.
RSI is like a tug-of-war scoreboard. It converts "how hard the bulls have been pulling" versus "how hard the bears have been pulling" over recent days into a ratio. RSI at 70 means the bulls are dominating; RSI at 30 means the bears are in control. Whether BTC is at $60,000 or $30,000, an RSI of 70 conveys the same meaning: "recent momentum has been bullish."
This is why I used RSI-3 (15 min) and RSI-7 (35 min) instead of RSI-14 (70 min) on the 5-minute chart. The smaller $n$ is, the more sensitive RSI becomes to recent changes. For scalping, a 70-minute-old trend is already stale news.
# Actual implementation
delta = series.diff()
gain = delta.where(delta > 0, 0.0).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0.0)).rolling(window=period).mean()
rs = gain / (loss + 1e-10)
rsi = 100 - (100 / (1 + rs))
EMA (Exponential Moving Average)
A Simple Moving Average (SMA) gives equal weight to every data point. But in markets, recent data matters more. The Exponential Moving Average (EMA) implements this intuition mathematically.
$$EMA_t = \alpha \cdot P_t + (1 - \alpha) \cdot EMA_{t-1}$$
Where the smoothing factor $\alpha = \frac{2}{n+1}$ and $n$ is the span. This recursive structure is the key -- the current EMA is a weighted average of the current price and the previous EMA. The larger $\alpha$ is (i.e., the smaller $n$ is), the more weight goes to the current price.
For EMA-3, $\alpha = 0.5$, meaning the current price and previous EMA are weighted 50:50. EMA-10 has $\alpha \approx 0.18$, so the current price has less influence. I used EMA-3/7/10 crossovers as features because crossovers between moving averages with different response speeds capture trend reversal points.
# EMA cross difference - normalized
ema3 = ema(close, 3)
ema7 = ema(close, 7)
result['EMA_3_7_Diff'] = (ema3 - ema7) / (close + 1e-10)
result['EMA_Cross_3_7'] = (ema3 > ema7).astype(float)
Notice that EMA_3_7_Diff is divided by the close price. This normalization lets us compare EMA differences on the same scale regardless of whether BTC is at $30,000 or $60,000.
MACD (Moving Average Convergence Divergence)
MACD is a trend-following indicator based on the difference between two EMAs.
$$MACD = EMA_{12} - EMA_{26}$$
$$\text{Signal Line} = EMA_9(MACD)$$
$$\text{Histogram} = MACD - \text{Signal Line}$$
MACD itself represents the divergence between short-term (12-period) and long-term (26-period) trends. The Signal Line shows the "trend of MACD." The Histogram is the difference between MACD and the Signal -- essentially the acceleration of the acceleration.
Here's a car analogy. MACD is "the difference between your current speed and your usual speed" (are you accelerating?). The Signal Line is "the average trend of that acceleration." The Histogram is "are you pressing the gas pedal harder or easing off?" When the histogram flips from positive to negative, that's the moment of transition from acceleration to deceleration.
In my system, I didn't use MACD raw -- I normalized it by ATR.
$$MACD_{normalized} = \frac{MACD}{ATR}$$
This is because MACD's absolute value depends on the price level. A MACD of 100 when BTC is at $60,000 means something entirely different from a MACD of 100 at $30,000. Dividing by ATR gives it the meaning of "trend strength relative to current volatility," enabling scale-independent comparison.
Bollinger Bands
Bollinger Bands apply the statistical concept of standard deviation to markets.
$$BB_{upper} = SMA_n + k \cdot \sigma_n$$
$$BB_{lower} = SMA_n - k \cdot \sigma_n$$
$$BB_{position} = \frac{P - BB_{lower}}{BB_{upper} - BB_{lower}}$$
$$BB_{width} = \frac{BB_{upper} - BB_{lower}}{SMA_n}$$
Where $SMA_n$ is the $n$-period simple moving average, $\sigma_n$ is the $n$-period standard deviation, and $k$ is typically 2. Under a normal distribution, price falls within $\pm 2\sigma$ bands about 95.4% of the time. Financial returns don't actually follow a normal distribution and have fat tails, but even so, breaching the bands signals something statistically "unusual."
Think of test scores in a class. Bollinger Bands are like drawing "class average +/- 2 standard deviations." If the average is 70 and the standard deviation is 10, the band runs from 50 to 90 and captures most students. A student scoring 95 falls outside the band -- an "unusual" case. Prices behave similarly: breaking through the band signals that "something out of the ordinary is happening."
The features I used are BB Position (where price sits within the band) and BB Width (how wide the band is). Position near 0 means price is near the lower band; near 1 means the upper band. When Width narrows, it indicates a volatility squeeze, which tends to precede large moves.
ATR (Average True Range)
ATR is an absolute measure of volatility. True Range is defined as the maximum of three values:
$$TR = \max(H_t - L_t, \; |H_t - C_{t-1}|, \; |L_t - C_{t-1}|)$$
$$ATR_n = \frac{1}{n} \sum_{i=1}^{n} TR_i$$
$H_t - L_t$ is the intraday high-low range, while $|H_t - C_{t-1}|$ and $|L_t - C_{t-1}|$ account for gaps. The reason we compare all three is that if there's a gap after the close, the intraday high-low alone would underestimate the true range of movement.
Think of a thermometer. ATR measures "how much did body temperature fluctuate during the day." Just looking at today's max and min temperature might miss a sudden fever that broke overnight, so we include the difference from yesterday's evening temperature (previous close) to capture the "true range of variation." Crypto trades 24/7, so gaps are rare, but ATR's mathematical definition covers this scenario by design.
In my system, ATR is used in two forms. One is ATR_Percent -- ATR divided by the current price.
$$ATR\% = \frac{ATR}{Close}$$
This feature ranked #1 in XGBoost feature importance (10.8%). When this value is high, the market is actively moving; when low, it's quiet. The model essentially taught itself that strategy should differ based on the volatility regime.
ADX (Average Directional Index) / DI
ADX measures whether a trend exists and how strong it is. It doesn't tell you the direction -- just whether there's a trend or not.
First, directional indicators are computed:
$$+DM = H_t - H_{t-1} \quad (\text{if } +DM > -DM \text{ and } +DM > 0, \text{ else } 0)$$
$$-DM = L_{t-1} - L_t \quad (\text{if } -DM > +DM \text{ and } -DM > 0, \text{ else } 0)$$
$$+DI = 100 \times \frac{SMA(+DM, n)}{ATR_n}$$
$$-DI = 100 \times \frac{SMA(-DM, n)}{ATR_n}$$
$$DX = 100 \times \frac{|+DI - (-DI)|}{+DI + (-DI)}$$
$$ADX = SMA(DX, n)$$
$+DI$ represents bullish pressure and $-DI$ bearish pressure, both as ratios relative to ATR. DX normalizes the divergence between these two forces to a 0-100 scale, and ADX smooths DX. ADX > 25 is typically interpreted as a trend being present; < 20 suggests a sideways market.
ADX is like an anemometer. It doesn't tell you which direction the wind blows, but it measures "is it blowing hard or calm right now?" High ADX means strong wind (a trend) -- you can ride it. Low ADX means no wind (sideways market) -- furl the sails and wait.
I only computed ADX on the 1-hour chart. Calculating ADX on 5-minute candles produces too much noise for meaningful trend assessment. When the 1-hour ADX is high, it signals a strong macro trend, and that's when you can trust the fast momentum signals from the 5-minute chart.
OBV (On-Balance Volume)
OBV is a cumulative sum of volume that tracks money flow.
$$OBV_t = OBV_{t-1} + \begin{cases} V_t & \text{if } C_t > C_{t-1} \\ -V_t & \text{if } C_t < C_{t-1} \\ 0 & \text{if } C_t = C_{t-1} \end{cases}$$
Volume is added on up days and subtracted on down days -- simple logic. When this cumulative value moves in the same direction as price, the trend is trustworthy. When they diverge, that's a divergence signal.
I used OBV Ratio (OBV divided by its 10-bar moving average) rather than raw OBV. Since OBV's absolute value keeps growing over time, converting to a ratio is necessary to capture recent changes in volume flow.
5-Minute Chart: Fast Momentum - 27 Features
The 5-minute chart is the heartbeat of scalping. What matters here is fast responsiveness. RSI-14 (70 minutes) is too slow for 5-minute trading. That's why I designed ultra-short indicators like RSI-3 (15 min) and RSI-7 (35 min).
- Ultra-short returns: Return 1bar (5min), 3bar (15min), 6bar (30min)
- Fast RSI: RSI-3, RSI-7, RSI-14
- EMA crosses: EMA-3/7/10 crossovers and differences
- Volume spikes: Current volume ratio vs 3-bar/6-bar averages
- Consecutive patterns: Count of consecutive bullish/bearish candles
- Price position: Current price position within 6-bar/12-bar range
- Volatility: Log-return based 5-bar/10-bar volatility
- ATR%: ATR relative to price (absolute volatility measure)
- Normalized MACD: MACD divided by ATR for scale independence
A quick note on log-return based volatility. I used log returns $r_t^{log} = \ln\frac{P_t}{P_{t-1}}$ instead of simple returns $r_t = \frac{P_t - P_{t-1}}{P_{t-1}}$ because of stationarity. Log returns have relatively stable mean and variance over time, and they're additive for compounding: $\sum_{t=1}^{n} r_t^{log} = \ln\frac{P_n}{P_0}$. Simple returns don't have this property.
15-Minute Chart: Medium-Term Confirmation - 12 Features
The 15-minute chart acts as a noise filter for the 5-minute chart. Here I used more traditional indicators.
- RSI-14, MACD/Signal/Histogram
- Bollinger Bands: Position (where in the band) and Width (band width)
- Volume Ratio, ATR%
- Returns: 1-bar/3-bar change rates
- Trend: Price position relative to MA20
1-Hour Chart: Trend Assessment - 14 Features
Starting from the 1-hour chart, you begin to see real "trends." This is where I added ADX (trend strength) and OBV (cumulative volume).
- The base 12 indicators from the 15-minute chart + ADX, OBV Ratio
- High ADX means a strong trend; OBV moving with price confirms trend reliability
4-Hour Chart: Macro Direction - 14 Features
The 4-hour chart shows "the big picture." With only 6 candles per day, signals from this timeframe carry real weight.
- Base 12 indicators + MA50 Distance + Trend Direction (sign of 5-bar change)
tf4h_MA50_Dist: Distance from the 50-bar (~8 day) moving average. Acts as long-term support/resistancetf4h_Trend_Dir: Direction over the last 20 hours. +1 for uptrend, -1 for downtrend
What XGBoost Told Us: Feature Importance
After training the model, I extracted XGBoost's feature importances. The results were quite impressive.
ATR_Percent: 10.8% ████████████████████
tf4h_Return_1: 9.6% ██████████████████
tf4h_Volume_Ratio: 4.0% ████████
MACD_Normalized: 3.9% ███████
tf1h_Return_1: 3.8% ███████
EMA_3_10_Diff: 3.3% ██████
tf4h_ATR_Pct: 3.1% ██████
tf4h_Return_3: 2.9% █████
12 out of the top 20 features (60%) were multi-timeframe features. What's especially striking is that tf4h_Return_1 (4-hour return) ranked #2. For a system that trades on 5-minute candles, the 4-hour trend matters that much.
The #1 feature, ATR_Percent, represents "how much is the market moving right now." The model essentially taught itself that strategy should differ between high and low volatility environments.
The Math Behind Feature Importance: Information Gain via Gini Impurity
Understanding how XGBoost computes feature importance allows for deeper interpretation of these numbers. XGBoost selects the optimal feature and split point at each tree split based on Gini Impurity reduction (or information gain).
Gini Impurity measures class impurity:
$$G = 1 - \sum_{k=1}^{K} p_k^2$$
Where $p_k$ is the proportion of class $k$ in the node, and $K$ is the number of classes (3 in our case: LONG, SHORT, NO_TRADE). $G = 0$ means the node is perfectly pure (contains only one class); higher $G$ means more mixture.
Imagine a fruit basket. A basket with only apples has Gini = 0 (perfectly pure). A basket with equal parts apples, pears, and oranges has high Gini (a jumbled basket). Each time a tree splits, it's asking "does this criterion make the baskets cleaner?" High feature importance means "this criterion does the best job of sorting the baskets."
The information gain from splitting a node on feature $f$ is:
$$\Delta G = G_{parent} - \sum_{j=1}^{J} \frac{n_j}{n} G_j$$
Where $n_j$ is the sample count in child node $j$, $n$ is the parent node's sample count, and $G_j$ is the child node's Gini Impurity. Feature importance is the sum of $\Delta G$ across all splits involving that feature.
ATR_Percent having 10.8% importance means that the total information gain from all splits on ATR_Percent accounts for 10.8% of the total across the entire tree. In terms of model decision-making, the information about "current volatility level" resolves the most uncertainty.
Revisiting v2.5's Mistake
As analyzed in Part 5, v2.5's "multi-timeframe" was a fake independence where three models shared the same 42 features from 5-minute candles. The 94% signal agreement rate was the evidence. When I built genuinely independent resampled features in v2.7, 12 out of the top 20 features by importance (60%) were multi-timeframe features. Combining different perspectives was more effective than deeply mining a single view.
The Pain of Merging
Building multi-timeframe features is conceptually straightforward. But in practice, index alignment was an absolute nightmare. When attaching 1-hour features to 5-minute data, you must ensure no future data leaks in.
I used pd.merge_asof to assign "the most recent higher-timeframe value" to each 5-minute candle. For example, the 10:15 five-minute candle gets the 10:00 hourly value -- not the 11:00 value. The direction='backward' parameter guarantees this.
result = pd.merge_asof(
result.sort_values('Timestamp'),
tf_features.sort_values('Timestamp'),
on='Timestamp',
direction='backward' # Always use only the most recent past value
)
I had a bug here once where a forward-fill accidentally leaked future values in, and the backtest results came out unrealistically good. That was the moment I truly felt the danger of look-ahead bias.
What I Learned
The biggest lesson from designing 67 features is that feature engineering is the crystallization of domain knowledge. Whether to use RSI-3 or RSI-14 is the answer to a domain question: "how fast a response do I need for 5-minute scalping?"
Adding 14 features from the 4-hour chart was better than expanding 5-minute features to 100. This aligns with the information-theoretic concept of "complementary information." Adding more features from the same timeframe increases redundancy, while features from different timeframes add genuinely new information.
Every formula has a reason, and every parameter has empirical backing. The model is just a tool. Good inputs produce good outputs. And the quality of those inputs starts with mathematical understanding.