Forecasting Pedigree Angus Vaquillona Prices

Auction Data from Argentine Cabaña Sales

Author

Dr Juan Zurita — University of Edinburgh

Forecasting Pedigree Angus Vaquillona Prices

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

Argentina’s pedigree cattle market is the foundation of its world-class beef genetics. Cabaña auctions — held at major expositions such as Palermo (Expo Rural), the Expo Angus de Otoño, and regional breed shows — set the benchmark prices for registered Angus breeding stock. This notebook forecasts the average price of pedigree and Puro Controlado (PC) vaquillonas (heifers) at these auctions, drawing on data from Entre Surcos y Corrales, the Sociedad Argentina de Angus, and press reports from La Nación, Infocampo, and Angus Digital.

Why pedigree vaquillona prices matter:

  • For breeders, they signal the return on genetic investment and guide decisions on whether to sell or retain elite females.
  • For policymakers, the premium over commercial cattle reflects breed-improvement incentives and the health of Argentina’s genetics pipeline.
  • For students, pedigree auction data offers a fascinating case study in thin-market forecasting — small samples, irregular timing, and extreme heterogeneity.

Key insight: We express pedigree prices as a premium ratio over the commercial novillo price. This removes the dominant inflation trend and isolates the genetics premium, which is far more stable and forecastable than the nominal ARS price.

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: Pedigree Auction Prices

The dataset below compiles verified auction results for Angus vaquillonas (pedigree and Puro Controlado) from cabaña auctions across Argentina, alongside the concurrent commercial novillo price at the Mercado Agroganadero.

Sources:

Code

# ═══════════════════════════════════════════════════════════════════
# Pedigree / PC Angus vaquillona auction results
# Each entry: (date, avg_price_ARS, category, event, n_sold)
# ═══════════════════════════════════════════════════════════════════

auction_data = [
    # ── 2017 ──
    ('2017-07-28', 85_000,   'PED', 'Palermo 2017', 4),
    ('2017-10-15', 60_000,   'PC',  'Expo Otoño 2017', 15),
    # ── 2018 ──
    ('2018-07-29', 130_000,  'PED', 'Palermo 2018', 5),
    ('2018-05-20', 70_000,   'PC',  'Expo Otoño 2018', 20),
    ('2018-10-10', 90_000,   'PC',  'Expo Primavera 2018', 18),
    # ── 2019 ──
    ('2019-07-28', 150_000,  'PED', 'Palermo 2019', 5),
    ('2019-05-18', 85_000,   'PC',  'Expo Otoño 2019', 22),
    ('2019-09-15', 110_000,  'PC',  'Expo Primavera 2019', 16),
    # ── 2020 (COVID — reduced activity) ──
    ('2020-08-15', 200_000,  'PC',  'Remate virtual Casamú 2020', 30),
    ('2020-11-10', 250_000,  'PC',  'Expo Primavera 2020', 12),
    # ── 2021 ──
    ('2021-05-22', 320_000,  'PC',  'Expo Otoño 2021', 25),
    ('2021-09-05', 450_000,  'PED', 'Rural Río Cuarto 2021', 6),
    ('2021-07-30', 380_000,  'PED', 'Palermo 2021', 4),
    # ── 2022 ──
    ('2022-05-28', 420_000,  'PC',  'Remate PC Angus (Entre Surcos)', 95),
    ('2022-07-31', 1_090_000,'PED', 'Palermo 2022', 5),
    ('2022-10-08', 600_000,  'PC',  'Expo Primavera 2022', 18),
    # ── 2023 ──
    ('2023-05-20', 1_200_000,'PC',  'Expo Otoño 2023', 30),
    ('2023-07-31', 6_800_000,'PED', 'Palermo 2023', 2),
    ('2023-10-08', 900_000,  'PC',  'La Pastoriza (Entre Surcos)', 20),
    # ── 2024 ──
    ('2024-05-18', 2_800_000,'PC',  'Expo Otoño 2024', 40),
    ('2024-07-28', 5_500_000,'PED', 'Palermo 2024', 6),
    ('2024-10-12', 3_200_000,'PC',  'Expo Primavera 2024', 22),
    # ── 2025 ──
    ('2025-05-20', 3_268_965,'PC',  'Expo Otoño Palermo 2025 (Entre Surcos)', 74),
    ('2025-06-05', 4_500_000,'PC',  'Casamú (Entre Surcos)', 35),
    ('2025-07-26', 9_000_000,'PED', 'Palermo 2025', 10),
    ('2025-08-22', 7_000_000,'PED', 'Tres Marías (Entre Surcos)', 15),
    # ── 2026 ──
    ('2026-05-15', 5_500_000,'PC',  'Expo Otoño 2026', 50),
    ('2026-07-28', 9_570_000,'PED', 'Palermo 2026 (black PED)', 7),
]

df_auctions = pd.DataFrame(auction_data,
    columns=['date', 'avg_price_ars', 'category', 'event', 'n_sold'])
df_auctions['date'] = pd.to_datetime(df_auctions['date'])
df_auctions = df_auctions.sort_values('date').reset_index(drop=True)

print(f"Auction records: {len(df_auctions)}")
print(f"Period: {df_auctions['date'].min().strftime('%b %Y')} – "
      f"{df_auctions['date'].max().strftime('%b %Y')}")
print(f"\nCategory breakdown:")
print(df_auctions.groupby('category').agg(
    n_auctions=('event', 'count'),
    total_sold=('n_sold', 'sum'),
    avg_price=('avg_price_ars', 'mean')
).to_string())
print(f"\n{df_auctions.tail(8).to_string(index=False)}")
Auction records: 28
Period: Jul 2017 – Jul 2026

Category breakdown:
          n_auctions  total_sold     avg_price
category                                      
PC                17         542  1.386704e+06
PED               11          69  3.650455e+06

      date  avg_price_ars category                                  event  n_sold
2024-07-28        5500000      PED                           Palermo 2024       6
2024-10-12        3200000       PC                    Expo Primavera 2024      22
2025-05-20        3268965       PC Expo Otoño Palermo 2025 (Entre Surcos)      74
2025-06-05        4500000       PC                  Casamú (Entre Surcos)      35
2025-07-26        9000000      PED                           Palermo 2025      10
2025-08-22        7000000      PED             Tres Marías (Entre Surcos)      15
2026-05-15        5500000       PC                        Expo Otoño 2026      50
2026-07-28        9570000      PED               Palermo 2026 (black PED)       7
Code

# ═══════════════════════════════════════════════════════════════════
# Commercial novillo price (monthly, ARS/kg live weight)
# From the companion novillo forecasting notebook
# ═══════════════════════════════════════════════════════════════════

novillo_monthly = {
    '2017-01': 48.0, '2017-04': 49.0, '2017-07': 50.0, '2017-10': 52.0,
    '2018-01': 54.0, '2018-04': 56.0, '2018-07': 58.0, '2018-10': 62.0,
    '2019-01': 64.0, '2019-04': 62.0, '2019-07': 68.0, '2019-10': 69.4,
    '2020-01': 84.4, '2020-04': 88.5, '2020-07': 95.6, '2020-10': 105.7,
    '2021-01': 160.0, '2021-04': 220.0, '2021-07': 225.0, '2021-10': 235.0,
    '2022-01': 223.8, '2022-04': 260.0, '2022-07': 285.0, '2022-10': 305.0,
    '2023-01': 336.5, '2023-04': 450.0, '2023-07': 650.0, '2023-10': 1000.0,
    '2024-01': 1424.2, '2024-04': 1550.0, '2024-07': 1700.0, '2024-10': 1950.0,
    '2025-01': 2400.0, '2025-04': 2788.0, '2025-07': 2924.0, '2025-10': 3246.0,
    '2026-01': 4117.0, '2026-04': 4265.0, '2026-07': 4318.0,
}

novillo_s = pd.Series(novillo_monthly, name='novillo_ars_kg')
novillo_s.index = pd.to_datetime([k + '-01' for k in novillo_s.index])
novillo_s = novillo_s.sort_index()

# Interpolate to daily so we can match any auction date
novillo_daily = novillo_s.reindex(
    pd.date_range(novillo_s.index.min(), novillo_s.index.max(), freq='D')
).interpolate('cubic')

# Match each auction to the concurrent novillo price
df_auctions['novillo_ars_kg'] = df_auctions['date'].map(
    lambda d: novillo_daily.asof(d)
)

# Compute the premium ratio: how many kg of novillo does one vaquillona buy?
# This is the "genetics premium" — independent of inflation.
df_auctions['premium_ratio'] = (
    df_auctions['avg_price_ars'] / df_auctions['novillo_ars_kg']
)

print("Premium ratio = vaquillona price / novillo price (kg equivalent)")
print(f"  Interpretation: a pedigree vaquillona costs as many ARS as "
      f"X kg of commercial novillo\n")
print(df_auctions[['date','category','avg_price_ars','novillo_ars_kg',
                    'premium_ratio','event']].to_string(index=False))
Premium ratio = vaquillona price / novillo price (kg equivalent)
  Interpretation: a pedigree vaquillona costs as many ARS as X kg of commercial novillo

      date category  avg_price_ars  novillo_ars_kg  premium_ratio                                  event
2017-07-28      PED          85000       50.514285    1682.692337                           Palermo 2017
2017-10-15       PC          60000       52.313750    1146.926002                        Expo Otoño 2017
2018-05-20       PC          70000       56.917764    1229.844511                        Expo Otoño 2018
2018-07-29      PED         130000       59.060052    2201.149451                           Palermo 2018
2018-10-10       PC          90000       62.403333    1442.230648                    Expo Primavera 2018
2019-05-18       PC          85000       65.131551    1305.051064                        Expo Otoño 2019
2019-07-28      PED         150000       68.148052    2201.090078                           Palermo 2019
2019-09-15       PC         110000       68.300972    1610.518816                    Expo Primavera 2019
2020-08-15       PC         200000       97.489420    2051.504661             Remate virtual Casamú 2020
2020-11-10       PC         250000      123.959623    2016.785740                    Expo Primavera 2020
2021-05-22       PC         320000      225.603890    1418.415261                        Expo Otoño 2021
2021-07-30      PED         380000      228.384993    1663.857135                           Palermo 2021
2021-09-05      PED         450000      234.033356    1922.802833                  Rural Río Cuarto 2021
2022-05-28       PC         420000      277.604361    1512.944530         Remate PC Angus (Entre Surcos)
2022-07-31      PED        1090000      291.767870    3735.846581                           Palermo 2022
2022-10-08       PC         600000      306.237425    1959.264123                    Expo Primavera 2022
2023-05-20       PC        1200000      544.267350    2204.798798                        Expo Otoño 2023
2023-07-31      PED        6800000      744.541376    9133.139162                           Palermo 2023
2023-10-08       PC         900000     1034.413292     870.058426            La Pastoriza (Entre Surcos)
2024-05-18       PC        2800000     1615.651640    1733.046859                        Expo Otoño 2024
2024-07-28      PED        5500000     1757.862669    3128.799591                           Palermo 2024
2024-10-12       PC        3200000     1994.824695    1604.150985                    Expo Primavera 2024
2025-05-20       PC        3268965     2879.383490    1135.300321 Expo Otoño Palermo 2025 (Entre Surcos)
2025-06-05       PC        4500000     2897.146505    1553.252482                  Casamú (Entre Surcos)
2025-07-26      PED        9000000     2958.465558    3042.117552                           Palermo 2025
2025-08-22      PED        7000000     3027.564580    2312.089409             Tres Marías (Entre Surcos)
2026-05-15       PC        5500000     4226.997617    1301.159948                        Expo Otoño 2026
2026-07-28      PED        9570000     4318.000000    2216.303844               Palermo 2026 (black PED)

2 — Exploratory Analysis

Pedigree auction prices in nominal ARS are dominated by inflation (just like the novillo), so the raw series is hard to interpret. The premium ratio — vaquillona price / novillo price per kg — strips out inflation and reveals the underlying genetics premium.

Code
# ── 2.1  Nominal prices by category (interactive) ───────────────
fig = make_subplots(rows=1, cols=2,
    subplot_titles=['(A)  Nominal Vaquillona Price',
                    '(B)  Premium Ratio over Commercial Novillo'])

for cat, colour in [('PED', UOE_RED), ('PC', UOE_BLUE)]:
    sub = df_auctions[df_auctions['category'] == cat]
    fig.add_trace(go.Scatter(
        x=sub['date'], y=sub['avg_price_ars'] / 1e6,
        mode='markers', name=f'{cat}',
        marker=dict(color=colour, size=sub['n_sold'].clip(upper=30)*0.5+4,
                    opacity=0.8, line=dict(width=1, color='white')),
        hovertemplate='%{x|%b %Y}<br>$%{y:.2f}M ARS<br>Lots: %{customdata}<extra>' + cat + '</extra>',
        customdata=sub['n_sold'],
    ), row=1, col=1)
    fig.add_trace(go.Scatter(
        x=sub['date'], y=sub['premium_ratio'],
        mode='markers', name=f'{cat} ratio',
        marker=dict(color=colour, size=sub['n_sold'].clip(upper=30)*0.5+4,
                    opacity=0.8, line=dict(width=1, color='white')),
        hovertemplate='%{x|%b %Y}<br>Ratio: %{y:,.0f}x<br>Lots: %{customdata}<extra>' + cat + '</extra>',
        customdata=sub['n_sold'],
        showlegend=False,
    ), row=1, col=2)

fig.add_hline(y=df_auctions['premium_ratio'].median(), line_dash='dash',
              line_color=UOE_GREY, opacity=0.5, row=1, col=2)

fig.update_layout(height=400, yaxis_title='ARS (millions)',
                  yaxis2_title='Premium ratio (kg novillo equiv.)')
fig.show()

# Summary stats
for cat in ['PED', 'PC']:
    sub = df_auctions[df_auctions['category'] == cat]
    print(f"\n── {cat} ──")
    print(f"  Median premium ratio: {sub['premium_ratio'].median():,.0f} kg equiv")
    print(f"  Mean premium ratio  : {sub['premium_ratio'].mean():,.0f} kg equiv")
    print(f"  Std                 : {sub['premium_ratio'].std():,.0f}")

── PED ──
  Median premium ratio: 2,216 kg equiv
  Mean premium ratio  : 3,022 kg equiv
  Std                 : 2,126

── PC ──
  Median premium ratio: 1,513 kg equiv
  Mean premium ratio  : 1,535 kg equiv
  Std                 : 367
Code

# ── 2.2  Build a quarterly time series ───────────────────────────
# Aggregate auctions to quarterly averages (weighted by n_sold)

df_auctions['quarter'] = df_auctions['date'].dt.to_period('Q')

quarterly = df_auctions.groupby('quarter').apply(
    lambda g: pd.Series({
        'avg_price_ars': np.average(g['avg_price_ars'], weights=g['n_sold']),
        'premium_ratio': np.average(g['premium_ratio'], weights=g['n_sold']),
        'n_sold': g['n_sold'].sum(),
        'n_auctions': len(g),
    })
).reset_index()

quarterly['date'] = quarterly['quarter'].dt.to_timestamp()
quarterly = quarterly.sort_values('date').reset_index(drop=True)

print(f"Quarterly series: {len(quarterly)} observations")
print(quarterly[['quarter','avg_price_ars','premium_ratio','n_sold','n_auctions']]
      .to_string(index=False))
Quarterly series: 24 observations
quarter  avg_price_ars  premium_ratio  n_sold  n_auctions
 2017Q3   8.500000e+04    1682.692337     4.0         1.0
 2017Q4   6.000000e+04    1146.926002    15.0         1.0
 2018Q2   7.000000e+04    1229.844511    20.0         1.0
 2018Q3   1.300000e+05    2201.149451     5.0         1.0
 2018Q4   9.000000e+04    1442.230648    18.0         1.0
 2019Q2   8.500000e+04    1305.051064    22.0         1.0
 2019Q3   1.195238e+05    1751.131022    21.0         2.0
 2020Q3   2.000000e+05    2051.504661    30.0         1.0
 2020Q4   2.500000e+05    2016.785740    12.0         1.0
 2021Q2   3.200000e+05    1418.415261    25.0         1.0
 2021Q3   4.220000e+05    1819.224554    10.0         2.0
 2022Q2   4.200000e+05    1512.944530    95.0         1.0
 2022Q3   1.090000e+06    3735.846581     5.0         1.0
 2022Q4   6.000000e+05    1959.264123    18.0         1.0
 2023Q2   1.200000e+06    2204.798798    30.0         1.0
 2023Q3   6.800000e+06    9133.139162     2.0         1.0
 2023Q4   9.000000e+05     870.058426    20.0         1.0
 2024Q2   2.800000e+06    1733.046859    40.0         1.0
 2024Q3   5.500000e+06    3128.799591     6.0         1.0
 2024Q4   3.200000e+06    1604.150985    22.0         1.0
 2025Q2   3.664251e+06    1269.505143   109.0         2.0
 2025Q3   7.800000e+06    2604.100666    25.0         2.0
 2026Q2   5.500000e+06    1301.159948    50.0         1.0
 2026Q3   9.570000e+06    2216.303844     7.0         1.0
Code
# ── 2.3  Premium ratio time series (interactive) ────────────────
fig = go.Figure()
fig.add_trace(go.Scatter(
    x=quarterly['date'], y=quarterly['premium_ratio'],
    mode='lines+markers', name='Premium ratio',
    line=dict(color=UOE_RED, width=2), marker=dict(size=7),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra></extra>'
))
fig.add_hline(y=quarterly['premium_ratio'].mean(), line_dash='dash',
              line_color=UOE_GREY, annotation_text=f"Mean = {quarterly['premium_ratio'].mean():.1f}x")
fig.update_layout(title='Premium Ratio: Pedigree Vaquillona / Commercial Novillo',
                  yaxis_title='Multiple of novillo kg price',
                  height=400)
fig.show()

3 — Train / Test Split

We hold out the last 3 quarters as the test set. With ~28 auction-level observations aggregated to ~18 quarterly points, this is a genuinely thin sample — exactly the kind of challenge pedigree-market participants face.

Code

HORIZON = 3
train_q = quarterly.iloc[:-HORIZON].copy()
test_q  = quarterly.iloc[-HORIZON:].copy()

print(f"Training: {train_q['quarter'].iloc[0]}{train_q['quarter'].iloc[-1]}  "
      f"({len(train_q)} quarters)")
print(f"Test    : {test_q['quarter'].iloc[0]}{test_q['quarter'].iloc[-1]}  "
      f"({len(test_q)} quarters)")

forecasts = {}
Training: 2017Q3 – 2025Q2  (21 quarters)
Test    : 2025Q3 – 2026Q3  (3 quarters)

4 — ARIMA on the Premium Ratio

Because the premium ratio is much more stationary than the nominal price, ARIMA can work directly on it (or on its log).

Code

import statsmodels.api as sm
from statsmodels.tsa.stattools import adfuller

y_train = train_q.set_index('date')['premium_ratio']

# ADF test
adf = adfuller(y_train, autolag='AIC')
print(f"ADF on premium ratio: stat={adf[0]:.3f}, p={adf[1]:.4f}")
print(f"  → {'stationary' if adf[1] < 0.05 else 'non-stationary at 5%'}")

# Grid search
best_aic, best_order = np.inf, (1,0,0)
for p in range(4):
    for d in range(2):
        for q in range(3):
            try:
                m = sm.tsa.ARIMA(y_train, order=(p,d,q)).fit()
                if m.aic < best_aic:
                    best_aic, best_order = m.aic, (p,d,q)
            except:
                pass

print(f"\nBest ARIMA order: {best_order}  (AIC = {best_aic:.1f})")
arima = sm.tsa.ARIMA(y_train, order=best_order).fit()

fc = arima.get_forecast(steps=HORIZON)
fc_mean = fc.predicted_mean
ci = fc.conf_int()

forecasts['ARIMA'] = fc_mean.values

# Convert to nominal ARS using the last known novillo prices
novillo_test = [4265.0, 4318.0, 4400.0]  # Q2, Q3, Q4 2026 (est)
fc_nominal = fc_mean.values * np.array(novillo_test)

print(f"\nForecast (premium ratio): {fc_mean.values.round(0)}")
print(f"Forecast (ARS nominal) : {(fc_nominal/1e6).round(2)} million")
ADF on premium ratio: stat=3.194, p=1.0000
  → non-stationary at 5%

Best ARIMA order: (0, 1, 1)  (AIC = 361.9)

Forecast (premium ratio): [2294. 2294. 2294.]
Forecast (ARS nominal) : [ 9.79  9.91 10.1 ] million
Code
# ── ARIMA plot (interactive) ──────────────────────────────────────
fig = go.Figure()
fig.add_trace(go.Scatter(
    x=train_q['date'], y=train_q['premium_ratio'],
    mode='lines+markers', name='Training data',
    line=dict(color=UOE_GREY, width=1.5), marker=dict(size=5),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Training</extra>'
))
fig.add_trace(go.Scatter(
    x=test_q['date'], y=test_q['premium_ratio'],
    mode='lines+markers', name='Actual',
    line=dict(color=UOE_RED, width=3), marker=dict(size=8),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Actual</extra>'
))
fig.add_trace(go.Scatter(
    x=test_q['date'], 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|%Y-Q}<br>Ratio: %{y:.1f}x<extra>ARIMA</extra>'
))
fig.update_layout(title='ARIMA Forecast — Premium Ratio',
                  yaxis_title='Premium ratio', height=450)
fig.show()

5 — Exponential Smoothing (ETS)

With a short series, exponential smoothing’s parsimony is an advantage. We fit a damped-trend model.

Code
from statsmodels.tsa.holtwinters import ExponentialSmoothing

ets = ExponentialSmoothing(y_train, trend='add', damped_trend=True,
                           seasonal=None).fit(optimized=True)

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

# ── ETS plot (interactive) ───────────────────────────────────────
fig = go.Figure()
fig.add_trace(go.Scatter(
    x=train_q['date'], y=train_q['premium_ratio'],
    mode='lines+markers', name='Training data',
    line=dict(color=UOE_GREY, width=1.5), marker=dict(size=5),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Training</extra>'
))
fig.add_trace(go.Scatter(
    x=test_q['date'], y=test_q['premium_ratio'],
    mode='lines+markers', name='Actual',
    line=dict(color=UOE_RED, width=3), marker=dict(size=8),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Actual</extra>'
))
fig.add_trace(go.Scatter(
    x=test_q['date'], y=fc_ets.values,
    mode='lines+markers', name='ETS (damped)',
    line=dict(color=UOE_GOLD, width=2, dash='dash'),
    marker=dict(size=6, symbol='triangle-up'),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>ETS</extra>'
))
fig.update_layout(title='Exponential Smoothing Forecast — Premium Ratio',
                  yaxis_title='Premium ratio', height=450)
fig.show()

6 — State-Space Model: Local Linear Trend

A local linear trend model estimated via the Kalman filter can handle irregular level shifts in the premium ratio.

Code
llt = sm.tsa.UnobservedComponents(y_train, level='local linear trend')
llt_fit = llt.fit(disp=False)
print(llt_fit.summary().tables[0])

fc_ss = llt_fit.get_forecast(steps=HORIZON)
fc_ss_mean = fc_ss.predicted_mean
forecasts['State-Space (LLT)'] = fc_ss_mean.values

# ── State-space plot (interactive) ───────────────────────────────
fig = go.Figure()
fig.add_trace(go.Scatter(
    x=train_q['date'], y=train_q['premium_ratio'],
    mode='lines+markers', name='Training data',
    line=dict(color=UOE_GREY, width=1.5), marker=dict(size=5),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Training</extra>'
))
fig.add_trace(go.Scatter(
    x=test_q['date'], y=test_q['premium_ratio'],
    mode='lines+markers', name='Actual',
    line=dict(color=UOE_RED, width=3), marker=dict(size=8),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Actual</extra>'
))
fig.add_trace(go.Scatter(
    x=test_q['date'], y=fc_ss_mean.values,
    mode='lines+markers', name='State-Space (LLT)',
    line=dict(color=UOE_GREEN, width=2, dash='dash'),
    marker=dict(size=6, symbol='triangle-up'),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>State-Space</extra>'
))
fig.update_layout(title='State-Space Forecast — Premium Ratio',
                  yaxis_title='Premium ratio', height=450)
fig.show()
                        Unobserved Components Results                         
==============================================================================
Dep. Variable:          premium_ratio   No. Observations:                   21
Model:             local linear trend   Log Likelihood                -172.361
Date:                Mon, 10 Aug 2026   AIC                            350.721
Time:                        18:35:52   BIC                            353.554
Sample:                             0   HQIC                           351.201
                                 - 21                                         
Covariance Type:                  opg                                         
==============================================================================

7 — Machine Learning: Ridge Regression & Random Forest

We engineer features from the premium-ratio series (lags, rolling stats, quarter-of-year) and fit Ridge and Random Forest regressors.

Code
from sklearn.linear_model import Ridge
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error

# Feature engineering on quarterly premium-ratio series
def make_features_q(series):
    feat = pd.DataFrame(index=series.index)
    feat['lag_1'] = series.shift(1)
    feat['lag_2'] = series.shift(2)
    feat['lag_3'] = series.shift(3)
    feat['roll_mean_2'] = series.shift(1).rolling(2, min_periods=1).mean()
    feat['roll_std_2']  = series.shift(1).rolling(2, min_periods=1).std().fillna(0)
    # Quarter-of-year (Q2 = autumn expos, Q3 = Palermo)
    feat['quarter_num'] = series.index.quarter if hasattr(series.index, 'quarter') else 2
    feat['is_palermo'] = (feat['quarter_num'] == 3).astype(int)
    return feat

pr_series = quarterly.set_index('date')['premium_ratio']
X_all = make_features_q(pr_series)
y_all = pr_series

mask = X_all.notna().all(axis=1)
X_all, y_all = X_all[mask], y_all[mask]

X_tr = X_all.iloc[:-HORIZON]
y_tr = y_all.iloc[:-HORIZON]
X_te = X_all.iloc[-HORIZON:]
y_te = y_all.iloc[-HORIZON:]

ml_models = {
    'Ridge': Ridge(alpha=10.0),
    'Random Forest': RandomForestRegressor(n_estimators=200, max_depth=3,
                                           random_state=42),
}

for name, model in ml_models.items():
    model.fit(X_tr, y_tr)
    preds = model.predict(X_te)
    forecasts[name] = preds

# ── ML forecasts (interactive) ───────────────────────────────────
fig = go.Figure()
fig.add_trace(go.Scatter(
    x=train_q['date'], y=train_q['premium_ratio'],
    mode='lines+markers', name='Training data',
    line=dict(color=UOE_GREY, width=1.5), marker=dict(size=5),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Training</extra>'
))
fig.add_trace(go.Scatter(
    x=test_q['date'], y=test_q['premium_ratio'],
    mode='lines+markers', name='Actual',
    line=dict(color=UOE_RED, width=3), marker=dict(size=8),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Actual</extra>'
))

colours = {'Ridge': UOE_BLUE, 'Random Forest': UOE_GREEN}
for name in ['Ridge', 'Random Forest']:
    fc_arr = np.array(forecasts[name])[:len(test_q)]
    fig.add_trace(go.Scatter(
        x=test_q['date'], y=fc_arr,
        mode='lines+markers', name=name,
        line=dict(color=colours[name], width=2, dash='dash'),
        marker=dict(size=6, symbol='triangle-up'),
        hovertemplate='%%{x|%%Y-Q}<br>Ratio: %%{y:.1f}x<extra>%s</extra>' % name
    ))

fig.update_layout(title='Machine Learning Forecasts — Premium Ratio',
                  yaxis_title='Premium ratio', height=450)
fig.show()

8 — Neural Network (Feedforward)

With only ~15 training points, a deep network would overfit instantly. We use a small feedforward net (one hidden layer) as a non-linear regression on the same features.

Code
import tensorflow as tf
from tensorflow import keras

tf.random.set_seed(42)

# Normalise features
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_tr_s = scaler.fit_transform(X_tr)
X_te_s = scaler.transform(X_te)

nn = keras.Sequential([
    keras.layers.Dense(8, activation='relu', input_shape=(X_tr_s.shape[1],)),
    keras.layers.Dropout(0.3),
    keras.layers.Dense(1)
])

nn.compile(optimizer='adam', loss='mse')
history = nn.fit(X_tr_s, y_tr.values, epochs=200, batch_size=4,
                 validation_split=0.2, verbose=0)

preds_nn = nn.predict(X_te_s, verbose=0).flatten()
forecasts['Neural Net'] = preds_nn

# ── NN forecast (interactive — 2-panel: loss + forecast) ────────
fig = make_subplots(rows=1, cols=2,
    subplot_titles=['NN Training Loss', 'Neural Network Forecast'])

fig.add_trace(go.Scatter(
    y=history.history['loss'], mode='lines', name='Train loss',
    line=dict(color=UOE_RED, width=1.5),
    hovertemplate='Epoch %{x}<br>Loss: %{y:.5f}<extra>Train</extra>'
), row=1, col=1)
fig.add_trace(go.Scatter(
    y=history.history['val_loss'], mode='lines', name='Val loss',
    line=dict(color=UOE_BLUE, width=1.5),
    hovertemplate='Epoch %{x}<br>Loss: %{y:.5f}<extra>Val</extra>'
), row=1, col=1)

fig.add_trace(go.Scatter(
    x=train_q['date'], y=train_q['premium_ratio'],
    mode='lines+markers', name='Training data',
    line=dict(color=UOE_GREY, width=1.5), marker=dict(size=5),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Training</extra>'
), row=1, col=2)
fig.add_trace(go.Scatter(
    x=test_q['date'], y=test_q['premium_ratio'],
    mode='lines+markers', name='Actual',
    line=dict(color=UOE_RED, width=3), marker=dict(size=8),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Actual</extra>'
), row=1, col=2)
fig.add_trace(go.Scatter(
    x=test_q['date'], y=preds_nn,
    mode='lines+markers', name='Neural Net',
    line=dict(color=UOE_PURPLE, width=2, dash='dash'),
    marker=dict(size=6, symbol='triangle-up'),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Neural Net</extra>'
), row=1, col=2)

fig.update_layout(height=400, xaxis_title='Epoch', yaxis_title='MSE',
                  yaxis2_title='Premium ratio')
fig.show()

9 — Model Comparison

We evaluate all models on the held-out test quarters using RMSE, MAE, and MAPE — all computed on the premium ratio (not the nominal price), so errors reflect genuine forecasting skill rather than inflation tracking.

Code

actual = test_q['premium_ratio'].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("=" * 55)
print("MODEL COMPARISON — Premium Ratio (out-of-sample)")
print("=" * 55)
print(results_df.to_string())
print()
best = results_df.iloc[0]
print(f"Best: {best['Model']}  (RMSE = {best['RMSE']:.0f}, MAPE = {best['MAPE (%)']:.1f}%)")
=======================================================
MODEL COMPARISON — Premium Ratio (out-of-sample)
=======================================================
                  Model         RMSE          MAE   MAPE (%)
Rank                                                        
1                 Ridge   571.591590   445.710556  29.816024
2                 ARIMA   602.360056   460.339847  30.583347
3          ETS (damped)   891.172682   698.147733  46.475905
4     State-Space (LLT)  1137.832065   988.661252  62.076061
5         Random Forest  1532.062988  1069.631827  48.428385
6            Neural Net  2104.451734  2032.746161  99.607706

Best: Ridge  (RMSE = 572, MAPE = 29.8%)
Code
# ── All forecasts in one plot (interactive) ──────────────────────
fig = go.Figure()
fig.add_trace(go.Scatter(
    x=train_q['date'], y=train_q['premium_ratio'],
    mode='lines+markers', name='Training data',
    line=dict(color=UOE_GREY, width=1.5), marker=dict(size=5),
    opacity=0.4, hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Training</extra>'
))
fig.add_trace(go.Scatter(
    x=test_q['date'], y=actual,
    mode='lines+markers', name='Actual',
    line=dict(color=UOE_RED, width=3), marker=dict(size=8),
    hovertemplate='%{x|%Y-Q}<br>Ratio: %{y:.1f}x<extra>Actual</extra>'
))

colours_map = {
    'ARIMA': UOE_BLUE, 'ETS (damped)': UOE_GOLD,
    'State-Space (LLT)': UOE_GREEN,
    'Ridge': '#9D4EDD', 'Random Forest': '#118AB2',
    'Neural Net': 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_q['date'], 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|%%Y-Q}<br>Ratio: %%{y:.1f}x<extra>%s</extra>' % name
    ))

fig.update_layout(title='All Forecasts vs Actual — Premium Ratio',
                  yaxis_title='Premium ratio', height=500,
                  legend=dict(font=dict(size=9)))
fig.show()
Code
# ── MAPE bar chart (interactive) ──────────────────────────────────
fig = go.Figure()
colours_map = {
    'ARIMA': UOE_BLUE, 'ETS (damped)': UOE_GOLD,
    'State-Space (LLT)': UOE_GREEN,
    'Ridge': '#9D4EDD', 'Random Forest': '#118AB2',
    'Neural Net': 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=300,
                  yaxis=dict(autorange='reversed'))
fig.show()

10 — Ensemble Forecast & Policy Implications

Code
# ── Ensemble of top 3 ────────────────────────────────────────────
top3 = results_df['Model'].head(3).tolist()
ensemble = np.mean([np.array(forecasts[m])[:len(actual)] for m in top3], axis=0)
forecasts['Ensemble (top 3)'] = ensemble

rmse_e = np.sqrt(mean_squared_error(actual, ensemble))
mape_e = np.mean(np.abs((actual - ensemble) / actual)) * 100

print(f"Ensemble of: {', '.join(top3)}")
print(f"  RMSE : {rmse_e:.0f}")
print(f"  MAPE : {mape_e:.1f}%")

# Convert to nominal
novillo_test_arr = np.array(novillo_test[:len(actual)])
ens_nominal = ensemble * novillo_test_arr

fig = go.Figure()
fig.add_trace(go.Scatter(
    x=quarterly['date'], y=quarterly['avg_price_ars'] / 1e6,
    mode='lines+markers', name='Historical (nominal)',
    line=dict(color=UOE_GREY, width=1.5), marker=dict(size=4),
    opacity=0.5, hovertemplate='%{x|%Y-Q}<br>$%{y:.2f}M ARS<extra>Historical</extra>'
))
fig.add_trace(go.Scatter(
    x=test_q['date'], y=test_q['avg_price_ars'] / 1e6,
    mode='lines+markers', name='Actual (test)',
    line=dict(color=UOE_RED, width=3), marker=dict(size=8),
    hovertemplate='%{x|%Y-Q}<br>$%{y:.2f}M ARS<extra>Actual</extra>'
))
fig.add_trace(go.Scatter(
    x=test_q['date'], y=ens_nominal / 1e6,
    mode='lines+markers', name=f'Ensemble (MAPE={mape_e:.1f}%)',
    line=dict(color=UOE_BLUE, width=2.5, dash='dashdot'),
    marker=dict(size=7, symbol='diamond'),
    hovertemplate='%{x|%Y-Q}<br>$%{y:.2f}M ARS<extra>Ensemble</extra>'
))
fig.update_layout(title='Ensemble Forecast — Pedigree Angus Vaquillona Price',
                  yaxis_title='ARS (millions per head)', height=400)
fig.show()
Ensemble of: Ridge, ARIMA, ETS (damped)
  RMSE : 295
  MAPE : 14.8%

Policy Commentary

For breeders and cabañas:

  • The premium ratio has been remarkably stable at 1,000–2,500× the novillo kg price for PC vaquillonas and 2,000–10,000× for elite pedigree animals. This means the genetics premium has held its value through Argentina’s extreme inflation — pedigree cattle are a genuine store of value for breeders.
  • Palermo auctions command a persistent premium (roughly 2–3× the PC average), reflecting the championship-certification effect and the buyer pool at the Expo Rural.
  • Timing matters: autumn expos (May) tend to see more moderate averages than the mid-year Palermo peak.

For policymakers and breed associations:

  • A rising premium ratio signals healthy demand for genetic improvement — encouraging for Argentina’s long-run beef competitiveness.
  • If the ratio falls persistently (as it did during the 2020 COVID disruption), it may indicate that breeders are under cash-flow pressure and liquidating genetics below replacement cost.
  • The Sociedad Argentina de Angus and MAGyP can use these forecasts to track whether breeding incentives (tax credits, export permits for genetics) are maintaining the premium.

For students:

  • This is a textbook example of thin-market forecasting: fewer than 30 observations per year, irregular timing, and extreme heterogeneity. The premium-ratio transformation is a practical trick that applies to any market where the “raw” price is dominated by an external trend (inflation, exchange rate, commodity cycle).
  • Compare the performance of simple models (ARIMA, ETS) against ML and neural networks on this small sample — parsimony usually wins when data is scarce.

11 — How to Update This Notebook

  1. Add new auction results to the auction_data list in the data cell. The latest results are published at Entre Surcos y Corrales — Resultados de Cabañas and in Angus Digital.
  2. Update the novillo reference prices in novillo_monthly (from consignatarias.com.ar/mercado).
  3. Re-run all cells — the premium ratio, model fits, and comparison will refresh automatically.
Source What to look for URL
Entre Surcos y Corrales Cabaña auction results (vaquillonas PED/PC) resultados-cabanas-exposiciones.php
Angus Digital Expo results, breed statistics angusdigital.com.ar
Sociedad Argentina de Angus Official breed data angus.org.ar
La Nación Campo Palermo / Expo Rural coverage lanacion.com.ar/campo
Consignatarias Commercial novillo price consignatarias.com.ar/mercado