Â
1. Learning Objectives
By the end of this lesson, you will be able to:
-
Design and implement a production-grade financial data pipeline with expandable windows.
-
Construct point-in-time feature matrices that completely eliminate look-ahead bias.
-
Compute technical indicators (RSI, MACD, Bollinger Bands, ATR) using vectorised Pandas.
-
Implement feature normalisation using expanding or rolling windows.
-
Create lagged features and interaction features for AI models.
-
Build an efficient backtesting framework with walk-forward validation.
-
Handle corporate actions (splits, dividends) using Pandas adjustment factors.
-
Implement a feature store for reproducible AI model training.
2. The Financial Data Pipeline Architecture
A production-grade pipeline has four stages: Ingestion, Cleaning, Feature Engineering, and Output.
Stage 1: Ingestion
import yfinance as yf
import pandas as pd
from datetime import datetime, timedelta
def ingest_data(tickers, start_date, end_date):
"""
Download adjusted close prices for given tickers.
"""
data = yf.download(tickers, start=start_date, end=end_date)['Adj Close']
data = data.rename(columns={t: t.replace(' ', '_') for t in data.columns})
return data
tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META', 'TSLA']
start = '2015-01-01'
end = '2024-01-01'
raw_data = ingest_data(tickers, start, end)
Stage 2: Cleaning and Corporate Actions
def clean_data(df):
"""
Handle missing values, adjust for corporate actions.
"""
# Check for missing data
missing_pct = df.isna().sum() / len(df) * 100
print(f"Missing data: {missing_pct}")
# Forward fill for weekends/holidays (only if < 5% missing)
if df.isna().sum().max() < 0.05 * len(df):
df = df.fillna(method='ffill')
# Drop any remaining NaN rows
df = df.dropna()
# Adjust for splits (if not already adjusted)
# Note: yfinance returns adjusted close automatically
return df
cleaned_data = clean_data(raw_data)
3. Point-in-Time Feature Construction – The Cardinal Rule
The Principle: For any timestamp t, all features must be computed using ONLY data available strictly before t. No future data can leak into the training set.
3.1 Expanding Window vs Rolling Window
def construct_features_point_in_time(df, lookback_days=252):
"""
Construct features using only past data (expanding window).
This is the SAFEST method for live deployment.
"""
features = pd.DataFrame(index=df.index)
for t in range(lookback_days, len(df)):
# Only use data up to t-1 (strictly past)
past_data = df.iloc[:t-1]
# Compute features using past_data only
features.iloc[t] = compute_features(past_data.iloc[-lookback_days:])
return features
3.2 Time Series Cross-Validation
from sklearn.model_selection import TimeSeriesSplit
def time_series_cv(X, y, n_splits=5, test_size=252):
"""
Walk-forward cross-validation for financial time series.
"""
tscv = TimeSeriesSplit(n_splits=n_splits, test_size=test_size, gap=1)
for fold, (train_idx, val_idx) in enumerate(tscv.split(X)):
X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
# Train model on X_train, validate on X_val
print(f"Fold {fold}: train {len(train_idx)}, val {len(val_idx)}")
# ... train and evaluate ...
3.3 Feature Lagging – Preventing Look-Ahead
def create_lagged_features(df, lags=[1, 2, 3, 5, 10, 20]):
"""
Create lagged features. Feature at time t uses price at t-lag.
"""
df_lagged = df.copy()
for lag in lags:
df_lagged[f'Return_Lag_{lag}'] = df['Return'].shift(lag)
df_lagged[f'Price_Lag_{lag}'] = df['Close'].shift(lag)
return df_lagged
4. Technical Indicators – Vectorised Implementation
4.1 Relative Strength Index (RSI)
def compute_rsi(data, window=14):
"""
RSI = 100 - 100 / (1 + RS)
RS = Average Gain / Average Loss
"""
delta = data.diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
avg_gain = gain.rolling(window=window, min_periods=window).mean()
avg_loss = loss.rolling(window=window, min_periods=window).mean()
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return rsi
4.2 Moving Average Convergence Divergence (MACD)
def compute_macd(data, fast=12, slow=26, signal=9):
"""
MACD = EMA(fast) - EMA(slow)
Signal = EMA(signal, MACD)
Histogram = MACD - Signal
"""
ema_fast = data.ewm(span=fast, adjust=False).mean()
ema_slow = data.ewm(span=slow, adjust=False).mean()
macd = ema_fast - ema_slow
signal_line = macd.ewm(span=signal, adjust=False).mean()
histogram = macd - signal_line
return macd, signal_line, histogram
4.3 Bollinger Bands
def compute_bollinger_bands(data, window=20, num_std=2):
"""
Middle = SMA(window)
Upper = Middle + num_std * Std(window)
Lower = Middle - num_std * Std(window)
%B = (Close - Lower) / (Upper - Lower)
"""
middle = data.rolling(window=window).mean()
std = data.rolling(window=window).std()
upper = middle + num_std * std
lower = middle - num_std * std
pct_b = (data - lower) / (upper - lower)
return upper, middle, lower, pct_b
4.4 Average True Range (ATR)
def compute_atr(high, low, close, window=14):
"""
TR = max(High - Low, |High - Close_prev|, |Low - Close_prev|)
ATR = SMA(TR, window)
"""
tr1 = high - low
tr2 = (high - close.shift()).abs()
tr3 = (low - close.shift()).abs()
true_range = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
atr = true_range.rolling(window=window).mean()
return atr
4.5 On-Balance Volume (OBV)
def compute_obv(close, volume):
"""
OBV = cumulative volume with sign based on price direction
"""
sign = np.sign(close.diff())
obv = (sign * volume).cumsum()
return obv
5. Feature Normalisation with Expanding Windows
def expanding_normalise(data, min_periods=252):
"""
Normalise features using expanding mean and std.
This prevents look-ahead bias.
"""
mean = data.expanding(min_periods=min_periods).mean()
std = data.expanding(min_periods=min_periods).std()
normalised = (data - mean) / std
return normalised, mean, std
def rolling_normalise(data, window=252):
"""
Normalise features using rolling window.
This is more adaptive to regime changes.
"""
mean = data.rolling(window=window).mean()
std = data.rolling(window=window).std()
normalised = (data - mean) / std
return normalised, mean, std
6. Target Engineering – Triple-Barrier Labelling
def triple_barrier_labels(close, upper_threshold=0.02, lower_threshold=0.02, horizon=20):
"""
Implement the Triple-Barrier labelling method (Lopez de Prado).
Returns:
- 1 if upper barrier hit first
- -1 if lower barrier hit first
- 0 if time barrier expires first
"""
labels = np.zeros(len(close))
for i in range(len(close) - horizon):
for j in range(1, horizon + 1):
price_change = (close.iloc[i+j] / close.iloc[i]) - 1
if price_change >= upper_threshold:
labels[i] = 1
break
elif price_change <= -lower_threshold:
labels[i] = -1
break
else:
labels[i] = 0
return labels
7. Feature Store – Reproducible Feature Engineering
A feature store stores computed features for reuse, ensuring that training and inference use identical transformations.
class FeatureStore:
def __init__(self, base_path='./feature_store'):
self.base_path = base_path
os.makedirs(base_path, exist_ok=True)
def compute_features(self, df, feature_set='technical'):
"""
Compute a pre-defined feature set.
"""
features = pd.DataFrame(index=df.index)
if feature_set == 'technical':
# Price-based
features['Return'] = df['Close'].pct_change()
features['Log_Return'] = np.log(df['Close'] / df['Close'].shift(1))
features['High_Low_Ratio'] = df['High'] / df['Low']
features['Close_Open_Ratio'] = df['Close'] / df['Open']
# Technical indicators
features['RSI_14'] = compute_rsi(df['Close'], 14)
features['SMA_20'] = df['Close'].rolling(20).mean()
features['SMA_50'] = df['Close'].rolling(50).mean()
features['SMA_200'] = df['Close'].rolling(200).mean()
features['Price_SMA_20'] = df['Close'] / features['SMA_20'] - 1
features['Price_SMA_50'] = df['Close'] / features['SMA_50'] - 1
# Volatility
features['Vol_20'] = features['Return'].rolling(20).std()
features['Vol_50'] = features['Return'].rolling(50).std()
features['Vol_Ratio'] = features['Vol_20'] / features['Vol_50']
# Volume-based
features['Volume_Change'] = df['Volume'].pct_change()
features['Volume_SMA_20'] = df['Volume'].rolling(20).mean()
features['Volume_Ratio'] = df['Volume'] / features['Volume_SMA_20']
return features
def save_features(self, features, name='features.parquet'):
features.to_parquet(f'{self.base_path}/{name}')
def load_features(self, name='features.parquet'):
return pd.read_parquet(f'{self.base_path}/{name}')
8. Building a Production-Ready Pipeline
class FinancialDataPipeline:
def __init__(self, tickers, start_date, end_date):
self.tickers = tickers
self.start_date = start_date
self.end_date = end_date
self.raw_data = None
self.cleaned_data = None
self.features = None
self.labels = None
def run(self):
"""
Execute the full pipeline.
"""
print("1. Ingesting data...")
self.raw_data = self.ingest()
print("2. Cleaning data...")
self.cleaned_data = self.clean()
print("3. Computing features...")
self.features = self.compute_features()
print("4. Engineering targets...")
self.labels = self.create_labels()
print("5. Creating train/val/test split...")
X_train, X_val, X_test, y_train, y_val, y_test = self.split_data()
return X_train, X_val, X_test, y_train, y_val, y_test
def ingest(self):
return yf.download(self.tickers, start=self.start_date, end=self.end_date)['Adj Close']
def clean(self):
df = self.raw_data.fillna(method='ffill').dropna()
return df
def compute_features(self):
fs = FeatureStore()
features = {}
for ticker in self.tickers:
ticker_data = pd.DataFrame({
'Open': self.cleaned_data[ticker + '_Open'],
'High': self.cleaned_data[ticker + '_High'],
'Low': self.cleaned_data[ticker + '_Low'],
'Close': self.cleaned_data[ticker + '_Close'],
'Volume': self.cleaned_data[ticker + '_Volume']
})
features[ticker] = fs.compute_features(ticker_data)
return pd.concat(features, axis=1)
def create_labels(self):
labels = pd.DataFrame()
for ticker in self.tickers:
close = self.cleaned_data[ticker + '_Close']
labels[ticker] = triple_barrier_labels(close)
return labels
def split_data(self, train_ratio=0.7, val_ratio=0.15):
n = len(self.features)
train_end = int(n * train_ratio)
val_end = int(n * (train_ratio + val_ratio))
X_train = self.features.iloc[:train_end]
X_val = self.features.iloc[train_end:val_end]
X_test = self.features.iloc[val_end:]
y_train = self.labels.iloc[:train_end]
y_val = self.labels.iloc[train_end:val_end]
y_test = self.labels.iloc[val_end:]
return X_train, X_val, X_test, y_train, y_val, y_test
9. Summary for the AI Practitioner
-
Point-in-Time is non-negotiable. Never useÂ
KFold; always useÂTimeSeriesSplit. -
Feature lagging prevents look-ahead bias. Features at timeÂ
t must use data up toÂt-1. -
Technical indicators are computed using vectorised Pandas operations. This is orders of magnitude faster than Python loops.
-
Normalisation must be done with expanding or rolling windows, not the entire dataset. This prevents data leakage.
-
Triple-Barrier labelling is superior to simple binary classification. It accounts for realistic stop-loss/take-profit constraints.
-
Feature stores ensure reproducibility. Save features to Parquet files for reuse across experiments.
-
Pipeline design should be modular: Ingestion → Cleaning → Feature Engineering → Labelling → Splitting.
-
Walk-forward validation is the gold standard for financial AI backtesting.
End of Lessons 3.1 and 3.2
In Lessons 3.3 and 3.4, we will cover Deep Learning with PyTorch/TensorFlow, focusing on building neural networks for financial time series, handling sequential data with LSTMs and Transformers, and implementing custom loss functions for financial objectives.