Forecasting Pedigree Angus Ternera Prices

Auction Data from Argentine Cabaña Sales

Author

Dr Juan Zurita — University of Edinburgh

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

Forecasting Pedigree Angus Ternera Prices

This notebook extends our pedigree cattle forecasting programme to terneras (female calves, typically under 12 months) sold at elite cabaña auctions. Terneras represent the earliest entry point into pedigree genetics: buyers acquire young animals whose productive value will only be realised over several breeding seasons. Like the companion vaquillona notebook, we draw 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 ternera prices matter:

  • For breeders, terneras are the most affordable route to introduce elite genetics into a herd; their price relative to vaquillonas signals the market’s discount for age/maturity risk.
  • For cabañas, the ternera-to-vaquillona price ratio indicates how much buyers value youth and future potential versus proven reproductive capacity.
  • For students, comparing forecasts across terneras and vaquillonas is a natural paired-market study in thin-sample econometrics.

Key insight: As with vaquillonas, we express ternera prices as a premium ratio over the commercial novillo price. This removes the dominant inflation trend and isolates the genetics premium.

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 Ternera Auction Prices

The dataset below compiles verified auction results for Angus terneras (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 ternera auction results
# Each entry: (date, avg_price_ARS, category, event, n_sold)
# ═══════════════════════════════════════════════════════════════════

auction_data = [
    # ── 2017 ──
    ('2017-07-28', 68_000,    'PED', 'Palermo 2017', 5),
    ('2017-10-20', 42_000,    'PC',  'Expo Primavera 2017', 10),
    # ── 2018 ──
    ('2018-05-20', 52_000,    'PC',  'Expo Otoño 2018', 12),
    ('2018-07-29', 95_000,    'PED', 'Palermo 2018', 4),
    ('2018-10-10', 65_000,    'PC',  'Expo Primavera 2018', 12),
    # ── 2019 ──
    ('2019-05-18', 62_000,    'PC',  'Expo Otoño 2019', 15),
    ('2019-07-28', 120_000,   'PED', 'Palermo 2019', 4),
    ('2019-09-15', 80_000,    'PC',  'Expo Primavera 2019', 10),
    # ── 2020 (COVID — reduced activity) ──
    ('2020-08-15', 150_000,   'PC',  'Remate virtual Casamú 2020', 20),
    ('2020-11-10', 185_000,   'PC',  'Expo Primavera 2020', 8),
    # ── 2021 ──
    ('2021-05-22', 240_000,   'PC',  'Expo Otoño 2021', 18),
    ('2021-07-30', 290_000,   'PED', 'Palermo 2021', 3),
    # ── 2022 ──
    ('2022-05-28', 310_000,   'PC',  'Remate PC Angus (Entre Surcos)', 60),
    ('2022-07-31', 1_360_000, 'PED', 'Palermo 2022', 5),
    ('2022-10-08', 450_000,   'PC',  'Expo Primavera 2022', 12),
    # ── 2023 ──
    ('2023-05-20', 880_000,   'PC',  'Expo Otoño 2023', 20),
    ('2023-07-31', 4_800_000, 'PED', 'Palermo 2023', 3),
    ('2023-10-08', 650_000,   'PC',  'La Pastoriza (Entre Surcos)', 15),
    # ── 2024 ──
    ('2024-05-18', 2_100_000, 'PC',  'Expo Otoño 2024', 25),
    ('2024-07-28', 4_200_000, 'PED', 'Palermo 2024', 5),
    ('2024-10-12', 2_400_000, 'PC',  'Expo Primavera 2024', 15),
    # ── 2025 ──
    ('2025-05-20', 2_500_000, 'PC',  'Expo Otoño Palermo 2025 (Entre Surcos)', 45),
    ('2025-06-05', 3_400_000, 'PC',  'Casamú (Entre Surcos)', 25),
    ('2025-07-26', 7_200_000, 'PED', 'Palermo 2025', 8),
    ('2025-08-22', 5_300_000, 'PED', 'Tres Marías (Entre Surcos)', 10),
    # ── 2026 ──
    ('2026-05-15', 4_100_000, 'PC',  'Expo Otoño 2026', 35),
    ('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: 27
Period: Jul 2017 – Jul 2026

Category breakdown:
          n_auctions  total_sold     avg_price
category                                      
PC                17         357  1.039176e+06
PED               10          54  3.300300e+06

      date  avg_price_ars category                                  event  n_sold
2024-07-28        4200000      PED                           Palermo 2024       5
2024-10-12        2400000       PC                    Expo Primavera 2024      15
2025-05-20        2500000       PC Expo Otoño Palermo 2025 (Entre Surcos)      45
2025-06-05        3400000       PC                  Casamú (Entre Surcos)      25
2025-07-26        7200000      PED                           Palermo 2025       8
2025-08-22        5300000      PED             Tres Marías (Entre Surcos)      10
2026-05-15        4100000       PC                        Expo Otoño 2026      35
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 ternera 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 = ternera price / novillo price (kg equivalent)")
print(f"  Interpretation: a pedigree ternera 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 = ternera price / novillo price (kg equivalent)
  Interpretation: a pedigree ternera 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          68000       50.514285    1346.153869                           Palermo 2017
2017-10-20       PC          42000       52.423537     801.166850                    Expo Primavera 2017
2018-05-20       PC          52000       56.917764     913.598780                        Expo Otoño 2018
2018-07-29      PED          95000       59.060052    1608.532291                           Palermo 2018
2018-10-10       PC          65000       62.403333    1041.611024                    Expo Primavera 2018
2019-05-18       PC          62000       65.131551     951.919600                        Expo Otoño 2019
2019-07-28      PED         120000       68.148052    1760.872062                           Palermo 2019
2019-09-15       PC          80000       68.300972    1171.286412                    Expo Primavera 2019
2020-08-15       PC         150000       97.489420    1538.628496             Remate virtual Casamú 2020
2020-11-10       PC         185000      123.959623    1492.421448                    Expo Primavera 2020
2021-05-22       PC         240000      225.603890    1063.811446                        Expo Otoño 2021
2021-07-30      PED         290000      228.384993    1269.785708                           Palermo 2021
2022-05-28       PC         310000      277.604361    1116.697153         Remate PC Angus (Entre Surcos)
2022-07-31      PED        1360000      291.767870    4661.239771                           Palermo 2022
2022-10-08       PC         450000      306.237425    1469.448092                    Expo Primavera 2022
2023-05-20       PC         880000      544.267350    1616.852452                        Expo Otoño 2023
2023-07-31      PED        4800000      744.541376    6446.921761                           Palermo 2023
2023-10-08       PC         650000     1034.413292     628.375530            La Pastoriza (Entre Surcos)
2024-05-18       PC        2100000     1615.651640    1299.785145                        Expo Otoño 2024
2024-07-28      PED        4200000     1757.862669    2389.265142                           Palermo 2024
2024-10-12       PC        2400000     1994.824695    1203.113239                    Expo Primavera 2024
2025-05-20       PC        2500000     2879.383490     868.241417 Expo Otoño Palermo 2025 (Entre Surcos)
2025-06-05       PC        3400000     2897.146505    1173.568542                  Casamú (Entre Surcos)
2025-07-26      PED        7200000     2958.465558    2433.694041                           Palermo 2025
2025-08-22      PED        5300000     3027.564580    1750.581981             Tres Marías (Entre Surcos)
2026-05-15       PC        4100000     4226.997617     969.955598                        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 — ternera price / novillo price per kg — strips out inflation and reveals the underlying genetics premium.

Compared to vaquillonas, terneras typically trade at a discount of 15–35% in the premium ratio, reflecting the greater age/maturity risk: the buyer must rear the animal for 1–2 additional years before it breeds.

Code
# ── 2.1  Nominal prices by category (interactive) ───────────────
fig = make_subplots(rows=1, cols=2,
    subplot_titles=['(A)  Nominal Ternera 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: 1,989 kg equiv
  Mean premium ratio  : 2,588 kg equiv
  Std                 : 1,667

── PC ──
  Median premium ratio: 1,117 kg equiv
  Mean premium ratio  : 1,136 kg equiv
  Std                 : 278
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   6.800000e+04    1346.153869     5.0         1.0
 2017Q4   4.200000e+04     801.166850    10.0         1.0
 2018Q2   5.200000e+04     913.598780    12.0         1.0
 2018Q3   9.500000e+04    1608.532291     4.0         1.0
 2018Q4   6.500000e+04    1041.611024    12.0         1.0
 2019Q2   6.200000e+04     951.919600    15.0         1.0
 2019Q3   9.142857e+04    1339.739455    14.0         2.0
 2020Q3   1.500000e+05    1538.628496    20.0         1.0
 2020Q4   1.850000e+05    1492.421448     8.0         1.0
 2021Q2   2.400000e+05    1063.811446    18.0         1.0
 2021Q3   2.900000e+05    1269.785708     3.0         1.0
 2022Q2   3.100000e+05    1116.697153    60.0         1.0
 2022Q3   1.360000e+06    4661.239771     5.0         1.0
 2022Q4   4.500000e+05    1469.448092    12.0         1.0
 2023Q2   8.800000e+05    1616.852452    20.0         1.0
 2023Q3   4.800000e+06    6446.921761     3.0         1.0
 2023Q4   6.500000e+05     628.375530    15.0         1.0
 2024Q2   2.100000e+06    1299.785145    25.0         1.0
 2024Q3   4.200000e+06    2389.265142     5.0         1.0
 2024Q4   2.400000e+06    1203.113239    15.0         1.0
 2025Q2   2.821429e+06     977.286819    70.0         2.0
 2025Q3   6.144444e+06    2054.187341    18.0         2.0
 2026Q2   4.100000e+06     969.955598    35.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 Angus Ternera / 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 ~27 auction-level observations aggregated to ~18 quarterly points, this is a genuinely thin sample — the same small-data challenge encountered in the companion vaquillona analysis.

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=1.395, p=0.9971
  → non-stationary at 5%

Best ARIMA order: (2, 1, 0)  (AIC = 348.4)

Forecast (premium ratio): [1975. 1248. 1217.]
Forecast (ARS nominal) : [8.42 5.39 5.35] 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)

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()

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         Random Forest   361.958621   330.982033  23.773283
2                 Ridge   432.425945   378.612051  21.415354
3                 ARIMA   600.625123   452.270051  25.871390
4          ETS (damped)   666.605905   424.955460  41.475428
5     State-Space (LLT)   671.161379   432.736950  41.983362
6            Neural Net  1826.994335  1741.307681  99.650584

Best: Random Forest  (RMSE = 362, MAPE = 23.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 Ternera Price',
                  yaxis_title='ARS (millions per head)', height=400)
fig.show()
Ensemble of: Random Forest, Ridge, ARIMA
  RMSE : 415
  MAPE : 16.7%

Policy Commentary

For breeders and cabañas:

  • Terneras trade at a discount to vaquillonas — typically 15–35% lower in the premium ratio — reflecting the additional rearing cost and age risk the buyer assumes. This discount has been remarkably stable across Argentina’s inflation regimes, confirming that the premium ratio captures genuine market fundamentals.
  • Palermo terneras command approximately 2× the PC average, mirroring the championship-certification premium observed in vaquillonas.
  • For cabañas deciding when to sell young females, the ternera-to-vaquillona ratio can guide the hold-vs-sell decision: if the gap narrows, holding calves to sell as vaquillonas adds less value.

For policymakers and breed associations:

  • The ternera premium ratio offers an early signal of demand for genetic improvement — it moves before the vaquillona ratio because ternera purchases reflect breeders’ forward-looking confidence.
  • A persistent decline in the ternera premium may indicate that breeders are postponing genetic investment, with long-run consequences for herd quality and beef export competitiveness.
  • Cross-referencing ternera and vaquillona forecasts gives the Sociedad Argentina de Angus a richer picture of breeding-stock demand across age cohorts.

For students:

  • Comparing the ternera and vaquillona notebooks side-by-side illustrates paired thin-market forecasting — the same methodology applied to two related but distinct markets. Which models generalise better? Does the age discount remain constant across forecast horizons?
  • The premium-ratio trick works for any pair of markets where one asset derives its value from another (young vs. mature livestock, junior vs. senior bonds, pre-revenue vs. revenue-stage startups).

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 (terneras 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