Model Framework
- Commodity markets have entered a new phase of volatility, shaped by shifting supply chains, weather extremes, and evolving central bank policies. In this environment, identifying persistent cyclical patterns is increasingly challenging. Yet, the empirical record shows that commodities often display recurring, seasonally-driven price behaviour.
- At Cordoba, we built a systematic asset allocation framework that seeks to capture and front-run commodity sector seasonality, using transparent signals and robust execution.
Seasonality Rotation Model
- Our approach begins with a universe of liquid US-listed commodity ETFs, including proxies for agriculture (DBC), base metals (CPER), energy (DBO), and precious metals (GLD). We collect a decade of daily data, then aggregate it to a monthly frequency to minimise noise and focus on persistent trends.
Methodology of Signal modelling
We examine four signalling regimes:
- Classic Seasonality: For each ETF, By looking at how each ETF performed during the same month in previous years, we can identify months that typically offer an advantage. For example, if gold has historically rallied every February, our model will spot this pattern and suggest a higher allocation to gold in February. We call this our “X-12” method because it compares each month to its own history over the past 12 years.
- Front-Running Seasonality: Markets are quick to adapt, and many professional investors already act on these calendar effects. To stay ahead of the crowd, we’ve designed a signal that anticipates where money is likely to flow next. Rather than simply following last year’s trends, our model looks forward, analysing the performance of the next calendar month in previous years.
- Bayesian Adjustment: Rather than blindly following historical averages, our strategy incorporates a layer of statistical learning known as Bayesian updating. This means we give more weight to signals that have recently been accurate, and less to those that haven’t performed as expected. If a seasonal pattern is breaking down or changing due to unusual market events, the model can quickly adapt by reducing exposure to outdated trends and increasing focus on what’s working now.
- Machine Learning Forecasting: we employ a Random Forest regressor, combining lagged returns, momentum, volatility, and seasonal indicators to learn nonlinear relationships and cross-asset dependencies.
Portfolio Construction & Allocation
- To decide how much to invest in each commodity ETF, we use what’s called a softmax function. In simple terms, this method looks at the strength of our signals for each investment and turns them into smooth, proportional weights. Think of it as a way to “let the best ideas speak the loudest”. The investments with the strongest signals get the biggest allocations, but we never put all our eggs in one basket. Even the lower-ranked ideas keep a smaller place in the portfolio, which helps us stay diversified and avoid the risk of concentrating too much in one area.
- Unlike rigid systems that might jump from one extreme position to another, softmax allows our allocations to adjust gradually as the market environment and our model’s signals change.
Backtesting

- From 2020’s COVID shock through the inflationary surge of 2021–2024, all strategies initially facing sharp drawdowns before rebounding as investors sought safe haven assets like gold and diversified commodities. As inflation accelerated post-pandemic, systematic models, especially the ML and Bayesian frameworks which captured rising commodity trends well and delivered superior risk-adjusted returns.

- The performance table shows that the machine learning regime delivered the highest annualised return (CAR, 8.9%), best Sharpe ratio (0.57), and strongest Calmar ratio (0.37), outperforming classic seasonality (CAR 8.0%, Sharpe 0.51, Calmar 0.31), front-run (CAR 7.0%), and Bayesian (CAR 7.8%). All strategies maintained volatility near 15–16% and capped drawdowns around -25%, with robust risk controls.
Bottom Line
- At Cordoba, we believe resilient portfolios are now essential as supply chains and resource security become front-page risks. Our adaptive, data-driven strategies aim to capture commodity uptrends while protecting against shocks like China’s export controls and post-pandemic volatility.
- We’re actively diversifying into non-China critical minerals and hedging supply risks, prioritising long-term resilience over short-term gains. In a new era where commodities drive diplomacy and market stability, proactive diversification and systematic adaptation are key to seizing tomorrow’s opportunities.
Code Snippet and Implementation
Python
# ==========================================
# DISCLAIMER:
# This code is for research and educational purposes only.
# No execution, trading, or investment decision should be based on these outputs.
# Use of this code may result in financial loss.
# Any loss from executions are not affiliated with by Cordoba Capital.
# ==========================================
# ==========================================
# SIGNAL FUNCTIONS
# ==========================================
def seasonality_percentile(returns, lag=12):
signals = pd.DataFrame(index=returns.index, columns=returns.columns)
for etf in returns.columns:
for i in range(lag, len(returns)):
compare_month = returns.index[i].month
hist_rets = returns[etf][:i]
mask = (hist_rets.index.month == compare_month)
month_rets = hist_rets[mask][-lag:]
if len(month_rets) == 0:
continue
percentile = month_rets.rank(pct=True)[-1]
signals.loc[returns.index[i], etf] = percentile
return signals.astype(float)
def front_run_signal(returns, lag=12):
signals = pd.DataFrame(index=returns.index, columns=returns.columns)
for etf in returns.columns:
for i in range(lag+1, len(returns)):
this_month = returns.index[i].month
next_month = (this_month % 12) + 1
hist_rets = returns[etf][:i-1]
mask = (hist_rets.index.month == next_month)
month_rets = hist_rets[mask][-lag:]
if len(month_rets) == 0:
continue
last_year_ret = month_rets[-1]
percentile = month_rets.rank(pct=True)[-1]
signals.loc[returns.index[i], etf] = percentile - last_year_ret
return signals.astype(float)
def bayesian_update_seasonality_signal(returns, lag=12, alpha=0.1):
signals = pd.DataFrame(index=returns.index, columns=returns.columns)
for etf in returns.columns:
for i in range(lag, len(returns)):
this_month = returns.index[i].month
hist_rets = returns[etf][:i]
mask = (hist_rets.index.month == this_month)
month_rets = hist_rets[mask][-lag:]
if len(month_rets) == 0:
continue
prior_mean = month_rets.mean()
if i - lag >= 1:
last_idx = returns.index[i - lag]
actual = returns[etf][last_idx]
forecast = prior_mean
error = actual - forecast
else:
error = 0
signal = alpha * prior_mean + (1 - alpha) * error
signals.loc[returns.index[i], etf] = signal
return signals
def build_features(returns):
df = pd.DataFrame(index=returns.index)
for col in returns.columns:
df[f"{col}_lag1"] = returns[col].shift(1)
df[f"{col}_mom3"] = returns[col].rolling(3).mean().shift(1)
df[f"{col}_vol12"] = returns[col].rolling(12).std().shift(1)
df[f"{col}_seasonal"] = returns[col].shift(12)
return df
def ml_regressor_signal(returns):
signals = pd.DataFrame(index=returns.index, columns=returns.columns)
X_full = build_features(returns)
for col in returns.columns:
y = returns[col].shift(-1)
X = X_full[[f"{col}_lag1", f"{col}_mom3", f"{col}_vol12", f"{col}_seasonal"]]
df = pd.concat([X, y], axis=1).dropna()
X_clean = df[[f"{col}_lag1", f"{col}_mom3", f"{col}_vol12", f"{col}_seasonal"]]
y_clean = df[col]
if len(X_clean) < 10:
continue
reg = RandomForestRegressor(n_estimators=50, random_state=42)
reg.fit(X_clean, y_clean)
preds = reg.predict(X_clean)
signals.loc[X_clean.index, col] = preds
return signals
# ==========================================
# WEIGHTING AND BACKTESTING FUNCTIONS
# ==========================================
def softmax_weights(signal_df, gamma=1.0):
weights = pd.DataFrame(index=signal_df.index, columns=signal_df.columns)
for idx, row in signal_df.iterrows():
sigs = row.fillna(0).values
exp_sigs = np.exp(gamma * sigs)
if exp_sigs.sum() == 0:
w = np.zeros_like(sigs)
else:
w = exp_sigs / exp_sigs.sum()
weights.loc[idx] = w
return weights.astype(float)
def backtest(returns, weights):
weights = weights.shift(1).dropna()
common_idx = returns.index.intersection(weights.index)
returns = returns.loc[common_idx]
weights = weights.loc[common_idx]
port_ret = (returns * weights).sum(axis=1)
return port_ret
def performance_metrics(port_ret):
car = (1 + port_ret).prod() ** (12 / len(port_ret)) - 1
vol = port_ret.std() * (12 ** 0.5)
sharpe = car / vol if vol != 0 else float('nan')
dd = (port_ret.cumsum() - port_ret.cumsum().cummax()).min()
calmar = car / abs(dd) if dd != 0 else float('nan')
return car, vol, sharpe, dd, calmar





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