Model Framework
- In traditional factor mining, researchers hand craft factor formulas and then combine many single factors with predefined weights. Manually creating factor expressions from vast raw price-volume data based on investing โintuitionโ is extremely labour-intensive. At the integration stage people often use linear schemes such as IC/IR weighted averages, which limits the space of possible factor interactions.
- At Cordoba, we utilise convolutional neural network (CNN) to automate the factor mining process. The core feature extraction tool is the convolutional kernel which is a weighted matrix that performs cross-relation on the raw input. We can shape raw OHLCV time series data into a 2D matrix and extract feature with such kernels.
Convolution Neural Network (CNN) and Our Methodology

- A CNN stacks multiple convolution layers and pooling layers. Convolution layers apply many learnable kernels to small, overlapping regions of the image to extract local features. Pooling layers merge these local features, gradually building more abstract, higher-level representations. Finally, one or more fully connected layers synthesize the aggregated information to make predictions (e.g., classify the image).

- We treat per-stock price/volume information the same way a CNN treats pixels in an image. The price-volume data for a single stock from t-n through t are stacked into one data image. At a single market snapshot, you might have 500 stocks (use all sp500 stocks), so you would feed the network 500 such data images along with 500 corresponding return labels.
Data Preparation
- For this model, we focus on the S&P 500 ETF (SPY) as a single-security universe. The raw input consists of unprocessed OHLCV data, which is transformed into 5ร30 โdata imagesโ by stacking 30 days of historical price and volume features for each sample. The prediction target is the standardized forward return at both 5-day and 10-day horizons. Our data spans from January 1, 2010, to January 1, 2023. We adopt a rolling in-sample window of 1,500 trading days (approximately six years), shifting the window forward every two trading days to generate training samples. For each training window, data is split chronologically into training and validation sets in a 1:1 ratio, with the earlier half used for training and the latter half for validation.
Feature Extraction
- Custom โfeature extraction layersโ used inside the Cordoba Terminal.
| Layer Name | Description |
| ts_corr(X, Y, d) | Measures the co-movement strength between two signals over d days |
| ts_cov(X, Y, d) | Measures the joint variability of two signals with scale |
| ts_stddev(X, d) | Captures the volatility of a single signal |
| ts_zscore(X, d) | Computes the z-score of the signal over the past d days |
| ts_return(X, d) | Calculates the rolling d-day return |
| ts_decaylinear(X, d) | Emphasizes recent values in a rolling average using linearly decayed weights |
| ts_min(X, d) | Rolling lowest value over the past d days |
| ts_max(X, d) | Rolling highest value over the past d days |
| ts_sum(X, d) | Rolling sum, e.g., total volume over d days |
| BN (BatchNorm) | Normalizes feature channels to stabilize training and ensure comparability |
- Here is an example of feature extract. The ts_corr(X, Y, d) layer is a custom network operation designed to extract meaningful relationships between different financial features over time. When d = 3, it computes the rolling Pearson correlation between every pair of features over a 3-day window. This sliding window moves across the time axis with a configurable stride (e.g., stride = 1), similar to how convolutional filters move across images in CNNs.
- However, unlike traditional CNNs which perform local convolutions (i.e., they only process adjacent features), ts_corr(X, Y, d) evaluates correlations across all combinations of features, even if they are not adjacent. For example, if there are 5 input features (like Open, High, Low, etc.), the layer computes the correlation for each of the 10 unique feature pairs (since 5 choose 2 = 10) at each time window.

- The result is a two-dimensional feature map where one axis represents the time windows, and the other axis represents the feature pair combinations. This output can then either be flattened and passed to a fully connected layer or further processed by stacking additional custom layers like another ts_corr, ts_cov, or pooling layer. This nesting of operators enables the model to learn highly abstract representations in a hierarchical manner, similar to how CNNs build up from low-level to high-level features.
Backtesting & Accuracy
The back-test reveals a marked limitation in the Cordoba Terminal: its predicted return series is almost flat after 100 days of prediction period. It behaves like a constant with only a handful of noticeable spikes. This plateau-like output, while still yielding a modest correlation with realised returns, is far from the nuanced signal desired in a production-grade strategy. Two architectural factors appear to underlie this behaviour.

First, the network is intentionally lightweight, which comprises seven parallel feature-extraction operators, three parallel pooling channels, and a single fully-connected layer. This sharply constrains the parameter space. As a result, the optimiser tends to minimise mean-squared error by converging on a near-constant prediction surface.

Second, the pooling design (max, mean, and min over each temporal slice) collapses three successive time-steps into a single scalar, inadvertently discarding valuable temporal structure that could differentiate observations. The net effect is a model that under-fits the rich dynamics of the input data.

While the cumulative return curve generated by the Cordoba Terminal still shadows the benchmark in direction, future iterations should consider deeper feature stacks, temporal-aware pooling mechanisms (such as attention layers or recurrent summarisation), and enhanced regularisation techniques to produce more expressive and predictive return signals.
Code Snippet and implementation
For full strategy implementation, please subscribe to the Cordoba Terminal service. Any losses incurred from executing the following content are the sole responsibility of the user.
# =============================================================
# Cordorba Terminal โ CNNโstyle alphaโfactor learner
# =============================================================
"""
Endโtoโend example that:
1. Pulls OHLCV data via Qlib (US bundle)
2. Builds rolling windows ("data images")
3. Defines CordorbaInception (feature extractor) + CordorbaTerminal (head)
4. Trains on nextโday returns & visualises loss
5. Runs a trivial longโonly signal backโtest against buyโandโhold
โ ๏ธ Production usage will require proper signal alignment, slippage, risk
& transactionโcost modelling. This script is purely educational.
"""
# -------- 0. Imports & environment ----------------------------------------
import qlib
from qlib.config import REG_US
from qlib.data import D
import pandas as pd
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
from torch import optim
from torchsummary import summary
from tqdm import tqdm
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
plt.style.use("Solarize_Light2")
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
# -------- 1. Data acquisition --------------------------------------------
qlib.init(provider_uri="~/.qlib/qlib_data/us_data", region=REG_US)
SYMBOL, START, END = ["SPY"], "2010-01-01", "2023-01-01"
FIELDS = ["$open", "$high", "$low", "$close", "$volume"]
df = D.features(SYMBOL, FIELDS, START, END, freq="day").droplevel("instrument")
df["return"] = df["$close"].pct_change()
df.dropna(inplace=True)
# -------- 2. Feature / label windows -------------------------------------
WINDOW = 30
feat = df[FIELDS].values
label = df["$close"].shift(-1) / df["$close"] - 1
X, y = [], []
for i in range(len(df) - WINDOW):
X.append(feat[i:i+WINDOW].T) # (5, WINDOW)
y.append(label.iloc[i+WINDOW])
X, y = np.asarray(X), np.asarray(y)
cutoff = pd.Timestamp("2020-01-01")
idx = df.index[WINDOW:]
train_mask, test_mask = idx <= cutoff, idx > cutoff
def to_tensor(arr):
return torch.from_numpy(arr).float().unsqueeze(1) # (N,1,5,WINDOW)
trainx, trainy = to_tensor(X[train_mask]), torch.from_numpy(y[train_mask]).float().unsqueeze(1)
testx , testy = to_tensor(X[test_mask] ), torch.from_numpy(y[test_mask] ).float().unsqueeze(1)
# -------- 3. Helper functions --------------------------------------------
def generate_combination(n):
comb, comb_rev = [], []
for i in range(1, n):
for j in range(i):
comb.append([i, j])
comb_rev.append([j, i])
return comb, comb_rev
def get_index_list(width, stride):
if width % stride == 0:
return list(np.arange(0, width+stride, stride))
mod = width % stride
return list(np.arange(0, width+stride-mod, stride)) + [width]
combination, combination_rev = generate_combination(5)
index_list = get_index_list(WINDOW, stride=10)
# -------- 4. CordorbaInception module ------------------------------------
class CordorbaInception(nn.Module):
"""Custom feature extractor that mimics CNNโlike sliding ops over timeโseries."""
def __init__(self, comb, comb_rev, idx_list):
super().__init__()
self.comb, self.comb_rev = comb, comb_rev
self.idx_list = idx_list
self.d = len(idx_list) - 1
# batch norm layers
self.bn_ops = nn.ModuleList([nn.BatchNorm2d(1) for _ in range(7)])
# pooling
self.max_pool = nn.MaxPool2d((1, self.d))
self.avg_pool = nn.AvgPool2d((1, self.d))
self.min_pool = nn.MaxPool2d((1, self.d)) # will negate input
self.bn_pool = nn.ModuleList([nn.BatchNorm2d(1) for _ in range(3)])
# ---- stat helpers (use NumPy for convenience, then back to torch) -----
def _window_view(self, mat, start, end):
return mat[..., start:end] # keeps dims (N,1,H,W_slice)
def _ts_std(self, mat):
res = []
for i in range(self.d):
seg = self._window_view(mat, self.idx_list[i], self.idx_list[i+1])
res.append(seg.std(axis=-1, keepdims=True))
return torch.from_numpy(np.concatenate(res, axis=-1))
# (other ts_* functions subscripbe for Cordoba Terminal Services
# ---- forward ---------------------------------------------------------
def forward(self, x):
x_np = x.detach().cpu().numpy()
conv_dummy = torch.from_numpy(x_np) # placeholder for real ops
# Here one would call self._ts_corr4d / _ts_cov4d etc. (omitted)
feature = conv_dummy # shape (N,1,H',W')
feat_flat = feature.flatten(1)
maxp = self.bn_pool[0]( self.max_pool(feature) )
avgp = self.bn_pool[1]( self.avg_pool(feature) )
minp = self.bn_pool[2]( -self.min_pool(-feature) )
pool_flat = torch.cat([maxp, avgp, minp], dim=2).flatten(1)
return torch.cat([feat_flat, pool_flat], dim=1) # (N,702)
# -------- 5. CordorbaTerminal (head network) -----------------------------
class CordorbaTerminal(nn.Module):
def __init__(self, comb, comb_rev, idx_list, fc1=270, fc2=30, drop=0.5):
super().__init__()
self.backbone = CordorbaInception(comb, comb_rev, idx_list)
self.fc1 = nn.Linear(fc1, fc2)
self.fc2 = nn.Linear(fc2, 1)
self.relu = nn.ReLU(); self.dropout = nn.Dropout(drop)
self._init_weights()
def _init_weights(self):
nn.init.xavier_uniform_(self.fc1.weight); nn.init.normal_(self.fc1.bias, 1e-6)
nn.init.xavier_uniform_(self.fc2.weight); nn.init.normal_(self.fc2.bias, 1e-6)
def forward(self, x):
x = self.backbone(x)
x = self.dropout(self.relu(self.fc1(x)))
return self.fc2(x)
# -------- 6. DataLoaders --------------------------------------------------
BATCH = 1024
train_loader = DataLoader(Dataset.from_tensor_slices((trainx, trainy)), batch_size=BATCH)
test_loader = DataLoader(Dataset.from_tensor_slices((testx , testy )), batch_size=BATCH)
# -------- 7. Training -----------------------------------------------------
model = CordorbaTerminal(combination, combination_rev, index_list).to(DEVICE)
summary(model, input_size=(1,5,WINDOW))
criterion = nn.MSELoss()
opt = optim.RMSprop(model.parameters(), lr=1e-4, momentum=0.9, weight_decay=1e-5)
EPOCHS = 5
train_hist, test_hist = [], []
for ep in range(1, EPOCHS+1):
# train
model.train(); tr_loss=0
for xb, yb in train_loader:
xb, yb = xb.to(DEVICE), yb.to(DEVICE)
opt.zero_grad(); loss = criterion(model(xb), yb); loss.backward(); opt.step()
tr_loss += loss.item()
train_hist.append(tr_loss/len(train_loader))
# eval
model.eval(); te_loss=0
with torch.no_grad():
for xb, yb in test_loader:
xb, yb = xb.to(DEVICE), yb.to(DEVICE)
te_loss += criterion(model(xb), yb).item()
test_hist.append(te_loss/len(test_loader))
print(f"Epoch {ep}: train {train_hist[-1]:.4f} | test {test_hist[-1]:.4f}")
# -------- 8. Loss plot ----------------------------------------------------
plt.figure(figsize=(7,4))
plt.plot(train_hist, "r-", label="train")
plt.plot(test_hist , "b--", label="test")
plt.xlabel("Epoch"); plt.ylabel("MSE"); plt.title("CordorbaTerminal loss"); plt.legend();
# -------- 9. Prediction & back-test --------------------------------------
model.eval();
with torch.no_grad():
y_pred = model(testx.to(DEVICE)).cpu().numpy().flatten()
pred_dates = df.index[WINDOW:][test_mask]
pred_series = pd.Series(y_pred, index=pred_dates)
cum_ret_spy = (1 + df["return"]).cumprod() - 1
cum_ret_str = (1 + pred_series.shift().fillna(0)).cumprod() - 1
plt.figure(figsize=(9,5))
plt.plot(cum_ret_spy, label="SPY buy&hold")
plt.plot(cum_ret_str, label="Cordorba signal")
plt.title("cumulativeโreturn comparison"); plt.legend(); plt.grid(True)





Continue reading our research
To continue reading the full note and explore the complete body of our work, visit the Research Library.