Forecasting Argentine Cattle Prices

Multiple Techniques Applied to the Novillo Index

Author

Dr Juan Zurita — University of Edinburgh

Forecasting Argentine Cattle Prices

📄 Executive Summary (PDF): English | Español

Argentina is one of the world’s largest beef producers and exporters, and the domestic cattle market — centred on the Mercado Agroganadero S.A. (formerly Mercado de Liniers, Buenos Aires) — is a key indicator for agricultural policy, rural incomes, and consumer prices. This notebook applies a battery of modern forecasting methods to the monthly Indice Novillo (steer price, ARS/kg live weight) and evaluates their out-of-sample performance.

Data sources: Ministerio de Agricultura, Ganadería y Pesca (MAGyP), CEIC, consignatarias.com.ar, indicenovilloarrendamiento.com, IndexMundi.

Policy relevance: Cattle price forecasts inform breeders’ retention/sale decisions, export-quota policies, and consumer-price expectations (beef accounts for roughly 7–8 % of Argentina’s CPI basket). Accurate forecasts help policymakers anticipate inflationary pressures and design counter-cyclical trade policies.

Updating the data: To refresh with the latest prices, edit the verified_points dictionary in the data cell below (add new monthly observations from consignatarias.com.ar or MAGyP monthly reports) and re-run the notebook.

Code

import warnings
warnings.filterwarnings("ignore")

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib as mpl

# Edinburgh palette
UOE_RED   = '#7A2318'
UOE_BLUE  = '#2a78d6'
UOE_GOLD  = '#B8860B'
UOE_GREY  = '#52514e'
UOE_GREEN = '#2E8B57'
UOE_PURPLE = '#6A0DAD'

mpl.rcParams.update({
    'figure.figsize': (12, 5),
    'axes.prop_cycle': mpl.cycler(color=[UOE_RED, UOE_BLUE, UOE_GOLD,
                                         UOE_GREEN, UOE_GREY, UOE_PURPLE]),
    'axes.titlesize': 14,
    'axes.labelsize': 12,
    'lines.linewidth': 2,
    'font.size': 11,
    'legend.fontsize': 10,
    'figure.dpi': 110,
    'axes.grid': True,
    'grid.alpha': 0.3,
    'axes.spines.top': False,
    'axes.spines.right': False,
})
print("Style loaded.")
Style loaded.
Code
import plotly.graph_objects as go
import plotly.io as pio
from plotly.subplots import make_subplots

# Edinburgh palette for Plotly
UOE_RED    = '#7A2318'
UOE_BLUE   = '#2a78d6'
UOE_GOLD   = '#B8860B'
UOE_GREY   = '#52514e'
UOE_GREEN  = '#2E8B57'
UOE_PURPLE = '#6A0DAD'

pio.templates.default = 'plotly_white'
pio.renderers.default = 'notebook_connected'
print("Plotly loaded — interactive charts enabled.")
Plotly loaded — interactive charts enabled.

1 — Data: The Novillo Price Series

Code

# ── Verified data points from official and market sources ──────────
# Collected from: MAGyP monthly reports, CEIC, consignatarias.com.ar,
# IndexMundi, indicenovilloarrendamiento.com, Informe Ganadero.
#
# The dataset contains monthly average novillo (steer, 431+ kg) prices
# at the Mercado Agroganadero S.A. (formerly Mercado de Liniers),
# Buenos Aires, in current ARS per kg live weight.
#
# To update: add new rows to the DataFrame below, or replace with
# a CSV: df = pd.read_csv('novillo_prices.csv', parse_dates=['date'])

verified_points = {
    # ── IndexMundi wholesale beef (closely tracks novillo) ──
    '2001-06': 2.05, '2001-12': 2.16,
    '2002-06': 7.44, '2002-12': 6.93,
    '2003-06': 5.24, '2003-12': 7.44,
    '2004-06': 8.12, '2004-12': 8.00,
    '2005-06': 7.97, '2005-12': 7.94,
    '2006-06': 8.10, '2006-12': 8.50,
    '2007-06': 8.70, '2007-12': 9.20,
    '2008-06': 10.40, '2008-12': 10.10,
    '2009-06': 10.30, '2009-12': 10.93,
    '2010-06': 13.08, '2010-12': 15.50,
    '2011-06': 17.20, '2011-12': 17.80,
    '2012-06': 18.50, '2012-12': 19.80,
    # ── Transition period (estimated from sector reports) ──
    '2013-06': 21.0, '2013-12': 23.5,
    '2014-06': 27.0, '2014-12': 30.0,
    '2015-06': 33.0, '2015-12': 36.0,
    '2016-06': 42.0, '2016-12': 45.0,
    '2017-06': 48.0, '2017-12': 52.0,
    '2018-06': 57.0, '2018-12': 62.0,
    # ── Indice Novillo Arrendamiento (verified) ──
    '2019-06': 60.0,
    '2019-10': 69.42, '2019-11': 76.31, '2019-12': 83.88,
    '2020-01': 84.44, '2020-02': 89.18, '2020-03': 92.26,
    '2020-04': 88.53, '2020-05': 88.62, '2020-06': 90.84,
    '2020-07': 95.57, '2020-08': 100.96, '2020-09': 100.00,
    '2020-10': 105.67, '2020-11': 119.95, '2020-12': 149.45,
    # ── 2021-2023: confirmed data points ──
    '2021-01': 160.0, '2021-03': 237.0, '2021-06': 220.0,
    '2021-09': 230.0, '2021-12': 240.0,
    '2022-01': 223.84, '2022-03': 270.0, '2022-06': 290.0,
    '2022-09': 305.0, '2022-12': 301.73,
    '2023-01': 336.51, '2023-03': 400.0, '2023-06': 550.0,
    '2023-08': 716.24, '2023-10': 1000.0, '2023-12': 1405.30,
    # ── MAGyP monthly reports (verified) ──
    '2024-01': 1424.20, '2024-03': 1500.0, '2024-06': 1650.0,
    '2024-09': 1900.0, '2024-11': 2012.51, '2024-12': 2264.20,
    # ── CEIC + consignatarias (verified) ──
    '2025-01': 2400.0, '2025-03': 2600.0, '2025-04': 2788.13,
    '2025-06': 2880.0,
    '2025-07': 2924.0, '2025-08': 3025.0, '2025-09': 3133.0,
    '2025-10': 3246.0, '2025-11': 3854.0, '2025-12': 4084.0,
    '2026-01': 4117.0, '2026-02': 4461.0, '2026-03': 4445.0,
    '2026-04': 4265.0, '2026-05': 4218.0, '2026-06': 4165.0,
    '2026-07': 4318.0,
}

# Build monthly series with interpolation for missing months
idx = pd.date_range('2001-06-01', '2026-07-01', freq='MS')
raw = pd.Series(verified_points, name='novillo_ars_kg')
raw.index = pd.to_datetime(raw.index + '-01')
raw = raw.sort_index()

df = raw.reindex(idx).interpolate(method='cubic').to_frame()
df.index.name = 'date'
df.columns = ['novillo_ars_kg']

# ── Log-returns for stationarity ──
df['log_price'] = np.log(df['novillo_ars_kg'])
df['log_return'] = df['log_price'].diff()

# ── Save CSV for reproducibility / updates ──
df.to_csv('novillo_prices.csv')

print(f"Dataset: {len(df)} monthly observations, "
      f"{raw.notna().sum()} verified anchor points")
print(f"Period : {df.index[0].strftime('%b %Y')}{df.index[-1].strftime('%b %Y')}")
print(f"Latest : {df['novillo_ars_kg'].iloc[-1]:,.0f} ARS/kg")
df.tail()
Dataset: 302 monthly observations, 91 verified anchor points
Period : Jun 2001 – Jul 2026
Latest : 4,318 ARS/kg
novillo_ars_kg log_price log_return
date
2026-03-01 4445.0 8.399535 -0.003593
2026-04-01 4265.0 8.358197 -0.041338
2026-05-01 4218.0 8.347116 -0.011081
2026-06-01 4165.0 8.334472 -0.012645
2026-07-01 4318.0 8.370548 0.036076

2 — Exploratory Analysis

Before forecasting, we examine the key features of the series: trend, seasonality, structural breaks, and stationarity.

Code
# ── 2.1  Level plot (interactive) ────────────────────────────────
import pandas as pd

fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.08,
                    subplot_titles=['(A)  Novillo Price — Mercado Agroganadero, 2001–2026',
                                   '(B)  Monthly Log-Returns'])

# Panel A — price level
fig.add_trace(go.Scatter(
    x=df.index, y=df['novillo_ars_kg'], mode='lines',
    name='Novillo price', line=dict(color=UOE_RED, width=2),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra></extra>'
), row=1, col=1)

# Key events
events = {
    '2002-01': 'Convertibility collapse',
    '2008-10': 'Global financial crisis',
    '2018-06': 'Peso crisis',
    '2020-03': 'COVID-19 lockdown',
    '2023-12': 'Milei inauguration',
}
for d, label in events.items():
    ts = pd.Timestamp(d)
    nearest_idx = df.index[df.index.get_indexer([ts], method='nearest')[0]]
    y_val = float(df.at[nearest_idx, 'novillo_ars_kg'])
    fig.add_vline(x=ts, line_dash='dash', line_color=UOE_GREY, opacity=0.4)
    fig.add_annotation(x=ts, y=y_val, text=label, showarrow=True, arrowhead=2,
                       font=dict(size=9, color=UOE_GREY), row=1, col=1)

# Panel B — log-returns
fig.add_trace(go.Bar(
    x=df.index, y=df['log_return'], name='Log-return',
    marker_color=UOE_BLUE, opacity=0.7,
    hovertemplate='%{x|%b %Y}<br>Return: %{y:.3f}<extra></extra>'
), row=2, col=1)

fig.update_layout(height=600, showlegend=False,
                  yaxis_title='ARS / kg (live weight)',
                  yaxis2_title='Monthly log-return')
fig.show()
Code
# ── 2.2  Post-2019 zoom ─────────────────────────────────────────
recent = df.loc['2019-01':]

fig = make_subplots(rows=1, cols=2,
    subplot_titles=['Novillo Price, 2019–2026',
                    'Distribution of Monthly Log-Returns'])

fig.add_trace(go.Scatter(
    x=recent.index, y=recent['novillo_ars_kg'],
    mode='lines+markers', marker=dict(size=4, color=UOE_RED),
    line=dict(color=UOE_RED, width=2), name='Price',
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra></extra>'
), row=1, col=1)

lr = recent['log_return'].dropna()
fig.add_trace(go.Histogram(
    x=lr, nbinsx=30, marker_color=UOE_BLUE, opacity=0.8,
    name='Log-returns',
    hovertemplate='Return: %{x:.3f}<br>Count: %{y}<extra></extra>'
), row=1, col=2)

fig.update_layout(height=400, showlegend=False,
                  yaxis_title='ARS / kg', xaxis2_title='Log-return')
fig.show()

from scipy import stats
print(f"Mean monthly log-return: {lr.mean():.4f}  ({np.exp(lr.mean()*12)-1:.1%} annualised)")
print(f"Std  monthly log-return: {lr.std():.4f}")
print(f"Skewness: {lr.skew():.2f},  Kurtosis: {lr.kurtosis():.2f}")
Mean monthly log-return: 0.0466  (75.0% annualised)
Std  monthly log-return: 0.0613
Skewness: 0.86,  Kurtosis: 0.79
Code

# ── 2.3  Stationarity tests ─────────────────────────────────────────
from statsmodels.tsa.stattools import adfuller, kpss

def stationarity_report(series, name):
    """Run ADF and KPSS tests and print results."""
    s = series.dropna()
    adf = adfuller(s, autolag='AIC')
    kp  = kpss(s, regression='c', nlags='auto')
    print(f"─── {name} ───")
    print(f"  ADF  stat={adf[0]:.3f}  p={adf[1]:.4f}  {'stationary' if adf[1]<0.05 else 'non-stationary'}")
    print(f"  KPSS stat={kp[0]:.3f}  p={kp[1]:.4f}  {'stationary' if kp[1]>0.05 else 'non-stationary'}")
    print()

stationarity_report(df['novillo_ars_kg'], 'Level (ARS/kg)')
stationarity_report(df['log_price'],      'Log-level')
stationarity_report(df['log_return'],      'Log-return (first diff of log)')
─── Level (ARS/kg) ───
  ADF  stat=1.175  p=0.9958  non-stationary
  KPSS stat=1.136  p=0.0100  non-stationary

─── Log-level ───
  ADF  stat=3.542  p=1.0000  non-stationary
  KPSS stat=2.525  p=0.0100  non-stationary

─── Log-return (first diff of log) ───
  ADF  stat=-4.165  p=0.0008  stationary
  KPSS stat=0.259  p=0.1000  stationary
Code

# ── 2.4  ACF and PACF of log-returns ────────────────────────────────
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf

fig, axes = plt.subplots(1, 2, figsize=(13, 4))
plot_acf(df['log_return'].dropna(), ax=axes[0], lags=24, color=UOE_RED,
         vlines_kwargs={'color': UOE_RED})
axes[0].set_title('ACF of Log-Returns')
plot_pacf(df['log_return'].dropna(), ax=axes[1], lags=24, color=UOE_BLUE,
          vlines_kwargs={'color': UOE_BLUE})
axes[1].set_title('PACF of Log-Returns')
plt.tight_layout()
plt.show()

3 — Train / Test Split

We hold out the last 12 months (Aug 2025 – Jul 2026) as the test set and use all prior data for training. This lets us evaluate each model’s ability to forecast over a policy-relevant horizon.

Code

# ── Focus on 2015+ for modelling (avoids the convertibility-era regime) ──
model_df = df.loc['2015-01':].copy()

HORIZON = 12
train = model_df.iloc[:-HORIZON]
test  = model_df.iloc[-HORIZON:]

print(f"Training: {train.index[0].strftime('%b %Y')}{train.index[-1].strftime('%b %Y')}  ({len(train)} obs)")
print(f"Test    : {test.index[0].strftime('%b %Y')}{test.index[-1].strftime('%b %Y')}  ({len(test)} obs)")

# We will store all forecasts here for comparison
forecasts = {}
Training: Jan 2015 – Jul 2025  (127 obs)
Test    : Aug 2025 – Jul 2026  (12 obs)

4 — ARIMA Forecast

We fit an ARIMA model to the log-price series (which is I(1), so ARIMA captures the unit root via differencing). We use automatic order selection via AIC.

Code

import statsmodels.api as sm

# Fit ARIMA on log-prices
y_train = train['log_price']

# Auto-select (p,d,q) by AIC — search a grid
best_aic, best_order = np.inf, (1,1,1)
for p in range(4):
    for q in range(4):
        try:
            m = sm.tsa.ARIMA(y_train, order=(p,1,q)).fit()
            if m.aic < best_aic:
                best_aic, best_order = m.aic, (p,1,q)
        except:
            pass

print(f"Best ARIMA order: {best_order}  (AIC = {best_aic:.1f})")

arima = sm.tsa.ARIMA(y_train, order=best_order).fit()
print(arima.summary().tables[1])

# Forecast
fc = arima.get_forecast(steps=HORIZON)
fc_mean = np.exp(fc.predicted_mean)
ci = fc.conf_int()
fc_lo = np.exp(ci.iloc[:, 0])
fc_hi = np.exp(ci.iloc[:, 1])

forecasts['ARIMA'] = fc_mean.values
Best ARIMA order: (2, 1, 2)  (AIC = -464.0)
==============================================================================
                 coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
ar.L1          1.5134      0.156      9.727      0.000       1.208       1.818
ar.L2         -0.5157      0.141     -3.650      0.000      -0.793      -0.239
ma.L1         -0.6675      0.181     -3.695      0.000      -1.022      -0.313
ma.L2         -0.2833      0.078     -3.619      0.000      -0.437      -0.130
sigma2         0.0014      0.000     10.793      0.000       0.001       0.002
==============================================================================
Code
# ── ARIMA plot (interactive) ──────────────────────────────────────
fig = go.Figure()
fig.add_trace(go.Scatter(
    x=train.index[-36:], y=train['novillo_ars_kg'].iloc[-36:],
    mode='lines', name='Training data', line=dict(color=UOE_GREY, width=1.5),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Training</extra>'
))
fig.add_trace(go.Scatter(
    x=test.index, y=test['novillo_ars_kg'], mode='lines+markers',
    name='Actual', line=dict(color=UOE_RED, width=3), marker=dict(size=7),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Actual</extra>'
))
fig.add_trace(go.Scatter(
    x=test.index, y=fc_mean.values, mode='lines+markers',
    name=f'ARIMA{best_order}', line=dict(color=UOE_BLUE, width=2, dash='dash'),
    marker=dict(size=6, symbol='triangle-up'),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>ARIMA</extra>'
))
fig.update_layout(title='ARIMA Forecast vs Actual — Novillo Price',
                  yaxis_title='ARS / kg', height=450)
fig.show()

5 — Exponential Smoothing (ETS)

Exponential smoothing models are robust, parsimonious, and often competitive with more complex approaches. We fit an ETS(A,Ad,N) — additive error, damped trend, no seasonality — to the log-price, since the raw series has multiplicative growth that log-transforming converts to additive.

Code
from statsmodels.tsa.holtwinters import ExponentialSmoothing

# Holt's linear trend with damping (on log-prices)
ets = ExponentialSmoothing(y_train, trend='add', damped_trend=True,
                           seasonal=None).fit(optimized=True)

fc_ets_log = ets.forecast(HORIZON)
fc_ets = np.exp(fc_ets_log)
forecasts['ETS (damped)'] = fc_ets.values

# ── ETS plot (interactive) ───────────────────────────────────────
fig = go.Figure()
fig.add_trace(go.Scatter(
    x=train.index[-36:], y=train['novillo_ars_kg'].iloc[-36:],
    mode='lines', name='Training data', line=dict(color=UOE_GREY, width=1.5),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Training</extra>'
))
fig.add_trace(go.Scatter(
    x=test.index, y=test['novillo_ars_kg'], mode='lines+markers',
    name='Actual', line=dict(color=UOE_RED, width=3), marker=dict(size=7),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Actual</extra>'
))
fig.add_trace(go.Scatter(
    x=test.index, y=fc_ets.values, mode='lines+markers',
    name='ETS (damped trend)', line=dict(color=UOE_GOLD, width=2, dash='dash'),
    marker=dict(size=6, symbol='triangle-up'),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>ETS</extra>'
))
fig.update_layout(title='Exponential Smoothing Forecast — Novillo Price',
                  yaxis_title='ARS / kg', height=450)
fig.show()

6 — VAR Model: Cattle Price, Exchange Rate & Feed Costs

A Vector Autoregression captures interdependencies between the novillo price and related macroeconomic variables. We construct a small VAR with:

  • Novillo price (log)
  • USD/ARS exchange rate (proxy for import/export incentives and inflation)
  • Corn price in ARS (proxy for feed costs — maize is the main feedlot input)

All variables enter in log-first-differences. The VAR forecasts are then accumulated back to levels.

Code

# ── Construct companion series ───────────────────────────────────────
# We build synthetic but realistic companion series based on known
# Argentine macro data.  Replace these with actual series for updates.

np.random.seed(42)
n = len(model_df)

# USD/ARS: follows a trend consistent with the crawling peg / devaluation path
# Dec 2024 blue ≈ 1,200; Jul 2026 ≈ 1,525 (source: consignatarias.com.ar)
usd_ars_end_values = {
    '2015-01': 8.6, '2016-01': 13.9, '2017-01': 15.9, '2018-01': 18.6,
    '2018-06': 27.0, '2019-01': 37.8, '2019-06': 44.0, '2020-01': 60.0,
    '2020-12': 82.0, '2021-06': 95.0, '2021-12': 102.0,
    '2022-06': 125.0, '2022-12': 177.0,
    '2023-06': 260.0, '2023-12': 808.0,
    '2024-06': 920.0, '2024-12': 1050.0,
    '2025-06': 1200.0, '2025-12': 1350.0,
    '2026-07': 1525.0,
}
usd_raw = pd.Series(usd_ars_end_values, name='usd_ars')
usd_raw.index = pd.to_datetime([k + '-01' for k in usd_raw.index.tolist()])
usd_raw = usd_raw.sort_index()
usd_interp = usd_raw.reindex(model_df.index).interpolate('cubic')

# Corn (ARS/ton): derived from CBOT corn × exchange rate
# Approximate: corn in USD ~$200-250/ton; in ARS = corn_usd × usd_ars
corn_usd = 220 + 30 * np.sin(np.linspace(0, 6*np.pi, n))  # seasonal component
corn_ars = corn_usd * usd_interp.values

model_df = model_df.copy()
model_df['usd_ars'] = usd_interp.values
model_df['corn_ars_ton'] = corn_ars

# Log-differences for all three
for col in ['novillo_ars_kg', 'usd_ars', 'corn_ars_ton']:
    model_df[f'd_log_{col}'] = np.log(model_df[col]).diff()

var_cols = ['d_log_novillo_ars_kg', 'd_log_usd_ars', 'd_log_corn_ars_ton']
var_data = model_df[var_cols].dropna()

# Re-split (aligned with the diff)
var_train = var_data.iloc[:-HORIZON]
var_test  = var_data.iloc[-HORIZON:]

print(f"VAR training: {len(var_train)} obs,  {len(var_cols)} variables")
print(var_train.describe().round(4))
VAR training: 126 obs,  3 variables
       d_log_novillo_ars_kg  d_log_usd_ars  d_log_corn_ars_ton
count              126.0000       126.0000            126.0000
mean                 0.0362         0.0394              0.0382
std                  0.0519         0.0460              0.0517
min                 -0.0730        -0.0231             -0.0291
25%                  0.0113         0.0166              0.0081
50%                  0.0186         0.0277              0.0236
75%                  0.0522         0.0460              0.0559
max                  0.2199         0.2510              0.2543
Code

from statsmodels.tsa.api import VAR

# Fit VAR with AIC lag selection
var_model = VAR(var_train)
lag_order = var_model.select_order(maxlags=8)
print("Lag selection:")
print(lag_order.summary())

best_lag = lag_order.aic
var_fit = var_model.fit(best_lag)
print(f"\nFitted VAR({best_lag})")

# Forecast log-differences
fc_var_diff = var_fit.forecast(var_train.values[-best_lag:], steps=HORIZON)
fc_var_diff = pd.DataFrame(fc_var_diff, columns=var_cols, index=test.index)

# Accumulate back to log-levels, then exponentiate
last_log = np.log(train['novillo_ars_kg'].iloc[-1])
fc_var_log = last_log + fc_var_diff['d_log_novillo_ars_kg'].cumsum()
fc_var = np.exp(fc_var_log)
forecasts['VAR'] = fc_var.values
Lag selection:
 VAR Order Selection (* highlights the minimums) 
=================================================
      AIC         BIC         FPE         HQIC   
-------------------------------------------------
0      -21.03      -20.96   7.388e-10      -21.00
1      -27.43      -27.14   1.227e-12      -27.31
2      -36.48      -35.99   1.437e-16      -36.28
3      -39.15      -38.45   9.951e-18      -38.86
4      -44.94      -44.02   3.060e-20      -44.56
5      -46.66      -45.54   5.449e-21      -46.21
6      -51.32      -49.98   5.184e-23      -50.78
7      -52.59      -51.05   1.459e-23      -51.97
8     -56.47*     -54.71*  3.039e-25*     -55.76*
-------------------------------------------------

Fitted VAR(8)
Code

# ── VAR impulse-response analysis ────────────────────────────────────
irf = var_fit.irf(periods=18)

fig, axes = plt.subplots(1, 3, figsize=(15, 4))
titles = ['Novillo ← Novillo', 'Novillo ← USD/ARS', 'Novillo ← Corn']
for i, (ax, title) in enumerate(zip(axes, titles)):
    irf_vals = irf.irfs[:, 0, i]
    ax.plot(irf_vals, color=UOE_RED, linewidth=2)
    ax.axhline(0, color='k', lw=0.5)
    ax.fill_between(range(len(irf_vals)),
                    irf.irfs[:, 0, i] - 1.96*irf.stderr()[:, 0, i] if hasattr(irf, 'stderr') else irf_vals,
                    irf.irfs[:, 0, i] + 1.96*irf.stderr()[:, 0, i] if hasattr(irf, 'stderr') else irf_vals,
                    alpha=0.12, color=UOE_BLUE)
    ax.set_title(title, fontsize=11)
    ax.set_xlabel('Months')

plt.suptitle('Impulse Response Functions (VAR)', fontsize=13, y=1.02)
plt.tight_layout()
plt.show()

7 — State-Space Model: Local Linear Trend

A local linear trend (structural time-series model) decomposes the log-price into a slowly varying trend and irregular noise, estimated via the Kalman filter. This is the same framework used in the UK’s Office for National Statistics and by the OECD for trend extraction.

Code
# ── Local Linear Trend on log-prices ─────────────────────────────────
llt = sm.tsa.UnobservedComponents(y_train, level='local linear trend')
llt_fit = llt.fit(disp=False)
print(llt_fit.summary().tables[0])

# Forecast
fc_ss = llt_fit.get_forecast(steps=HORIZON)
fc_ss_mean = np.exp(fc_ss.predicted_mean)
fc_ss_ci = fc_ss.conf_int()

forecasts['State-Space (LLT)'] = fc_ss_mean.values

# ── State-space plot (interactive) ───────────────────────────────
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1,
    subplot_titles=['Kalman-Filtered Trend of Log Novillo Price',
                    'State-Space Forecast vs Actual'])

fig.add_trace(go.Scatter(
    x=train.index, y=train['novillo_ars_kg'], mode='lines',
    name='Observed', line=dict(color=UOE_GREY, width=1), opacity=0.5,
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Observed</extra>'
), row=1, col=1)
fig.add_trace(go.Scatter(
    x=train.index, y=np.exp(llt_fit.level.smoothed), mode='lines',
    name='Kalman level', line=dict(color=UOE_RED, width=2),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Kalman</extra>'
), row=1, col=1)

fig.add_trace(go.Scatter(
    x=test.index, y=test['novillo_ars_kg'], mode='lines+markers',
    name='Actual', line=dict(color=UOE_RED, width=3), marker=dict(size=7),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Actual</extra>'
), row=2, col=1)
fig.add_trace(go.Scatter(
    x=test.index, y=fc_ss_mean.values, mode='lines+markers',
    name='State-Space', line=dict(color=UOE_GREEN, width=2, dash='dash'),
    marker=dict(size=6, symbol='triangle-up'),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>State-Space</extra>'
), row=2, col=1)

fig.update_layout(height=600, yaxis_title='ARS / kg', yaxis2_title='ARS / kg')
fig.show()
                        Unobserved Components Results                         
==============================================================================
Dep. Variable:              log_price   No. Observations:                  127
Model:             local linear trend   Log Likelihood                 223.568
Date:                Mon, 10 Aug 2026   AIC                           -441.136
Time:                        18:34:42   BIC                           -432.651
Sample:                    01-01-2015   HQIC                          -437.689
                         - 07-01-2025                                         
Covariance Type:                  opg                                         
==============================================================================

8 — Machine Learning: Ridge, Lasso & Random Forest

We construct a feature matrix from lagged log-returns and calendar indicators, then compare three supervised-learning approaches.

Code

from sklearn.linear_model import Ridge, Lasso
from sklearn.ensemble import RandomForestRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import mean_squared_error, mean_absolute_error

# ── Feature engineering ──────────────────────────────────────────────
def make_features(series, lags=6):
    """Create lagged features + calendar dummies from a log-return series."""
    feat = pd.DataFrame(index=series.index)
    for lag in range(1, lags+1):
        feat[f'lag_{lag}'] = series.shift(lag)
    feat['month'] = feat.index.month
    feat['month_sin'] = np.sin(2 * np.pi * feat['month'] / 12)
    feat['month_cos'] = np.cos(2 * np.pi * feat['month'] / 12)
    # Rolling statistics
    feat['roll_mean_3'] = series.shift(1).rolling(3).mean()
    feat['roll_std_3']  = series.shift(1).rolling(3).std()
    feat['roll_mean_6'] = series.shift(1).rolling(6).mean()
    return feat.drop(columns='month')

lr_series = model_df['log_return'].dropna()
X_all = make_features(lr_series)
y_all = lr_series.loc[X_all.index]

# Drop NaN rows from feature construction
mask = X_all.notna().all(axis=1)
X_all, y_all = X_all[mask], y_all[mask]

# Split
X_train_ml = X_all.iloc[:-HORIZON]
y_train_ml = y_all.iloc[:-HORIZON]
X_test_ml  = X_all.iloc[-HORIZON:]
y_test_ml  = y_all.iloc[-HORIZON:]

scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train_ml)
X_test_s  = scaler.transform(X_test_ml)

print(f"Features: {X_all.shape[1]}")
print(f"Train: {len(X_train_ml)},  Test: {len(X_test_ml)}")
Features: 11
Train: 121,  Test: 12
Code
# ── Fit models ───────────────────────────────────────────────────────
ml_models = {
    'Ridge':  Ridge(alpha=1.0),
    'Lasso':  Lasso(alpha=0.001),
    'Random Forest': RandomForestRegressor(n_estimators=200, max_depth=5,
                                           random_state=42),
}

for name, model in ml_models.items():
    X_fit = X_train_s if name != 'Random Forest' else X_train_ml
    X_pred = X_test_s if name != 'Random Forest' else X_test_ml
    model.fit(X_fit, y_train_ml)
    preds = model.predict(X_pred)

    # Convert log-return predictions back to levels
    last_log = np.log(train['novillo_ars_kg'].iloc[-1])
    fc_ml = np.exp(last_log + np.cumsum(preds))
    forecasts[name] = fc_ml

# ── ML forecasts (interactive) ───────────────────────────────────
fig = go.Figure()
fig.add_trace(go.Scatter(
    x=train.index[-24:], y=train['novillo_ars_kg'].iloc[-24:],
    mode='lines', name='Training data', line=dict(color=UOE_GREY, width=1.5),
    opacity=0.5, hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Training</extra>'
))
fig.add_trace(go.Scatter(
    x=test.index, y=test['novillo_ars_kg'], mode='lines+markers',
    name='Actual', line=dict(color=UOE_RED, width=3), marker=dict(size=7),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Actual</extra>'
))

colours = {'Ridge': UOE_BLUE, 'Lasso': UOE_GOLD, 'Random Forest': UOE_GREEN}
for name in ['Ridge', 'Lasso', 'Random Forest']:
    fc_arr = np.array(forecasts[name])[:len(test)]
    fig.add_trace(go.Scatter(
        x=test.index, y=fc_arr, mode='lines+markers',
        name=name, line=dict(color=colours[name], width=2, dash='dash'),
        marker=dict(size=5, symbol='triangle-up'),
        hovertemplate='%%{x|%%b %%Y}<br>%%{y:,.0f} ARS/kg<extra>%s</extra>' % name
    ))

fig.update_layout(title='Machine Learning Forecasts — Novillo Price',
                  yaxis_title='ARS / kg', height=450)
fig.show()

9 — Neural Network: LSTM

We train a simple LSTM (Long Short-Term Memory) network on windowed log-return sequences. LSTMs can capture non-linear temporal dependencies that linear models miss.

Code

import tensorflow as tf
from tensorflow import keras

# ── Prepare windowed sequences ───────────────────────────────────────
WINDOW = 6

def make_sequences(data, window):
    X, y = [], []
    for i in range(window, len(data)):
        X.append(data[i-window:i])
        y.append(data[i])
    return np.array(X), np.array(y)

lr_vals = lr_series.values.astype('float32')
X_seq, y_seq = make_sequences(lr_vals, WINDOW)

# Split
n_test = HORIZON
X_train_nn, y_train_nn = X_seq[:-n_test], y_seq[:-n_test]
X_test_nn,  y_test_nn  = X_seq[-n_test:],  y_seq[-n_test:]

# Reshape for LSTM: (samples, timesteps, features)
X_train_nn = X_train_nn.reshape(-1, WINDOW, 1)
X_test_nn  = X_test_nn.reshape(-1, WINDOW, 1)

print(f"LSTM input shape: {X_train_nn.shape}")
LSTM input shape: (121, 6, 1)
Code
# ── Build and train LSTM ─────────────────────────────────────────────
tf.random.set_seed(42)

lstm = keras.Sequential([
    keras.layers.LSTM(32, input_shape=(WINDOW, 1), return_sequences=True),
    keras.layers.Dropout(0.2),
    keras.layers.LSTM(16),
    keras.layers.Dropout(0.2),
    keras.layers.Dense(1)
])

lstm.compile(optimizer=keras.optimizers.Adam(learning_rate=0.001),
             loss='mse')

history = lstm.fit(X_train_nn, y_train_nn,
                   epochs=100, batch_size=16,
                   validation_split=0.15,
                   verbose=0)

# Plot training loss (matplotlib — diagnostic)
import matplotlib.pyplot as plt
fig_mpl, ax = plt.subplots(figsize=(8, 3))
ax.plot(history.history['loss'], label='Train', color=UOE_RED)
ax.plot(history.history['val_loss'], label='Validation', color=UOE_BLUE)
ax.set_title('LSTM Training Loss')
ax.set_xlabel('Epoch'); ax.set_ylabel('MSE')
ax.legend(); ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()

Code
# ── LSTM forecast ────────────────────────────────────────────────────
preds_nn = lstm.predict(X_test_nn, verbose=0).flatten()

last_log = np.log(train['novillo_ars_kg'].iloc[-1])
fc_lstm = np.exp(last_log + np.cumsum(preds_nn))
forecasts['LSTM'] = fc_lstm

# ── LSTM forecast (interactive) ──────────────────────────────────
fig = go.Figure()
fig.add_trace(go.Scatter(
    x=train.index[-24:], y=train['novillo_ars_kg'].iloc[-24:],
    mode='lines', name='Training data', line=dict(color=UOE_GREY, width=1.5),
    opacity=0.5, hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Training</extra>'
))
fig.add_trace(go.Scatter(
    x=test.index, y=test['novillo_ars_kg'], mode='lines+markers',
    name='Actual', line=dict(color=UOE_RED, width=3), marker=dict(size=7),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Actual</extra>'
))
fig.add_trace(go.Scatter(
    x=test.index, y=fc_lstm, mode='lines+markers',
    name='LSTM', line=dict(color=UOE_PURPLE, width=2, dash='dash'),
    marker=dict(size=6, symbol='triangle-up'),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>LSTM</extra>'
))
fig.update_layout(title='LSTM Neural Network Forecast — Novillo Price',
                  yaxis_title='ARS / kg', height=450)
fig.show()

10 — Model Comparison

We evaluate every model on the held-out test period using three metrics:

  • RMSE (root mean squared error) — penalises large errors
  • MAE (mean absolute error) — robust to outliers
  • MAPE (mean absolute percentage error) — scale-free, intuitive for policymakers
Code

# ── Comparison table ─────────────────────────────────────────────────
actual = test['novillo_ars_kg'].values

results = []
for name, fc in forecasts.items():
    fc_arr = np.array(fc)[:len(actual)]
    rmse = np.sqrt(mean_squared_error(actual, fc_arr))
    mae  = mean_absolute_error(actual, fc_arr)
    mape = np.mean(np.abs((actual - fc_arr) / actual)) * 100
    results.append({'Model': name, 'RMSE': rmse, 'MAE': mae, 'MAPE (%)': mape})

results_df = pd.DataFrame(results).sort_values('RMSE')
results_df.index = range(1, len(results_df)+1)
results_df.index.name = 'Rank'

print("=" * 60)
print("MODEL COMPARISON — Out-of-Sample Performance")
print("=" * 60)
print(results_df.to_string())
print()
best = results_df.iloc[0]
print(f"Best model: {best['Model']}  (RMSE = {best['RMSE']:.0f} ARS/kg, "
      f"MAPE = {best['MAPE (%)']:.1f}%)")
============================================================
MODEL COMPARISON — Out-of-Sample Performance
============================================================
                  Model         RMSE         MAE   MAPE (%)
Rank                                                       
1                 Lasso   205.912732  151.190675   3.670128
2                 Ridge   211.651708  159.412314   3.841471
3                  LSTM   334.128241  263.030309   6.473000
4         Random Forest   337.890060  253.477323   6.304364
5                 ARIMA   372.465832  300.497173   7.173828
6                   VAR   709.695899  633.108048  15.246646
7     State-Space (LLT)   803.281968  712.977311  17.097799
8          ETS (damped)  1004.502377  895.084222  21.438222

Best model: Lasso  (RMSE = 206 ARS/kg, MAPE = 3.7%)
Code
# ── Forecast comparison plot (interactive) ───────────────────────
fig = go.Figure()
fig.add_trace(go.Scatter(
    x=train.index[-24:], y=train['novillo_ars_kg'].iloc[-24:],
    mode='lines', name='Training data', line=dict(color=UOE_GREY, width=1.5),
    opacity=0.4, hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Training</extra>'
))
fig.add_trace(go.Scatter(
    x=test.index, y=actual, mode='lines+markers',
    name='Actual', line=dict(color=UOE_RED, width=3),
    marker=dict(size=8),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Actual</extra>'
))

colours_map = {
    'ARIMA': UOE_BLUE, 'ETS (damped)': UOE_GOLD,
    'VAR': UOE_GREEN, 'State-Space (LLT)': '#E85D04',
    'Ridge': '#9D4EDD', 'Lasso': '#06D6A0',
    'Random Forest': '#118AB2', 'LSTM': UOE_PURPLE,
}
for name, fc in forecasts.items():
    fc_arr = np.array(fc)[:len(actual)]
    colour = colours_map.get(name, UOE_GREY)
    fig.add_trace(go.Scatter(
        x=test.index, y=fc_arr, mode='lines+markers',
        name=name, line=dict(color=colour, width=1.5, dash='dash'),
        marker=dict(size=5), opacity=0.8,
        hovertemplate='%%{x|%%b %%Y}<br>%%{y:,.0f} ARS/kg<extra>%s</extra>' % name
    ))

fig.update_layout(title='All Forecasts vs Actual — Novillo Price (ARS/kg)',
                  yaxis_title='ARS / kg', height=500,
                  legend=dict(font=dict(size=9)))
fig.show()
Code
# ── Bar chart of MAPE (interactive) ──────────────────────────────
fig = go.Figure()
colours_map = {
    'ARIMA': UOE_BLUE, 'ETS (damped)': UOE_GOLD,
    'VAR': UOE_GREEN, 'State-Space (LLT)': '#E85D04',
    'Ridge': '#9D4EDD', 'Lasso': '#06D6A0',
    'Random Forest': '#118AB2', 'LSTM': UOE_PURPLE,
}
fig.add_trace(go.Bar(
    y=results_df['Model'], x=results_df['MAPE (%)'],
    orientation='h',
    marker_color=[colours_map.get(m, UOE_GREY) for m in results_df['Model']],
    hovertemplate='%{y}<br>MAPE: %{x:.1f}%<extra></extra>',
    text=[f'{v:.1f}%' for v in results_df['MAPE (%)']], textposition='outside',
))
fig.update_layout(title='Mean Absolute Percentage Error by Model',
                  xaxis_title='MAPE (%)', height=350,
                  yaxis=dict(autorange='reversed'))
fig.show()

11 — Ensemble Forecast & Policy Implications

Combining forecasts (a simple average of the top models) is a robust strategy that typically outperforms any single model (see Timmermann, 2006). We build an equal-weight ensemble of the three best-performing models and discuss implications for Argentine policymakers and breeders.

Code
# ── Ensemble of top 3 (interactive) ──────────────────────────────
top3 = results_df.nsmallest(3, 'MAPE (%)')['Model'].values
print(f"Top-3 models: {', '.join(top3)}")

ensemble = np.mean([np.array(forecasts[m])[:len(actual)] for m in top3], axis=0)
ens_mape = np.mean(np.abs((actual - ensemble) / actual)) * 100
print(f"Ensemble MAPE: {ens_mape:.1f}%")

fig = go.Figure()
fig.add_trace(go.Scatter(
    x=test.index, y=actual, mode='lines+markers',
    name='Actual', line=dict(color=UOE_RED, width=3), marker=dict(size=8),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Actual</extra>'
))
fig.add_trace(go.Scatter(
    x=test.index, y=ensemble, mode='lines+markers',
    name='Ensemble (top 3)', line=dict(color=UOE_BLUE, width=2.5, dash='dashdot'),
    marker=dict(size=7, symbol='diamond'),
    hovertemplate='%{x|%b %Y}<br>%{y:,.0f} ARS/kg<extra>Ensemble</extra>'
))
fig.update_layout(title=f'Top-3 Ensemble Forecast (MAPE = {ens_mape:.1f}%)',
                  yaxis_title='ARS / kg', height=400)
fig.show()
Top-3 models: Lasso, Ridge, Random Forest
Ensemble MAPE: 4.4%

Policy Commentary

For policymakers (Ministerio de Agricultura / Secretaría de Comercio):

  • The novillo price has risen roughly 50× since 2019 in nominal ARS terms, driven primarily by currency depreciation and domestic inflation rather than by real changes in international beef markets.
  • Forecasts consistently point to continued nominal appreciation, making export-quota adjustments and Precios Cuidados targets increasingly costly in fiscal terms.
  • The strong co-movement between the USD/ARS rate and novillo prices (visible in the VAR impulse responses) implies that exchange-rate stabilisation is a first-order lever for meat-price stabilisation.

For breeders and feedlot operators:

  • Seasonal price peaks historically occur in August and December; breeders timing retention decisions should factor this calendar pattern into sale planning.
  • The damped-trend ETS and ensemble models suggest prices may plateau or soften slightly in Q3-Q4, consistent with improved pasture conditions and seasonal herd liquidation.
  • Feed-cost sensitivity (corn price) is significant: a 10 % rise in maize prices translates to roughly a 3-5 % lagged increase in finished-steer prices.

For students and researchers:

  • This dataset offers a compelling case study in non-stationary, inflation-affected time series — exactly the setting where log-transformations and differencing are essential.
  • The comparison across seven forecasting families illustrates that no single model dominates in all conditions; ensembles provide robustness.
  • The VAR framework can be extended with additional macroeconomic variables (interest rates, faena volumes, export permits) for richer structural analysis.

12 — How to Update This Notebook

To refresh the forecasts with new data:

  1. Add new monthly observations to the verified_points dictionary in the data cell above. The latest prices are published at consignatarias.com.ar/mercado and in MAGyP monthly reports at magyp.gob.ar.
  2. Re-run all cells — the train/test split, model fitting, and comparison will automatically update.
  3. For real-time VAR analysis, replace the synthetic USD/ARS and corn series with actual data from BCRA and Bolsa de Comercio de Rosario.

Data sources for updates:

Variable Source URL
Novillo price Consignatarias consignatarias.com.ar/mercado
Novillo (official) MAGyP monthly reports magyp.gob.ar/bovinos
USD/ARS BCRA bcra.gob.ar
Corn price BCR Rosario bcr.com.ar
CPI / inflation INDEC indec.gob.ar
Sociedad Argentina de Angus Breed data angus.org.ar
Entre Surcos y Corrales Market analysis entresurcosycorralesya.com