Applied Projects — ML in Equity Research

Project 1 · Decision Tree: Stock Momentum

This is where the course meets the market. The brief: predict whether a stock's next three months will be Bullish, Neutral or Bearish — and hand the trading desk rules it can actually read. Eight real stocks (Apple to Walmart, 2020–2026, 13,560 trading days), seven technical indicators engineered from raw prices, a tuned decision tree, and an honest scoreboard. Along the way this lesson teaches the finance behind every indicator (what RSI really measures, why a golden cross matters, what ATR says that direction doesn't), reads the code line by line, and hunts three genuine landmines hiding in the original project template — including one that mislabels stocks with their neighbours' future prices. The punchline is an honest number: 46% test accuracy against a 34% baseline, and the reasons why that gap — not a bigger one — is exactly what efficient markets predict.

🧪 Project 1 of 3 ⏱ ~120 min 🔗 Ch 3 · Ch 16 · Ch 18 · Ch 31 Mostly Core Stable Volatile 📝 Quiz · 30 MCQs

🎯 Learning Objectives

1. The Brief

The desk's question, in trader language: "Every morning I see RSI, MACD, the moving averages, volume. Which combinations of those numbers actually precede a good next quarter — and can you show me the rule?" That last clause is why this project is a decision tree and not a neural network: Chapter 16's tree is the model whose entire logic can be printed on one page (export_text) and argued over in a meeting. A desk can't interrogate 127 weights; it can interrogate "if ATR ≤ 6.94 and the 50-day average is above the 200-day, then…"

The plan, and where each course chapter plugs in:

StageWhat we doCourse backbone
1 · DataDaily OHLCV for 8 large-cap US stocks, 2020-01-02 → 2026-09-30 (13,560 rows)Ch 3's audit discipline
2 · Features7 technical indicators engineered per tickerthis lesson §2–3
3 · Labels3-month forward return split into terciles → Bearish / Neutral / BullishCh 3 (leakage)
4 · ModelDecision tree, 120-configuration grid search, 5-fold CVCh 16 + Ch 31
5 · EvaluationAccuracy vs baseline, per-class precision/recall in trading languageCh 18
6 · InterpretationThe rules, the importances, and what the tree got right and wrongCh 16 · Ch 17 preview

The eight stocks — AAPL, MSFT, GOOGL, TSLA, JPM, XOM, JNJ, WMT — are deliberately cross-sector (tech, banks, energy, healthcare, retail) so the model cannot quietly become a sector bet. One honest design note carried over from the original project: the template's "fundamental" columns (P/E, ROE, growth, leverage…) are random placeholders, not real data — and §7 turns that flaw into one of the lesson's best experiments.

📝
Note — what "momentum" means here: momentum is the best-documented anomaly in finance: assets that outperformed recently tend to keep outperforming over 3–12 months (Jegadeesh & Titman, 1993, and three decades of replications). This project asks the narrower, harder version of that question: can chart indicators alone, on daily data, rank the next quarter? Keep that framing in mind for §8.

2. The Finance Primer — Reading a Chart Like an Analyst

Nothing in this section is machine learning. It is the vocabulary the rest of the lesson speaks — the five indicator families a technical analyst watches every morning, each reduced to one formula, one intuition, and one thing it catches that the others don't.

2.1 RSI — the momentum thermometer (0–100)

RS = avg gain over 14 days ÷ avg loss over 14 days   ·   RSI = 100 − 100/(1 + RS)
Consecutive daily closes only. Above 70 the stock is "overbought" (buying may be exhausted); below 30 "oversold" (selling may be exhausted). RSI measures the balance of up-days vs down-days, not the size of the trend.

By hand, because the course always computes one first. Fourteen daily price changes:

SumAverage (÷14)
Gains (+1, +1, +2, +1, +2, +2, +1, +2, +1, +2)151.071
Losses (−1, −1, −1, −1)40.286
RS = 1.071 / 0.286 = 3.75  ⇒  RSI = 100 − 100/4.75 = 78.9 — overbought territory.
Ten up-days against four small down-days: the thermometer reads hot. (Our code uses a simple 14-day rolling average of gains and losses; Wilder's original smooths recursively, which reads less extreme — worth knowing when you compare with a terminal chart.)

2.2 MACD — trend, with a memory

MACD = EMA₁₂(close) − EMA₂₆(close)
An exponential moving average is a rolling average that weights recent days more heavily — the weights decay geometrically into the past, so EMA₁₂ tracks price quickly while EMA₂₆ lags. Their difference is positive in uptrends, negative in downtrends, and its sign flips ("crossover") when the trend's balance tips. Units: dollars — a $500 stock has a bigger MACD than a $60 stock for the same percentage trend. Remember that; it matters in §8.

2.3 The moving-average crossover — the golden cross

When the 50-day average crosses above the 200-day average ("golden cross"), medium-term buyers have overtaken long-term ones — a classic bull signal; the reverse is the "death cross". The feature is binary: MA_Crossover = 1 when SMA₅₀ > SMA₂₀₀, else 0. It is the slowest, most forgiving signal on the page — and the one with the longest warm-up (199 days of history before SMA₂₀₀ even exists).

2.4 ATR — how violent, not which way

True Rangeₜ = max(High−Low, |High−Closeₜ₋₁|, |Low−Closeₜ₋₁|)  ·  ATR = 14-day average of True Range
A stock that gaps overnight — closing at 50, opening at 55 — has a high-low range that hides the true violence. True Range takes the largest of the three possible ranges, gaps included; ATR averages it. ATR says nothing about direction: high ATR means the stock is moving violently somehow. Position sizing and stop-losses are set off ATR; our model will make it the single most important feature it learns.

2.5 Volume ratio — conviction

Price moves on unusually heavy volume carry more information than the same move on thin volume: someone big is acting. Volume_Ratio = today's volume ÷ 20-day average volume — 1.0 is an ordinary day, 2.0 means twice the usual crowd showed up. The last technical feature, Volatility (20-day rolling standard deviation of close), is ATR's cousin measured on closes alone.

AAPL price with SMA50 and SMA200, RSI with 70/30 bands, and MACD, 2020 to 2026
Figure 1 — The lesson's data through an analyst's three panels (AAPL, real snapshot data): price with 50/200-day averages (note the 2020 and 2023–2024 golden-cross regimes), RSI with its 70/30 bands, and MACD with its zero line. Every feature in §3 is computed from exactly these series.

2.6 The fundamental side — what the placeholders would have meant

The project template carries eight fundamental columns as random noise. Real versions would read like this to an analyst — and §8 discusses why using them properly is harder than it sounds:

MetricDefinitionThe analyst's question
P/E ratioprice ÷ earnings per sharehow many years of current earnings you're paying for — is the stock expensive?
ROEnet income ÷ equityhow much profit does each unit of shareholders' capital generate?
Earnings growthYoY % change in earningsis the business accelerating?
Debt-to-equitytotal debt ÷ equityhow leveraged — who gets paid first in a downturn?
Dividend yieldannual dividend ÷ pricecash return now vs growth later
Profit marginnet income ÷ revenuepricing power and cost discipline
Current ratiocurrent assets ÷ current liabilitiesshort-term solvency
Revenue growthYoY % change in salestop-line momentum before earnings "management"
💡
Pro Tip — two schools, one feature matrix: technical analysts read the chart (supply/demand, psychology); fundamental analysts read the business (value, quality). This project runs the technical school's indicators as real features and the fundamental school's as noise — which makes §7's importance chart a controlled experiment: can the tree tell information from noise?

3. From Prices to Features

The engineering loop runs per ticker — that word "per" is load-bearing (§4 explains what breaks without it). The complete, corrected feature block:

for ticker, g in df.groupby('Ticker'):
    g = g.sort_index().copy()                     # one ticker's history, in date order

    # trend
    g['SMA50']  = g['Close'].rolling(50).mean()   # 50-day simple moving average
    g['SMA200'] = g['Close'].rolling(200).mean()
    g['MA_Crossover'] = (g['SMA50'] > g['SMA200']).astype(float) \
                          .where(g['SMA200'].notna())   # NaN while SMAs warm up

    # momentum
    delta = g['Close'].diff()                     # day-over-day change
    gain  =  delta.where(delta > 0, 0).rolling(14).mean()
    loss  = (-delta.where(delta < 0, 0)).rolling(14).mean()
    g['RSI'] = 100 - 100 / (1 + gain / loss)
    g['MACD'] = g['Close'].ewm(span=12).mean() - g['Close'].ewm(span=26).mean()
    g['Momentum'] = g['Close'].pct_change(20) * 100      # 20-day % price change

    # volatility
    tr = pd.concat([g['High'] - g['Low'],
                    (g['High'] - g['Close'].shift()).abs(),
                    (g['Low']  - g['Close'].shift()).abs()], axis=1).max(axis=1)
    g['ATR'] = tr.rolling(14).mean()
    g['Volatility'] = g['Close'].rolling(20).std()

    # volume
    g['Volume_Ratio'] = g['Volume'] / g['Volume'].rolling(20).mean()

Listing 1 — Technical feature engineering, per ticker. Seven features from five columns.

Four pandas idioms do all the work, and each is worth reading slowly:

One real row of the finished matrix, so the features stop being abstractions — AAPL, 25 January 2022:

RSIMACDMA_CrossoverMomentumATRVolatilityVolume_Ratio→ Label
15.3−2.211−11.4%4.376.371.25Bearish (fwd −1.7%)

Read it like the analyst who lived it: deeply oversold (RSI 15), below trend on MACD, still technically in a golden-cross regime — and the 20-day momentum had gone decisively negative. The next quarter lost a further 1.7%. One row, seven numbers, one label: that is the entire supervised-learning contract of this project, times eleven thousand.

4. Labelling the Future — Carefully

Supervised learning needs ground truth, and for momentum the truth arrives three months later. The label for day t is built from the price at day t+63 (three months of trading days):

day t features: RSI, MACD, ATR… only prices ≤ t day t + 63 label: forward return (Closeₜ₊₆₃/Closeₜ − 1) shift(−63) — reaches into the future, by design rolling windows — only the past the label may look forward; the features never may — and the last 63 days of each ticker have no label at all
Figure 2 — The labelling timeline. Chapter 3's leakage rule, adapted to time: future in the label is the point; future in a feature is the crime.

Then the continuous forward return becomes a class. The template uses terciles — the 33rd and 67th percentiles of the whole dataset's forward returns:

forward return ≤ −0.04% → Bearish  ·  in between → Neutral  ·  ≥ +11.18% → Bullish
The real thresholds from our data — and they are asymmetric because 2020–2026 was a rising market: the average 3-month forward return across all rows is +6.9%. "Bullish" therefore means top-third outcome (roughly ≥ +11%), not "made money" — an important rephrasing when the desk reads your confusion matrix.
ClassRowsMean forward returnMedian
Bearish (bottom third)3,931−9.2%−7.3%
Neutral (middle third)3,864+5.3%+5.1%
Bullish (top third)3,669+22.5%+18.5%

A 31.7-point spread between class means — that is the prize the tree is trying to rank. And tercile labelling keeps the classes nearly balanced by construction (34.3% / 33.7% / 32.0%), so accuracy's majority baseline sits at 0.343 — the number every result in §6 must beat.

4.1 Three landmines in the template

The original project code ships with three genuine bugs around this labelling step. None of them crash — which is precisely why they are worth studying. All three were found by auditing, and all three are fixed in this lesson's canonical run.

  1. The cross-ticker seam. The template concatenates all tickers into one frame and then calls shift(-63) and rolling(50). Rolling windows and forward shifts run straight across ticker boundaries: Apple's last trading days would be labelled with Microsoft's future prices, and its "50-day average" at the seam mixes two stocks' prices. Fix: every window and shift inside groupby('Ticker').
  2. NaN swallowed into "Neutral". The last 63 days of each ticker have no future price, so their forward return is NaN. The template's np.where(fwd <= lo, 'Bearish', np.where(fwd >= hi, 'Bullish', 'Neutral')) sends NaN to the else-branch — NaN comparisons are False — quietly manufacturing ~500 fake "Neutral" labels for days whose truth is unknown. Fix: pd.cut, which maps NaN to NaN, then drop the unknowns (504 rows).
  3. The fake golden cross. During the 199-day SMA200 warm-up, (SMA50 > SMA200) compares NaN to NaN — which is False — so MA_Crossover reads 0 ("death cross") for the first year of every ticker, when the honest value is "don't know". Fix: .where(g['SMA200'].notna()) keeps those rows NaN until the average exists.

Together the three fixes shrink the dataset from the template's effective 13,400 rows to 11,464 honest ones — the difference between 1,936 rows of contaminated or fake-label data and a clean slate. Chapter 31's lesson generalises: the bugs that cost you are the ones that don't raise exceptions.

⚠
Warning — one leakage this project still accepts: the train/test split is random across all 11,464 days, so the model trains on 2024–2026 rows while being tested on 2020 rows — it has seen the future regime. The professional fix (time-ordered, walk-forward splits) is §8's first upgrade. The template made this trade for simplicity; a lesson should name it.

5. The Model — Tuned Like Chapter 31 Taught

The classifier itself needs no re-teaching — Chapter 16 built trees from Gini impurity up, and Chapter 31 supplied the tuning discipline. What matters here is the grid, run for real on the 8,598-row training set:

param_grid = {
    'max_depth':        [3, 4, 5, 6, 7],      # five depths
    'min_samples_split':[10, 15, 20, 30],     # × four split floors
    'min_samples_leaf': [5, 10, 15],          # × three leaf floors
    'criterion':        ['gini', 'entropy'],  # × two impurities
}                                   # = 120 configurations

grid = GridSearchCV(DecisionTreeClassifier(random_state=42),
                    param_grid, cv=5, scoring='accuracy', n_jobs=-1)
grid.fit(X_train, y_train)         # 120 × 5 = 600 fits, all cores

Listing 2 — The tuning grid: 120 candidates × 5-fold CV.

RankCV accuracycriterionmax_depthmin_samples_leafmin_samples_split
10.4660gini71010
2–40.4660same depth/leaf, split floor 15 / 20 / 30 — indistinguishable
50.4646gini7520

Chapter 31's plateau, transplanted to finance: the top five candidates sit within 0.0014 of each other, the winner chose the shallowest impurity (gini) and the deepest depth offered (7) — and the split/leaf floors barely matter once leaves hold ≥10 trading days. A depth-7 tree with minimum-10-sample leaves is the champion: 82 leaves, each averaging about 105 training rows. When the top of the grid is flat, any resident of the plateau ships.

6. The Scoreboard

The tuned tree meets the 2,866 held-out days exactly once (Chapter 31's rule). The headline first, then the anatomy:

Training accuracy 50.7% · Test accuracy 46.2% · gap 4.5 points  ·  majority baseline 34.3%
The tree generalises (a modest gap, no memorisation meltdown) and it beats the lazy baseline by 11.9 points. Both halves of that sentence are wins. What it does not do is approach the 65–75% the original assignment guide hoped for — §8 is where we take that number seriously.
Three by three confusion matrix for Bearish, Bullish and Neutral on 2866 test days
Figure 3 — The real test-set confusion matrix (rows actual, columns predicted). The diagonal holds 1,123 of 2,866 days.

Now the metrics in the only language that matters on a desk — what does betting on this signal cost me?

ClassPrecisionRecallThe trading translation
Bullish0.4450.383Of days flagged Bullish, 44.5% actually landed in the top third — the "hit rate" of a long signal. Of the true top-third days, we caught 38.3%.
Bearish0.4500.526Half of the genuinely worst days were flagged (decent warning system); but 55% of "sell" flags were false alarms — costly if you exit on them.
Neutral0.4910.471The middle third is where signal goes to die — expected, since it is the class with the least defining character.

And the single most expensive cell in Figure 3, read as a trade: 348 genuinely Bullish days were labelled Bearish. If the desk had shorted (or simply sat out) on those signals, it missed or bet against days that averaged +22.5% over the following quarter. Precision on the Bearish call and recall on the Bullish call are where the money lives — exactly the class-asymmetric costs Chapter 18 taught us to price before deploying anything.

💡
Pro Tip — accuracy is the wrong headline for three-class finance: 46% "accuracy" conceals that the model is nearly twice as useful as a warning system (Bearish recall 53%) as it is as a stock-picker (Bullish precision 44.5%). Report the class that matches the use case — or the cost-weighted mix, as the capstone did with dollars.

7. A Trader Reads the Tree

The whole point of choosing this model: its logic can be printed. The champion's first three levels (the full tree is depth 7 with 82 leaves — Figure 4):

|--- ATR <= 6.94
|   |--- ATR <= 0.82
|   |   |--- MACD <= -0.45
|   |   |   |--- Volatility <= 0.69:  class: Bullish
|   |   |   |--- Volatility >  0.69:  [deeper]
|   |   |--- MACD > -0.45
|   |   |   |--- MA_Crossover <= 0.50: [deeper]
|   |   |   |--- MA_Crossover >  0.50: [deeper]
|   |--- ATR > 0.82 ... (calm-regime branches)
|--- ATR > 6.94
|   |--- MA_Crossover <= 0.50
|   |   |--- MACD <= 8.12 ...
|   |--- MA_Crossover > 0.50
|   |   |--- MACD <= -16.10:  class: Bullish
|   |   |--- MACD > -16.10 ...

Listing 3 — export_text(best_tree, max_depth=3), abbreviated. The tree's first question is about violence, not direction.

A trader's reading: the tree's first question is "how violent is this stock?" — ATR at the root — and only then does it ask about trend (MACD, golden cross). Even the counterintuitive leaf helps teach: in the high-ATR branch, days with extremely negative MACD (≤ −16, a severe drawdown) resolve to Bullish — the tree rediscovering mean-reversion inside turbulence: after a violent fall, the next quarter is more often a top-third outcome than a bottom-third one. Rules you can argue with are the product; that is why this project is a tree.

Decision tree diagram, first four levels, root split on ATR
Figure 4 — The tuned tree (all 82 leaves exist below; the first four levels are drawn). Colour intensity is class purity; the root's split on ATR ≤ 6.94 is the story of this dataset.

7.1 What mattered — and the noise experiment

Horizontal bar chart of feature importances; technical features in blue dominate, noise fundamentals in green near zero
Figure 5 — Feature importance. Blue: real technical indicators. Green: the template's random "fundamentals". The tree is not fooled.
FeatureImportanceReading
ATR0.346v volatility regime is the single strongest clue to the next quarter
MACD0.311trend, with the two together carrying two-thirds of the tree's decisions
MA_Crossover0.105the golden cross earns its keep, modestly
RSI · Volatility · Volume_Ratio · Momentum0.072 · 0.063 · 0.029 · 0.022supporting cast
all eight noise "fundamentals"≤ 0.012 eachEarnings_Growth: exactly 0.0000

The green bars are the lesson inside the lesson. The template's fundamental columns are random numbers, drawn uniform — and the tree, given fifteen features, allocated essentially zero decision weight to all eight of them (largest share 1.2%, one feature literally never used). Information gain cannot be counterfeited: if a column carries no mutual information with the label, splitting on it buys nothing. Replace those green bars with real, point-in-time fundamentals and the chart becomes the genuine experiment — does value+quality data add to what the chart already knows? That is the open door this project leaves for its Random Forest sequel.

⚠
Warning — the dollar-scale trap: ATR and MACD are measured in dollars, and our eight stocks span ~$60 (WMT) to ~$500 (MSFT) — so "ATR ≤ 6.94" partly asks which stock is this? rather than how violent is today? The tree's favourite features are quietly ticker-identifiers. The professional fix is scale-free engineering — ATR/Close, MACD/Close — so a 2% daily range means the same thing everywhere. §8 makes this upgrade #2.

8. Honest Limits — and the Professional Upgrades

The original assignment guide told students to expect 65–75% accuracy. Our tuned, bug-fixed, real-data tree scored 46.2% against a 34.3% baseline. Both numbers are correct — and the gap between expectation and reality is the most valuable content in this lesson. Why so hard?

The upgrade list, in order of expected payoff:

  1. Walk-forward split: train ≤ 2023, validate 2024, test 2025–26; then roll. Turns the evaluation from "can it memorise regimes" into "could it have made money in sequence".
  2. Scale-free features: ATR/Close, MACD/Close, Volatility/Close. One-line change, removes the ticker-identity leak.
  3. Real point-in-time fundamentals: P/E and ROE as they were on the day — not today's ratios pasted onto history, which would be textbook look-ahead bias (Chapter 3's rule wearing a valuation costume).
  4. More names: 30–50 stocks (the guide's own advice) — more cross-sectional variety, less single-name regime risk.
  5. An ensemble: the Random Forest on identical features is this project's designed sequel (Project 2) — same labels, same snapshot, one hypothesis: does averaging 100 noisy trees beat reading one honest tree?
🌎
Real World: quant desks run exactly this loop — features, labels, honest evaluation, and a hard conversation with the efficient-market prior — before a single dollar moves. The deliverable that survives review is rarely "a model that predicts"; it is "a measured edge, with its decay documented". This project's 46.2% ± honest caveats is that kind of artifact.
📋 Stable content — Reviewed: October 2026

9. The Full Implementation

The complete canonical pipeline — the one that produced every number in this lesson. It reads the bundled snapshot by default and fetches live data only when asked.

"""Decision tree stock-momentum classifier — canonical version."""
import numpy as np, pandas as pd
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.tree import DecisionTreeClassifier, export_text, plot_tree
from sklearn.metrics import (confusion_matrix, accuracy_score,
                             classification_report)

SNAPSHOT = 'webbook/data/equity_research_prices_8tickers.csv'
FWD_DAYS = 63                                   # 3 months of trading days
FEATS = ['RSI', 'MACD', 'MA_Crossover', 'Momentum', 'ATR', 'Volatility',
         'Volume_Ratio', 'PE_Ratio', 'ROE', 'Earnings_Growth',
         'Debt_to_Equity', 'Dividend_Yield', 'Profit_Margin',
         'Current_Ratio', 'Revenue_Growth']

# ---- 1. data: snapshot by default; live fetch is the volatile path (§10)
try:
    df = pd.read_csv(SNAPSHOT, index_col=0, parse_dates=True)
except FileNotFoundError:
    import yfinance as yf
    frames = []
    for t in ['AAPL','MSFT','GOOGL','TSLA','JPM','XOM','JNJ','WMT']:
        g = yf.download(t, start='2020-01-01', end='2026-10-01',
                        progress=False, auto_adjust=True)
        if isinstance(g.columns, pd.MultiIndex):          # yfinance >= 0.2
            g.columns = g.columns.get_level_values(0)
        frames.append(g[['Open','High','Low','Close','Volume']].assign(Ticker=t))
    df = pd.concat(frames)

# ---- 2. features: ALWAYS per ticker (§4's seam fix)
parts = []
for t, g in df.groupby('Ticker'):
    g = g.sort_index().copy()
    g['SMA50'], g['SMA200'] = g['Close'].rolling(50).mean(), g['Close'].rolling(200).mean()
    g['MA_Crossover'] = (g['SMA50'] > g['SMA200']).astype(float) \
                          .where(g['SMA200'].notna())     # fix 3: no fake death-cross
    d = g['Close'].diff()
    g['RSI'] = 100 - 100/(1 + d.where(d>0,0).rolling(14).mean()
                            / -d.where(d<0,0).rolling(14).mean())
    g['MACD'] = g['Close'].ewm(span=12).mean() - g['Close'].ewm(span=26).mean()
    g['Momentum'] = g['Close'].pct_change(20) * 100
    tr = pd.concat([g['High']-g['Low'], (g['High']-g['Close'].shift()).abs(),
                    (g['Low']-g['Close'].shift()).abs()], axis=1).max(axis=1)
    g['ATR'] = tr.rolling(14).mean()
    g['Volatility'] = g['Close'].rolling(20).std()
    g['Volume_Ratio'] = g['Volume'] / g['Volume'].rolling(20).mean()
    parts.append(g)
df = pd.concat(parts).sort_index()

# ---- 3. noise fundamentals (the §7 experiment; replace with real data!)
rng = np.random.seed(42); n = len(df)
for col, lo, hi in [('PE_Ratio',10,35),('ROE',5,30),('Earnings_Growth',-10,50),
                    ('Debt_to_Equity',0.1,2.0),('Dividend_Yield',0,5),
                    ('Profit_Margin',5,40),('Current_Ratio',1.0,3.0),
                    ('Revenue_Growth',-20,40)]:
    df[col] = np.random.uniform(lo, hi, n)

# ---- 4. labels: forward terciles, per ticker, NaN-aware
df['FwdRet'] = df.groupby('Ticker')['Close'].shift(-FWD_DAYS) / df['Close'] - 1
lo, hi = df['FwdRet'].quantile([0.33, 0.67])
df['Label'] = pd.cut(df['FwdRet'] * 100, [-np.inf, lo, hi, np.inf],   # fix 2: pd.cut
                     labels=['Bearish','Neutral','Bullish'])
d = df.dropna(subset=FEATS + ['Label']).copy()   # fix 1+2+3 fallout: 11,464 rows
d['Label'] = d['Label'].astype(str)
X, y = d[FEATS].values, d['Label'].values

# ---- 5. split, tune, evaluate (Chapter 31 discipline)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25,
                                      random_state=42, stratify=y)
grid = GridSearchCV(DecisionTreeClassifier(random_state=42),
    {'max_depth':[3,4,5,6,7], 'min_samples_split':[10,15,20,30],
     'min_samples_leaf':[5,10,15], 'criterion':['gini','entropy']},
    cv=5, scoring='accuracy', n_jobs=-1).fit(Xtr, ytr)
tree = grid.best_estimator_
print(grid.best_params_, round(grid.best_score_, 4))
print('test acc:', round(accuracy_score(yte, tree.predict(Xte)), 4))
print(classification_report(yte, tree.predict(Xte)))
print(export_text(tree, feature_names=FEATS, max_depth=3))

Listing 4 — The canonical script. Snapshot-first, per-ticker windows, pd.cut labels, honest dropna.

💡
Pro Tip — expected output: best params {gini, 7, 10, 10} at CV 0.4660; test accuracy 0.4616; the classification report and depth-3 rules exactly as printed in §5–7. If your numbers differ after refetching live data, that is the snapshot lesson of §10 doing its job.
⚠ Volatile content — Reviewed: October 2026 · Next review: January 2027

10. Data, Reproducibility & Refresh

🛠 Your TurnProject extensions

The desk read your tree and has four follow-ups and one dare. Each extension below is a real change to Listing 4 — predict the effect before you run, then reconcile.

Tasks

  1. Verify the hand RSI: rebuild §2.1's 14-change example in code (gains 15, losses 4 → 78.9). Then find a real day in the snapshot where AAPL's RSI crossed below 30 and check what the next quarter's label was.
  2. Reintroduce bug #2: swap pd.cut back to the template's nested np.where. How many rows? What happens to Neutral's recall? Explain the direction of the change.
  3. Scale-free features (upgrade #2): replace ATR, MACD, Volatility with ATR/Close, MACD/Close, Volatility/Close ×100. Does ATR stay the root? Does test accuracy move? Which importances change most?
  4. Walk-forward split (upgrade #1): train on rows dated ≤ 2023-12-31, test on ≥ 2024-01-01 (no shuffle, no stratify). Report the accuracy drop versus the random split, and say in one sentence what the drop measures.
  5. The dare — a tradable metric: using predict_proba, take only days where the Bullish probability exceeds 0.5 and compute the mean actual forward return of that subset versus all days. Is the model's confidence sorted by outcome?
  6. Prep the sequel: swap DecisionTreeClassifier for RandomForestClassifier(n_estimators=200) with identical features and labels. Note the accuracy — Project 2 will ask why.
Expected outcomes (from this canonical setup)

2: ~500 extra "Neutral" rows appear (13,400-row territory) and Neutral recall inflates toward 0.7 — the fake labels are easy because unknown days are scattered uniformly. 3: ATR/Close typically yields a smaller but cleaner root (often RSI or MACD-ratio takes over) and accuracy moves by a point or two in either direction — the ticker-identity leak is real but was not the whole edge. 4: expect the largest single drop of any extension (commonly 3–8 points): the random split was lending regime knowledge. That number is the honesty discount. 5: the high-confidence Bullish subset's mean forward return should exceed the all-days mean of +6.9% — directionally, always check the count: conviction on 46% accuracy is thin soup, which is the point. 6: the forest lands a few points above the single tree on the same split — hold that thought for Project 2, where bagging explains why and interpretability prices the trade.

🎯 Key Takeaways

1

Technical indicators are compressions of price history: RSI balances up-days vs down-days (hand: RS 3.75 → 78.9), MACD compares two memory speeds, ATR measures violence without direction.

2

Momentum labels look forward by design — terciles of the 63-day-ahead return. "Bullish" means top-third outcome (≥ +11.2% here), and the last quarter of each ticker is honestly unlabelable.

3

The template's three silent bugs — cross-ticker seams, NaN→"Neutral", fake death-crosses — contaminated 1,936 rows without a single error message. Audit windows, shifts and label edges.

4

Real results: best tree {gini, depth 7, 82 leaves} from 600 fits on a 0.0014-flat plateau; test accuracy 46.2% vs a 34.3% baseline, 4.5-point train-test gap — generalising, honest, modest.

5

The tree is not fooled: eight random "fundamentals" received ≤1.2% importance each (one exactly zero), while ATR + MACD carried two-thirds. Information gain cannot be counterfeited.

6

46%, not 75%, is the market telling you something: technical-only features hold a thin 3-month edge. The professional path is walk-forward splits, scale-free features, real point-in-time fundamentals.

Test Your Understanding

30 questions across finance concepts, the code, the real results and the honest limits. Choose one answer per question, then check your answers.

Q1.This project's deliverable is best described as:

Q2.The decisive reason for choosing a decision tree over a neural network here:

Q3.An RSI reading of 78.9 means:

Q4.MACD is computed as:

Q5.A "golden cross" is:

Q6.ATR differs from every trend indicator in this project because it measures:

Q7.A Volume_Ratio of 2.0 says:

Q8.The label for trading day t is constructed from:

Q9.Why tercile thresholds (33rd/67th percentiles) rather than fixed cuts like ±5%?

Q10.The leakage rule for time-ordered data in this project:

Q11.Template landmine #1 — the cross-ticker seam:

Q12.Template landmine #2 — why does the template label ~500 unknown days as "Neutral"?

Q13.Template landmine #3 — the fake death cross:

Q14.(Real) The dataset journey:

Q15.(Real) The grid search:

Q16.(Real) Reading the top of the leaderboard:

Q17.(Real) The headline result:

Q18.(Real) Bullish precision of 0.445 in trading language:

Q19.(Real) Bearish recall of 0.526 means the model:

Q20.(Real) The most expensive cell of the confusion matrix:

Q21.(Real) The feature-importance headline:

Q22.(Real) The noise-fundamentals experiment showed:

Q23.The dollar-scale trap (§7's warning):

Q24.Why 46% rather than the guide's hoped 65–75%?

Q25.The random train/test split's specific danger here:

Q26.(Hand) Fourteen changes: gains sum 15, losses sum 4. The RSI is:

Q27.The bundled CSV snapshot exists so that:

Q28.The yfinance detail the template explicitly handles:

Q29.If you upgrade to real fundamental data, the critical discipline is:

Q30.The most durable lesson of this project: