14. Quanto in Practice#

Authors: Brian Nguyen · Claire Kim · Created: 2026-08-22 · Tags: #Pricing #Quanto #Hedging #Validation

2. Objective & Input Framework#

Run notebook 13’s contract through the checks a desk would want before quoting it, in order:

  1. Split the decomposition by contract year - does the covariance replicate?

  2. Put a bootstrap interval on the covariance.

  3. Score a stylized exposed book unhedged, plain-HRCO-hedged and quanto-hedged on tail relief per premium dollar.

  4. Quote each instrument from trailing history and settle it on the following contract year.

  5. Sweep every trigger geography at matched trigger probability.

  6. Re-specify the trigger on a long-record airport index and split the quote into a weather factor and a market factor.

The legs are notebook 13’s, assembled by markets.MarketContext with no outage layer; Setup lists them and the one series read from disk.

3. Mathematical Model & Analytical Formulation#

The payoff objects are notebook 13’s, unchanged: the hourly HRCO payoff \(X_{h,d} = \max\{0,\ P_{E,h} - H\,P_{G,d}\}\) per MWh, the daily heat trigger \(Y_d = \mathbf{1}\{\text{CDD}_d \geq K\}\), and the quanto that pays \(X_{h,d}\,Y_d\):

\[E[XY] = E[X]\,E[Y] + \mathrm{Cov}(X, Y), \qquad \text{share} = \frac{\mathrm{Cov}(X, Y)}{E[XY]}\]

3.1 The Exposed Book and Its Hedges#

The exposure is a buyer of the day-ahead strip - one MW of flat load at the hourly DAM price, daily cost \(C_d = \sum_h P_{E,h}\) - and each hedge is one MW of an option strip charged per day at its in-sample fair premium \(\pi = E[H_d]\):

\[\text{cost}^{\text{book}}_d = C_d - H_d + \pi, \qquad H_d \in \Big\{\sum_h X_{h,d},\ \ \sum_h X_{h,d}\,Y_d\Big\}\]

Fair pricing equalizes mean cost, so the books differ only in shape. Expected shortfall is the mean cost over the worst tenth of days, and efficiency is tail relief per premium dollar:

\[\mathrm{ES}_{0.9} = E\big[\text{cost}_d \mid \text{cost}_d \geq Q_{0.9}\big], \qquad \text{efficiency} = \frac{\mathrm{ES}^{\text{unhedged}}_{0.9} - \mathrm{ES}^{\text{book}}_{0.9}}{\pi}\]

3.2 The Out-of-Time Quote#

For a fold with quote period \(Q\) and settlement period \(S\) the seller quotes the trailing fair value and books the difference at settlement:

\[\pi_Q = E_Q[\text{payoff}], \qquad \text{margin} = \pi_Q - E_S[\text{payoff}]\]

The identity \(E[XY] = P(Y)\,E[X \mid Y{=}1]\) supplies a second, exact rule that takes the trigger probability from the full weather history through the quote date:

\[\pi^{\text{fact}}_Q = \hat{P}_{\text{long}}[Y{=}1] \times \hat{E}_Q[X \mid Y{=}1]\]

The two rules differ only in which sample estimates \(P(Y{=}1)\).

3.3 The Block Bootstrap#

Heat waves are serially correlated, so single-day resampling understates uncertainty. Each replicate resamples contiguous \(L\)-day blocks of the calendar, circular at the edges, and recomputes the decomposition; the 2.5th and 97.5th percentiles are the interval.

3.4 The Matched-Probability Trigger#

To compare geographies rather than trigger levels, each candidate index \(W\) takes the level whose empirical exceedance on hub \(z\)’s covered days is closest to a target probability \(p_z\):

\[K_W = \arg\min_{L \in \{W_d\}} \big|\hat{P}(W \geq L) - p_z\big|, \qquad Y_d = \mathbf{1}\{W_d \geq K_W\}\]

All candidates then buy the same trigger probability and differ only in which days they select.

3.5 Notation#

Symbol

Meaning

Units

Code

\(X_{h,d}\), \(Y_d\)

HRCO payoff, heat trigger

$/MWh, 0/1

price_leg, condition

\(X_{h,d} Y_d\)

conditioned payoff

$/MWh

settled_value, conditioned_value

\(E[XY]\), \(E[X]E[Y]\)

quanto value, independence value

$/MWh

quanto_value, independence_value

\(\mathrm{Cov}(X,Y)\), share

the covariance and its share of quanto value

$/MWh, ratio

covariance, covariance_share

\(C_d\)

exposed book’s daily cost

$/MW-day

daily_cost

\(H_d\), \(\pi\)

daily hedge payoff, fair daily premium

$/MW-day

daily_plain_payoff / daily_quanto_payoff, premium

\(\mathrm{ES}_{0.9}\)

expected shortfall, worst tenth of days

$/MW-day

expected_shortfall

efficiency

tail relief per premium dollar

ratio

tail_relief_per_premium_dollar

\(\pi_Q\), margin

out-of-time quote and seller margin

$/MWh

quoted, seller_margin

\(L\), replicates

bootstrap block length and count

days, count

BLOCK_DAYS, N_REPLICATES

\(W_d\), \(K_W\)

candidate trigger index, matched trigger level

CDD65

candidate_cdd, threshold

\(p_z\)

target trigger probability for hub \(z\)

share of days

baseline_probability, COMMON_TRIGGER_PROBABILITY

\(\hat{P}_{\text{long}}\)

trigger probability from the full airport record

share of days

condition_probability

Setup: Inputs and Source Data#

Open the market, declare the contract and evaluation constants, and build the settlement book every section below scores. Only the airport record is read from disk: it is in the CAISO dataset catalog but no adapter source provides it yet.

Dataset

Granularity

Role

caiso_lmp_day_ahead_hourly

hourly (trading hub APNode)

the power leg and the exposed book’s cost

caiso_fuel_prices

hourly (fuel region)

the citygate gas legs, averaged to daily indices

caiso_weather_daily_location

daily (station)

the seven station CDD indices, and the load-weighted blend behind each market zone

caiso_weather_daily_weather_zone

daily (CAISO weather zone)

the four drainage-basin CDD indices, candidate triggers

openmeteo_temperature_daily

daily (airport station)

the 2018+ airport temperature record behind the 6.6 factorized quote

from datetime import date

import polars as pl
from bokeh.io import output_notebook, show
from bokeh.models import ColumnDataSource, FactorRange, HoverTool, Span
from bokeh.plotting import figure
from bokeh.resources import INLINE
from bokeh.transform import dodge

from chart_theme import SERIES, html_table, style
from markets import MarketContext
from markets.adapters.caiso.weather import CaisoWeatherSource
from pricing.core.schedule import SettlementSchedule
from pricing.core.schema import DateRange
from pricing.evaluation.bootstrap import CircularBlockBootstrap
from pricing.evaluation.concentration import value_concentration
from pricing.evaluation.folds import Fold, FoldSchedule, quote_then_settle
from pricing.evaluation.hedging import ExpectedShortfall, HedgeBook, compare_books
from pricing.evaluation.quoting import FactorizedQuote, NaiveHistoricalMean
from pricing.evaluation.scorecard import IndexScorecard
from pricing.evaluation.statistics import DecompositionStatistic, measure
from pricing.math.quantiles import matched_probability_level
from settings import config

output_notebook(INLINE, hide_banner=True)

Declare: Contract, Date Range, and Evaluation Constants#

  • DATE_RANGE - notebook 13’s range, unchanged: 2023-06-01 (first clean month above the OASIS LMP retention floor) through 2026-06-30.

  • HUBS - the three full-history CAISO trading hubs.

  • CONTRACT_YEARS - three June-through-May contract years as a lookup; quotes and settlements use whole years.

  • CCGT_HR, CDD_TRIGGER - notebook 13’s reference contract: strike heat rate 7.0, heat trigger at 15 zonal CDD65.

  • BLOCK_DAYS, N_REPLICATES, SEED - 10-day blocks (longer than any single heat event in the sample), 1,000 replicates, fixed seed.

  • TAIL_SHARE - the expected-shortfall tail, the worst tenth of days.

  • VOLUME_MW - the 50 MW reference contract for dollar conversions.

  • COMMON_TRIGGER_PROBABILITY - the 5%-of-days trigger probability at which the geography sweep compares hubs on equal footing.

  • VALLEY_AIRPORT - the Fresno airport station (FAT), chosen in section 6.6, carrying the factorized quote’s long weather record.

  • PRICING_FOLDS - the two out-of-time folds sections 6.4 and 6.6 score.

DATE_RANGE = (date(2023, 6, 1), date(2026, 6, 30))
HUBS = ("NP15", "SP15", "ZP26")

CONTRACT_YEAR_START_MONTH = 6
CONTRACT_YEARS = pl.DataFrame(
    {
        "contract_year_start": [2023, 2024, 2025],
        "contract_year": [
            "year 1 (2023-24)",
            "year 2 (2024-25)",
            "year 3 (2025-26)",
        ],
    }
)
CCGT_HR = 7.0
CDD_TRIGGER = 15.0
BLOCK_DAYS = 10
N_REPLICATES = 1000
SEED = 7
TAIL_SHARE = 0.10
VOLUME_MW = 50.0
COMMON_TRIGGER_PROBABILITY = 0.05
VALLEY_AIRPORT = "FAT"

PRICING_FOLDS = FoldSchedule.of(
    Fold(
        "quote year 2 from year 1",
        DateRange(date(2023, 6, 1), date(2024, 5, 31)),
        DateRange(date(2024, 6, 1), date(2025, 5, 31)),
    ),
    Fold(
        "quote year 3 from years 1-2",
        DateRange(date(2023, 6, 1), date(2025, 5, 31)),
        DateRange(date(2025, 6, 1), date(2026, 5, 31)),
    ),
)
FOLD_LABELS = PRICING_FOLDS.labels

market = MarketContext.for_market("CAISO", span=DATE_RANGE)
HOURS_PER_YEAR = SettlementSchedule.hourly().settled_intervals_per_year(
    market.definition.clock
)
annual_scale = VOLUME_MW * HOURS_PER_YEAR / 1000.0

html_table(
    CONTRACT_YEARS.select(
        pl.col("contract_year_start").cast(pl.String).alias("starting year"),
        pl.col("contract_year").alias("contract year"),
    ),
    kind="reference",
)
reference · 3 rows × 2 columns
starting year contract year
2023 year 1 (2023-24)
2024 year 2 (2024-25)
2025 year 3 (2025-26)

Source: The Priced Instruments and Their Books#

  • calls, quantos - the plain hourly HRCO at each trading hub and its conditioned version, built off the market so the gas leg, clock and degree-day base come from the registry.

  • book - the stacked per-interval settlement book: payoff \(X\), trigger \(Y\), conditioned payoff \(XY\), the hub’s DAM price, contract year.

  • daily - the same book at one row per hub-day, the unit the bootstrap, folds and hedge comparison consume.

calls = {
    hub: market.heat_rate_call(hub, CCGT_HR, volume_mw=VOLUME_MW) for hub in HUBS
}
quantos = {
    hub: calls[hub].conditional_on(
        market.cooling_degree_days(hub).above(CDD_TRIGGER)
    )
    for hub in HUBS
}
plans = {
    hub: quantos[hub].instrument.compile(quantos[hub].market_data) for hub in HUBS
}


def settlement_book(hub: str) -> pl.LazyFrame:
    """One hub's per-interval book with its power leg named for stacking.

    Parameters
    ----------
    hub : str
        Trading hub label.

    Returns
    -------
    pl.LazyFrame
        One row per settled hour carrying ``price_leg``, ``condition``,
        ``settled_value`` and ``power_price``.
    """
    market_data = quantos[hub].market_data
    return (
        plans[hub]
        .book()
        .select(
            "point",
            "trade_day",
            "condition",
            "price_leg",
            "settled_value",
            market.power(hub)
            .expression(market_data.schema, market_data.clock)
            .alias("power_price"),
        )
    )


contract_year_start = (
    pl.col("trade_day").dt.year()
    - (pl.col("trade_day").dt.month() < CONTRACT_YEAR_START_MONTH).cast(pl.Int32)
).alias("contract_year_start")

book = (
    pl.concat([settlement_book(hub) for hub in HUBS])
    .with_columns(contract_year_start)
    .join(CONTRACT_YEARS.lazy(), on="contract_year_start", how="left")
    .collect()
)
daily = book.group_by("point", "trade_day", "contract_year").agg(
    pl.col("price_leg").mean(),
    pl.col("settled_value").mean(),
    pl.col("condition").mean(),
    pl.col("power_price").sum().alias("daily_cost"),
    pl.col("price_leg").sum().alias("daily_plain_payoff"),
    pl.col("settled_value").sum().alias("daily_quanto_payoff"),
)

4. Quantitative Data Exploration & Assumption Testing#

4.1 Fold Coverage#

Days and hot days per hub per contract year (June 2026 sits outside every contract year and is excluded from fold-based sections):

fold_audit = (
    book.filter(pl.col("contract_year").is_not_null())
    .group_by("contract_year", "point")
    .agg(
        pl.col("trade_day").n_unique().alias("days"),
        pl.col("trade_day")
        .filter(pl.col("condition") == 1.0)
        .n_unique()
        .alias("hot days"),
    )
    .sort("contract_year", "point")
)
html_table(fold_audit.rename({"point": "hub"}))
9 rows × 4 columns
contract_year hub days hot days
year 1 (2023-24) NP15 366 9
year 1 (2023-24) SP15 366 0
year 1 (2023-24) ZP26 366 72
year 2 (2024-25) NP15 365 28
year 2 (2024-25) SP15 365 6
year 2 (2024-25) ZP26 365 106
year 3 (2025-26) NP15 365 8
year 3 (2025-26) SP15 365 0
year 3 (2025-26) ZP26 365 97

4.2 How Concentrated Is the Payoff?#

The share of each hub’s total HRCO payoff delivered by its top tenth of days, and how few days carry half of it - the concentration that makes single-day resampling and plain standard errors untrustworthy:

concentration = value_concentration(
    daily, "daily_plain_payoff", by=("point",)
)
concentration_audit = (
    concentration.shares_at(TAIL_SHARE)
    .select(
        pl.col("point").alias("hub"),
        (100 * pl.col("value_share"))
        .round(1)
        .alias("pct of payoff from top tenth of days"),
    )
    .join(
        concentration.half_value_periods.select(
            pl.col("point").alias("hub"),
            pl.col("periods_to_half_value").alias("days to half the payoff"),
        ),
        on="hub",
    )
    .join(
        daily.group_by("point")
        .agg(
            pl.col("daily_plain_payoff")
            .mean()
            .round(1)
            .alias("mean day payoff ($/MW-day)"),
            pl.col("daily_plain_payoff")
            .max()
            .round(0)
            .alias("max day payoff ($/MW-day)"),
        )
        .rename({"point": "hub"}),
        on="hub",
    )
    .sort("hub")
)
html_table(concentration_audit)
3 rows × 5 columns
hub pct of payoff from top tenth of days days to half the payoff mean day payoff ($/MW-day) max day payoff ($/MW-day)
NP15 33.30 231 204.60 4,228.00
SP15 72.60 40 64.60 4,301.00
ZP26 33.10 237 171.90 4,169.00

6. Quantitative Analysis & Validation#

6.1 Does Each Year Show the Covariance?#

year_split = measure(
    DecompositionStatistic(),
    book.filter(pl.col("contract_year").is_not_null()).lazy(),
    by=("contract_year", "point"),
).collect()

fig = figure(
    title="Covariance Share by Contract Year",
    x_range=CONTRACT_YEARS["contract_year"].to_list(),
    y_axis_label="Covariance Share of Quanto Value",
    height=380,
    sizing_mode="stretch_width",
    toolbar_location="above",
)
offsets = [-0.25, 0.0, 0.25]
renderers = []
for color, offset, hub in zip(SERIES, offsets, HUBS):
    hub_rows = year_split.filter(pl.col("point") == hub).sort("contract_year")
    source = ColumnDataSource(
        {
            "year": hub_rows["contract_year"].to_list(),
            "share": hub_rows["covariance_share"].to_list(),
            "covariance": hub_rows["covariance"].to_list(),
        }
    )
    renderers.append(
        fig.vbar(x=dodge("year", offset, range=fig.x_range), top="share", width=0.22,
                 color=color, legend_label=hub, name=hub, source=source)
    )
fig.add_layout(Span(location=0, dimension="width", line_color="black", line_width=1))
fig.add_tools(
    HoverTool(renderers=renderers, tooltips=[("hub", "$name"), ("year", "@year"),
              ("share", "@share{0.000}"),
              ("covariance ($/MWh)", "@covariance{0.000}")])
)
fig.legend.location = "top_left"
show(style(fig))

html_table(
    year_split.select(
        "contract_year",
        pl.col("point").alias("hub"),
        pl.col("condition_probability").round(3),
        pl.col("quanto_value").round(3),
        pl.col("independence_value").round(3),
        pl.col("covariance").round(3),
        pl.col("covariance_share").round(3),
    ).sort("contract_year", "hub")
)
9 rows × 7 columns
contract_year hub condition_probability quanto_value independence_value covariance covariance_share
year 1 (2023-24) NP15 0.025 0.447 0.296 0.150 0.337
year 1 (2023-24) SP15 0.000 0.000 0.000 0.000 —
year 1 (2023-24) ZP26 0.197 3.162 2.036 1.125 0.356
year 2 (2024-25) NP15 0.077 1.274 0.579 0.695 0.546
year 2 (2024-25) SP15 0.016 0.412 0.035 0.377 0.916
year 2 (2024-25) ZP26 0.290 2.672 1.783 0.889 0.333
year 3 (2025-26) NP15 0.022 0.209 0.138 0.071 0.339
year 3 (2025-26) SP15 0.000 0.000 0.000 0.000 —
year 3 (2025-26) ZP26 0.266 1.485 1.388 0.097 0.065

Year by Year#

The covariance has a stable sign and an unstable size: every hub-year in which the condition is met shows positive covariance, seven of nine cells, but NP15’s share runs 0.34, 0.55, 0.34 and ZP26 falls from 0.36 to 0.06 - NP15 because year 3 gave only eight days above the trigger, ZP26 because the payoff stopped concentrating on the 97 days it was met, more than year 1’s 72. SP15’s headline 0.89 rests on year 2 alone; its 15-CDD trigger was met on zero days in years 1 and 3. The sign replicates, the level does not.

6.2 How Wide Are the Intervals?#

The bootstrap resamples the daily book and re-runs the same decomposition statistic the point estimates come from:

bootstrap = CircularBlockBootstrap(
    block_days=BLOCK_DAYS, replicates=N_REPLICATES, seed=SEED, confidence=0.95
).intervals(
    DecompositionStatistic(), daily.lazy(), by=("point",), day_column="trade_day"
)
share_intervals = bootstrap.frame.filter(
    pl.col("statistic") == "covariance_share"
).sort("point")

fig = figure(
    title="Covariance Share With 95% Bootstrap Intervals",
    x_range=list(HUBS),
    y_axis_label="Covariance Share of Quanto Value",
    height=380,
    sizing_mode="stretch_width",
    toolbar_location="above",
)
interval_source = ColumnDataSource(
    {
        "hub": share_intervals["point"].to_list(),
        "point": share_intervals["point_estimate"].to_list(),
        "lower": share_intervals["lower"].to_list(),
        "upper": share_intervals["upper"].to_list(),
    }
)
fig.segment("hub", "lower", "hub", "upper", source=interval_source,
            color=SERIES[0], line_width=3, alpha=0.5)
interval_points = fig.scatter("hub", "point", source=interval_source,
                              color=SERIES[0], size=10)
fig.add_layout(Span(location=0, dimension="width", line_color="black", line_width=1))
fig.add_tools(
    HoverTool(renderers=[interval_points],
              tooltips=[("hub", "@hub"), ("point", "@point{0.000}"),
                        ("95% interval", "@lower{0.000} to @upper{0.000}")])
)
show(style(fig))

html_table(
    bootstrap.frame.filter(
        pl.col("statistic").is_in(["covariance", "covariance_share"])
    )
    .with_columns(
        pl.when(pl.col("statistic") == "covariance")
        .then(pl.col("point_estimate") * annual_scale)
        .alias("point ($k/yr, 50 MW)"),
        pl.when(pl.col("statistic") == "covariance")
        .then(pl.col("lower") * annual_scale)
        .alias("lower ($k/yr)"),
        pl.when(pl.col("statistic") == "covariance")
        .then(pl.col("upper") * annual_scale)
        .alias("upper ($k/yr)"),
    )
    .select(
        pl.col("point").alias("hub"),
        "statistic",
        pl.col("point_estimate").round(3).alias("point"),
        pl.col("lower").round(3),
        pl.col("upper").round(3),
        pl.col("point ($k/yr, 50 MW)").round(1),
        pl.col("lower ($k/yr)").round(1),
        pl.col("upper ($k/yr)").round(1),
    )
    .sort("hub", "statistic")
)
6 rows × 8 columns
hub statistic point lower upper point ($k/yr, 50 MW) lower ($k/yr) upper ($k/yr)
NP15 covariance 0.279 0.066 0.561 122.10 28.70 245.80
NP15 covariance_share 0.434 0.200 0.563 — — —
SP15 covariance 0.119 0.000 0.367 52.20 0.00 161.00
SP15 covariance_share 0.893 0.579 0.950 — — —
ZP26 covariance 0.593 0.111 1.201 260.00 48.70 526.40
ZP26 covariance_share 0.241 0.065 0.376 — — —

What the Intervals Say#

NP15’s share interval [0.20, 0.56] and ZP26’s [0.07, 0.38] sit entirely above zero, so the effect is not the artifact of one resampled heat wave. SP15’s covariance lower bound is exactly zero - many replicates contain no 15-CDD day - so its 0.89 share is conditional on the contract paying at all. In dollars at 50 MW the NP15 covariance is about 122 thousand a year inside a 29-to-246 interval and ZP26 about 260 inside 49-to-526, which argues for a premium above the point estimate.

6.3 Does the Hedge Do Its Job?#

One exposure and two hedged books, each charged the in-sample mean of its own payoff, so every book costs the same on average and the comparison is one of tails:

hedge_books = compare_books(
    daily,
    "daily_cost",
    (
        HedgeBook("plain HRCO", "daily_plain_payoff"),
        HedgeBook("quanto", "daily_quanto_payoff"),
    ),
    by=("point",),
    shortfall=ExpectedShortfall(TAIL_SHARE),
)

hedged_only = hedge_books.frame.filter(pl.col("book") != "unhedged")
fig = figure(
    title="Tail Relief per Premium Dollar, by Hedge",
    x_range=list(HUBS),
    y_axis_label="Shortfall Reduction per Premium Dollar",
    height=380,
    sizing_mode="stretch_width",
    toolbar_location="above",
)
book_offsets = {"plain HRCO": -0.17, "quanto": 0.17}
book_colors = {"plain HRCO": SERIES[0], "quanto": SERIES[1]}
renderers = []
for book_name, offset in book_offsets.items():
    book_rows = hedged_only.filter(pl.col("book") == book_name).sort("point")
    source = ColumnDataSource(
        {
            "hub": book_rows["point"].to_list(),
            "relief": book_rows["tail_relief_per_premium_dollar"].to_list(),
            "premium": book_rows["premium"].to_list(),
        }
    )
    renderers.append(
        fig.vbar(x=dodge("hub", offset, range=fig.x_range), top="relief", width=0.3,
                 color=book_colors[book_name], legend_label=book_name,
                 name=book_name, source=source)
    )
fig.add_tools(
    HoverTool(renderers=renderers, tooltips=[("book", "$name"), ("hub", "@hub"),
              ("relief per dollar", "@relief{0.00}"),
              ("daily premium ($/MW-day)", "@premium{0.00}")])
)
fig.legend.location = "top_left"
show(style(fig))

html_table(
    hedge_books.frame.select(
        pl.col("point").alias("hub"),
        "book",
        pl.col("mean_cost").round(0).alias("mean daily cost ($/MW-day)"),
        pl.col("cost_deviation").round(0).alias("sd ($/MW-day)"),
        pl.col("expected_shortfall").round(0).alias("worst-tenth mean ($/MW-day)"),
        pl.col("premium").round(1).alias("daily premium ($/MW-day)"),
        pl.col("tail_relief_per_premium_dollar").round(2)
        .alias("tail relief per premium dollar"),
    ).sort("hub", "book")
)
9 rows × 7 columns
hub book mean daily cost ($/MW-day) sd ($/MW-day) worst-tenth mean ($/MW-day) daily premium ($/MW-day) tail relief per premium dollar
NP15 plain HRCO 946.00 299.00 1,447.00 204.60 1.810
NP15 quanto 946.00 467.00 1,800.00 15.40 1.240
NP15 unhedged 946.00 475.00 1,819.00 — —
SP15 plain HRCO 812.00 369.00 1,481.00 64.60 5.500
SP15 quanto 812.00 507.00 1,819.00 3.20 5.240
SP15 unhedged 812.00 512.00 1,836.00 — —
ZP26 plain HRCO 794.00 313.00 1,320.00 171.90 1.880
ZP26 quanto 794.00 393.00 1,517.00 59.10 2.130
ZP26 unhedged 794.00 457.00 1,643.00 — —

The Hedge Verdict#

Per premium dollar the quanto is competitive with the plain option - behind at NP15 (1.24 vs 1.81) and SP15 (5.24 vs 5.50), ahead at ZP26 (2.13 vs 1.88) - at a fraction of the outlay: 15 dollars per MW-day at NP15 against 205 for the plain strip, 3 against 65 at SP15, 59 against 172 at ZP26. It is a targeted instrument, not a full tail hedge: the plain HRCO takes NP15’s worst-tenth mean cost from 1,819 to 1,447 while the quanto moves it only to 1,800, because most expensive days are not 15-CDD days. SP15’s efficiency rests on six days the condition is met.

6.4 Does a Quote Survive the Future?#

One fold protocol prices both instruments: quanto_value is the quanto premium \(E[XY]\) and price_leg_value the plain HRCO premium \(E[X]\), each quoted on the fold’s history and settled on its future.

INSTRUMENT_LABELS = pl.DataFrame(
    {
        "statistic": ["price_leg_value", "quanto_value"],
        "instrument": ["plain HRCO", "quanto"],
    }
)
pricing_test = (
    pl.concat(
        [
            quote_then_settle(
                plans[hub], PRICING_FOLDS, DecompositionStatistic(), by=("point",)
            )
            for hub in HUBS
        ]
    )
    .join(INSTRUMENT_LABELS, on="statistic")
    .rename({"point": "hub"})
)

quanto_quotes = pricing_test.filter(pl.col("instrument") == "quanto")
fold_factors = [(fold, hub) for fold in FOLD_LABELS for hub in HUBS]
fig = figure(
    title="Quanto Quotes Against Settlement, by Fold",
    x_range=FactorRange(*fold_factors),
    y_axis_label="Premium / Realized Payoff ($/MWh)",
    height=400,
    sizing_mode="stretch_width",
    toolbar_location="above",
)
quote_source = ColumnDataSource(
    {
        "factor": list(zip(quanto_quotes["fold"].to_list(),
                           quanto_quotes["hub"].to_list())),
        "quoted": quanto_quotes["quoted"].to_list(),
        "realized": quanto_quotes["realized"].to_list(),
    }
)
fig.vbar(x=dodge("factor", -0.17, range=fig.x_range), top="quoted", width=0.3,
         color=SERIES[0], legend_label="quoted premium", source=quote_source)
fig.vbar(x=dodge("factor", 0.17, range=fig.x_range), top="realized", width=0.3,
         color=SERIES[1], legend_label="realized payoff", source=quote_source)
fig.add_tools(
    HoverTool(tooltips=[("fold, hub", "@factor"), ("quoted", "@quoted{0.000}"),
              ("realized", "@realized{0.000}")])
)
fig.xaxis.major_label_orientation = 0.9
fig.legend.location = "top_right"
show(style(fig))

html_table(
    pricing_test.with_columns(
        (pl.col("seller_margin") * annual_scale).round(1)
        .alias("seller margin ($k/yr, 50 MW)")
    ).select(
        "fold",
        "hub",
        "instrument",
        pl.col("quoted").round(3),
        pl.col("realized").round(3),
        pl.col("seller_margin").round(3),
        "seller margin ($k/yr, 50 MW)",
    ).sort("fold", "hub", "instrument")
)
12 rows × 7 columns
fold hub instrument quoted realized seller_margin seller margin ($k/yr, 50 MW)
quote year 2 from year 1 NP15 plain HRCO 12.05 7.544 4.511 1,977.20
quote year 2 from year 1 NP15 quanto 0.45 1.274 −0.827 −362.40
quote year 2 from year 1 SP15 plain HRCO 5.96 2.110 3.844 1,685.00
quote year 2 from year 1 SP15 quanto 0.00 0.412 −0.412 −180.50
quote year 2 from year 1 ZP26 plain HRCO 10.35 6.139 4.212 1,846.10
quote year 2 from year 1 ZP26 quanto 3.16 2.672 0.490 214.90
quote year 3 from years 1-2 NP15 plain HRCO 9.80 6.289 3.514 1,540.00
quote year 3 from years 1-2 NP15 quanto 0.86 0.209 0.651 285.30
quote year 3 from years 1-2 SP15 plain HRCO 4.04 0.226 3.809 1,669.40
quote year 3 from years 1-2 SP15 quanto 0.21 0.000 0.206 90.10
quote year 3 from years 1-2 ZP26 plain HRCO 8.25 5.222 3.026 1,326.30
quote year 3 from years 1-2 ZP26 quanto 2.92 1.485 1.432 627.70

The Settlement Verdict#

The plain HRCO was overpriced by trailing history in every fold at every hub - seller margins of 0.36 to 0.94 of the quoted premium - one market regime seen twice (2023’s rich spark spreads softening through 2026), not a repeatable edge. The zonal-triggered quanto is not quotable where its trigger is rare: NP15’s first fold undercharged by 1.8 times the premium and SP15’s quoted zero, then paid out. Only ZP26, whose condition is met roughly a quarter of days, keeps margins inside half the premium. The failure scales inversely with trigger frequency, so section 6.6 attacks it with a longer record.

6.5 Which Geography Should the Trigger Settle On?#

A trading hub is a price zone, not a place: NP15’s capacity-weighted heat index blends coastal and Central Valley thermometers that disagree. This section sweeps every daily CDD index the weather source publishes - three market-zone blends, four CAISO weather zones, seven stations - each at the trigger level matched to the same probability on the hub’s covered days. The stations and each market zone’s weights:

WEATHER_SOURCE = CaisoWeatherSource()
CANDIDATES = WEATHER_SOURCE.candidate_degree_day_indices()
GEOGRAPHY = {
    **{region: "market zone" for region in market.definition.weather_regions},
    **{zone: "weather zone" for zone in WEATHER_SOURCE.weather_zones},
    **{station: "station" for station in WEATHER_SOURCE.stations},
}

crosswalk = pl.read_parquet(
    config("DATA_DIR") / "processed" / "weather_zone_crosswalk.parquet"
)
blend_weights = pl.DataFrame(
    [
        {"location": station, "market zone": region, "zonal blend weight": weight}
        for region in market.definition.weather_regions
        for station, weight in WEATHER_SOURCE.blend_weights(region).items()
    ]
)
html_table(
    blend_weights.join(
        crosswalk.select("location", "weather_zone_id"), on="location", how="left"
    )
    .select(
        pl.col("location").str.replace_all("_", " ").str.to_titlecase()
        .alias("station"),
        "market zone",
        pl.col("weather_zone_id").alias("weather zone"),
        "zonal blend weight",
    )
    .sort("market zone", "station"),
    kind="reference",
)
reference · 7 rows × 4 columns
station market zone weather zone zonal blend weight
Sacramento NP15 CA-02 0.500
San Jose NP15 CA-04 0.500
Los Angeles SP15 CA-06 0.683
San Diego SP15 CA-06 0.317
Bakersfield ZP26 CA-05 0.348
Fresno ZP26 CA-05 0.435
Merced ZP26 CA-05 0.217

Inputs: the fourteen candidate indices and each hub’s settlement book. Operation: solve every candidate’s trigger level at a target probability over a stated span, join the 0/1 condition onto the book, and score all fourteen in one grouped pass. Validation: each candidate’s realized trigger probability is reported beside its matched level, so a geography whose sample cannot hit the target says so.

CANDIDATE_RECORDS = {
    index.temperature.regions[0]: (
        market.repository.daily_record(index, market.span)
        .select(
            "trade_day", pl.col(index.name).alias("candidate_cdd")
        )
        .unique()
        .collect()
    )
    for index in CANDIDATES
}
candidate_daily = pl.concat(
    [
        record.with_columns(
            pl.lit(label).alias("candidate"),
            pl.lit(GEOGRAPHY[label]).alias("kind"),
        )
        for label, record in CANDIDATE_RECORDS.items()
    ]
)


def matched_thresholds(
    probabilities: dict[str, float], span: tuple[date, date]
) -> pl.DataFrame:
    """Each candidate index's trigger level at each hub's target probability.

    Parameters
    ----------
    probabilities : dict of str to float
        Hub label to the trigger probability its level should buy.
    span : tuple of datetime.date
        The inclusive calibration range each level is solved on.

    Returns
    -------
    pl.DataFrame
        Columns ``point``, ``candidate`` and ``threshold``.
    """
    return pl.DataFrame(
        [
            {
                "point": hub,
                "candidate": label,
                "threshold": matched_probability_level(
                    record.filter(pl.col("trade_day").is_between(*span))[
                        "candidate_cdd"
                    ].to_list(),
                    probability,
                ),
            }
            for hub, probability in probabilities.items()
            for label, record in CANDIDATE_RECORDS.items()
        ]
    )


def scored(thresholds: pl.DataFrame, span: tuple[date, date]) -> pl.DataFrame:
    """Score every candidate geography against every hub's price leg.

    Parameters
    ----------
    thresholds : pl.DataFrame
        Per hub and candidate trigger levels.
    span : tuple of datetime.date
        The inclusive range of trade days to score over.

    Returns
    -------
    pl.DataFrame
        One row per hub and candidate, carrying the decomposition and the
        scorecard's correlation, tail uplift and index agreement.
    """
    joined = (
        book.filter(pl.col("trade_day").is_between(*span))
        .join(candidate_daily, on="trade_day")
        .join(thresholds, on=["point", "candidate"])
        .with_columns(
            (pl.col("candidate_cdd") >= pl.col("threshold"))
            .cast(pl.Float64)
            .alias("condition_leg")
        )
        .with_columns(
            (pl.col("price_leg") * pl.col("condition_leg")).alias("conditioned_value")
        )
    )
    return (
        IndexScorecard(
            value_column="conditioned_value",
            price_leg_column="price_leg",
            condition_column="condition_leg",
        )
        .score(joined.lazy(), by=("point", "candidate", "kind"))
        .collect()
        .join(thresholds, on=["point", "candidate"])
    )


baseline_probability = {
    row["point"]: row["probability"]
    for row in daily.group_by("point")
    .agg(pl.col("condition").mean().alias("probability"))
    .sort("point")
    .iter_rows(named=True)
}
common_probability = {hub: COMMON_TRIGGER_PROBABILITY for hub in HUBS}

common_sweep = scored(matched_thresholds(common_probability, DATE_RANGE), DATE_RANGE)
matched_sweep = scored(matched_thresholds(baseline_probability, DATE_RANGE), DATE_RANGE)

index_order = (
    common_sweep.group_by("candidate")
    .agg(pl.col("covariance_share").mean())
    .sort("covariance_share")["candidate"]
    .to_list()
)
fig = figure(
    title="Covariance Share by Trigger Index at a Common 5% Trigger Probability",
    y_range=index_order,
    x_axis_label="Covariance Share of Quanto Value",
    height=460,
    sizing_mode="stretch_width",
    toolbar_location="above",
)
renderers = []
for color, hub in zip(SERIES, HUBS):
    hub_rows = common_sweep.filter(pl.col("point") == hub)
    source = ColumnDataSource(
        {
            "index": hub_rows["candidate"].to_list(),
            "share": hub_rows["covariance_share"].to_list(),
            "kind": hub_rows["kind"].to_list(),
            "threshold": hub_rows["threshold"].to_list(),
        }
    )
    renderers.append(
        fig.scatter("share", "index", source=source, color=color, size=9,
                    legend_label=hub, name=hub)
    )
fig.add_tools(
    HoverTool(renderers=renderers,
              tooltips=[("hub", "$name"), ("index", "@index (@kind)"),
                        ("share", "@share{0.000}"),
                        ("trigger (CDD65)", "@threshold{0.0}")])
)
fig.legend.location = "bottom_right"
show(style(fig))

Each hub keeps its own baseline trigger probability, with every candidate ranked against the hub’s own zonal index:

ranked = matched_sweep.sort(
    "point", "covariance", descending=[False, True]
).with_columns((pl.int_range(pl.len()).over("point") + 1).alias("rank"))
html_table(
    pl.concat(
        [
            ranked.group_by("point", maintain_order=True).head(3),
            ranked.filter(pl.col("candidate") == pl.col("point")),
        ]
    )
    .unique(subset=["point", "candidate"], maintain_order=True)
    .sort("point", "rank")
    .select(
        pl.col("point").alias("hub"),
        "rank",
        pl.col("candidate").alias("index"),
        "kind",
        pl.col("threshold").round(1).alias("trigger (CDD65)"),
        pl.col("condition_probability").round(3).alias("trigger probability"),
        pl.col("quanto_value").round(3),
        pl.col("covariance").round(3),
        pl.col("covariance_share").round(3),
    )
)
11 rows × 9 columns
hub rank index kind trigger (CDD65) trigger probability quanto_value covariance covariance_share
NP15 1 merced station 23.30 0.043 1.073 0.709 0.661
NP15 2 bakersfield station 29.20 0.043 1.051 0.687 0.654
NP15 3 ZP26 market zone 26.60 0.043 1.028 0.665 0.647
NP15 13 NP15 market zone 15.00 0.043 0.642 0.279 0.434
SP15 1 South Coast Drainage weather zone 16.30 0.005 0.133 0.119 0.893
SP15 2 SP15 market zone 18.20 0.005 0.133 0.119 0.893
SP15 3 los_angeles station 21.40 0.005 0.133 0.119 0.893
ZP26 1 san_diego station 2.20 0.261 2.645 0.775 0.293
ZP26 2 SP15 market zone 4.20 0.261 2.631 0.762 0.289
ZP26 3 South Coast Drainage weather zone 3.70 0.261 2.630 0.760 0.289
ZP26 13 ZP26 market zone 15.00 0.261 2.463 0.593 0.241

The common-probability sweep re-run on contract years 1-2 against contract year 3 separately, with triggers re-matched within each period:

EARLY_RANGE = (date(2023, 6, 1), date(2025, 5, 31))
LATE_RANGE = (date(2025, 6, 1), date(2026, 5, 31))

sweep_early = scored(
    matched_thresholds(common_probability, EARLY_RANGE), EARLY_RANGE
)
sweep_late = scored(matched_thresholds(common_probability, LATE_RANGE), LATE_RANGE)
stability = sweep_early.join(
    sweep_late, on=["point", "candidate", "kind"], suffix="_y3"
)

fig = figure(
    title="Trigger Index Stability: Years 1-2 Against Year 3",
    x_axis_label="Covariance Share, Years 1-2",
    y_axis_label="Covariance Share, Year 3",
    height=400,
    sizing_mode="stretch_width",
    toolbar_location="above",
)
fig.line([0, 1], [0, 1], color="black", line_dash="dashed", line_width=1)
renderers = []
for color, hub in zip(SERIES, HUBS):
    hub_rows = stability.filter(pl.col("point") == hub)
    source = ColumnDataSource(
        {
            "early": hub_rows["covariance_share"].to_list(),
            "late": hub_rows["covariance_share_y3"].to_list(),
            "index": hub_rows["candidate"].to_list(),
            "kind": hub_rows["kind"].to_list(),
        }
    )
    renderers.append(
        fig.scatter("early", "late", source=source, color=color, size=9,
                    legend_label=hub, name=hub)
    )
fig.add_tools(
    HoverTool(renderers=renderers,
              tooltips=[("hub", "$name"), ("index", "@index (@kind)"),
                        ("share years 1-2", "@early{0.000}"),
                        ("share year 3", "@late{0.000}")])
)
fig.legend.location = "top_left"
show(style(fig))

stability_receipt = pl.concat(
    [
        stability.sort("point", "covariance", descending=[False, True])
        .group_by("point", maintain_order=True).head(1),
        stability.filter(pl.col("candidate") == pl.col("point")),
    ]
).unique(subset=["point", "candidate"], maintain_order=True)
html_table(
    stability_receipt.sort("point")
    .select(
        pl.col("point").alias("hub"),
        pl.col("candidate").alias("index"),
        "kind",
        pl.col("covariance").round(3).alias("covariance, years 1-2"),
        pl.col("covariance_y3").round(3).alias("covariance, year 3"),
        pl.col("covariance_share").round(3).alias("share, years 1-2"),
        pl.col("covariance_share_y3").round(3).alias("share, year 3"),
    )
)
6 rows × 7 columns
hub index kind covariance, years 1-2 covariance, year 3 share, years 1-2 share, year 3
NP15 merced station 0.970 0.160 0.661 0.340
NP15 NP15 market zone 0.363 0.140 0.423 0.312
SP15 merced station 0.943 0.078 0.822 0.874
SP15 SP15 market zone 0.882 0.100 0.812 0.900
ZP26 merced station 0.861 0.145 0.673 0.359
ZP26 ZP26 market zone 0.810 0.142 0.660 0.355

The Geography Verdict#

NP15’s own index is nearly the worst thermometer for NP15: rank 13 of 14 at its baseline probability (share 0.43), while Central Valley indices take the top three - Merced 0.66, Bakersfield 0.65, ZP26’s blend 0.65. The coast-heavy capacity weighting dilutes the heat that moves NP15 prices, and ZP26’s own blend ranks 13 of 14 for ZP26 for the same reason. At a common 5% probability San Joaquin Drainage is first at all three hubs, SP15 included, where Bakersfield (0.84) is above SP15’s own index (0.77).

Year 3 scrambles the local rankings: Merced’s share falls from 0.66 to 0.34 at NP15 and 0.67 to 0.36 at ZP26 - a wider swing than NP15’s own market-zone blend (0.42 to 0.31), about the same as ZP26’s (0.66 to 0.36), while both SP15 series rise. Settle the weather leg on one standardized broad index rather than a bespoke per-hub station. The broadest index available here is a market-zone one; adding a system weather region to the CAISO definition is the graduation that would put a system-wide blend in the field.

6.6 The Practice Contract: An Airport Trigger With a Long Record#

Two design moves attack section 6.4’s estimation problem. First, settle the weather leg on an airport station, the convention of listed weather derivatives, for its long record: Fresno (FAT, daily since 2018) is the only Central Valley airport the raw weather cache carries. Second, quote by the factorization of section 3.2 - trigger probability from the full airport record, payoff-given-trigger from the market history. The airport record is the one series read from disk here, because openmeteo_temperature_daily has no adapter source yet; providing one is the next graduation this section asks for. It first has to agree with the weather build’s Fresno station over the pricing range:

DEGREE_DAY_BASE = market.definition.degree_day_base
airport_cdd = (
    pl.scan_parquet(
        config("DATA_DIR") / "opensource" / "openmeteo_temperature_daily.parquet"
    )
    .filter(pl.col("station") == VALLEY_AIRPORT)
    .select(
        pl.col("interval_start_utc").dt.date().alias("trade_day"),
        (pl.col("temp_mean_f") - DEGREE_DAY_BASE)
        .clip(lower_bound=0.0)
        .alias("airport_cdd"),
    )
    .collect()
)
overlap = airport_cdd.join(
    CANDIDATE_RECORDS["fresno"].rename({"candidate_cdd": "build_cdd"}),
    on="trade_day",
)
html_table(
    overlap.select(
        pl.len().alias("overlap days"),
        pl.corr("airport_cdd", "build_cdd").round(4).alias("correlation"),
        (pl.col("airport_cdd") - pl.col("build_cdd")).abs().mean().round(2)
        .alias("mean abs difference (CDD65)"),
        pl.col("airport_cdd").mean().round(2).alias("mean airport CDD65"),
        pl.col("build_cdd").mean().round(2).alias("mean build CDD65"),
    ).with_columns(pl.lit(airport_cdd.height).alias("airport record days"))
)
1 rows × 6 columns
overlap days correlation mean abs difference (CDD65) mean airport CDD65 mean build CDD65 airport record days
1,126 0.983 1.090 7.310 7.830 3,160

Each hub keeps its baseline trigger probability, mapped onto the airport index by the matched-probability rule. In-sample decomposition under the airport trigger, against the hub’s own zonal index:

airport_sample = airport_cdd.filter(
    pl.col("trade_day").is_between(*DATE_RANGE)
)["airport_cdd"].to_list()
airport_thresholds = pl.DataFrame(
    [
        {
            "point": hub,
            "threshold": matched_probability_level(airport_sample, probability),
        }
        for hub, probability in baseline_probability.items()
    ]
)
valley = (
    book.join(airport_cdd, on="trade_day")
    .join(airport_thresholds, on="point")
    .with_columns(
        (pl.col("airport_cdd") >= pl.col("threshold"))
        .cast(pl.Float64)
        .alias("condition_leg")
    )
    .with_columns(
        (pl.col("price_leg") * pl.col("condition_leg")).alias("conditioned_value")
    )
)
airport_matched = (
    IndexScorecard(
        value_column="conditioned_value",
        price_leg_column="price_leg",
        condition_column="condition_leg",
    )
    .score(valley.lazy(), by=("point",))
    .collect()
    .join(airport_thresholds, on="point")
    .with_columns(pl.lit("Fresno Airport").alias("candidate"))
)
html_table(
    pl.concat(
        [
            airport_matched.select(
                "point", "candidate", "threshold", "condition_probability",
                "quanto_value", "covariance", "covariance_share",
            ),
            ranked.filter(pl.col("candidate") == pl.col("point")).select(
                "point", "candidate", "threshold", "condition_probability",
                "quanto_value", "covariance", "covariance_share",
            ),
        ]
    )
    .sort("point", "covariance", descending=[False, True])
    .select(
        pl.col("point").alias("hub"),
        pl.col("candidate").alias("index"),
        pl.col("threshold").round(1).alias("trigger (CDD65)"),
        pl.col("condition_probability").round(3).alias("trigger probability"),
        pl.col("quanto_value").round(3),
        pl.col("covariance").round(3),
        pl.col("covariance_share").round(3),
    )
)
6 rows × 7 columns
hub index trigger (CDD65) trigger probability quanto_value covariance covariance_share
NP15 Fresno Airport 25.20 0.043 1.034 0.671 0.649
NP15 NP15 15.00 0.043 0.642 0.279 0.434
SP15 SP15 18.20 0.005 0.133 0.119 0.893
SP15 Fresno Airport 30.70 0.005 0.077 0.063 0.813
ZP26 Fresno Airport 14.30 0.261 2.471 0.601 0.243
ZP26 ZP26 15.00 0.261 2.463 0.593 0.241

Section 6.4’s out-of-time test, re-run on the airport-triggered quanto under both quoting rules. The naive rule sees only the market quote years; the factorized rule takes the trigger frequency from the airport record back to 2018. Only the sample estimating \(P(Y=1)\) differs:

valley_daily = valley.group_by("point", "trade_day").agg(
    pl.col("conditioned_value").mean(), pl.col("condition_leg").mean()
)
tagged = valley_daily.join(PRICING_FOLDS.frame, how="cross").filter(
    pl.col("trade_day").is_between(pl.col("period_start"), pl.col("period_end"))
)
quote_rows = tagged.filter(pl.col("phase") == "quote")
history = (
    airport_cdd.join(airport_thresholds, how="cross")
    .with_columns(
        (pl.col("airport_cdd") >= pl.col("threshold"))
        .cast(pl.Float64)
        .alias("condition_leg")
    )
    .join(
        PRICING_FOLDS.frame.filter(pl.col("phase") == "quote").select(
            "fold", "period_end"
        ),
        how="cross",
    )
    .filter(pl.col("trade_day") <= pl.col("period_end"))
)
realized = (
    tagged.filter(pl.col("phase") == "settle")
    .group_by("point", "fold")
    .agg(pl.col("conditioned_value").mean().alias("realized"))
)
factorized_test = (
    pl.concat(
        [
            NaiveHistoricalMean()
            .quote(quote_rows, "conditioned_value", by=("point", "fold"))
            .collect()
            .select(
                "point",
                "fold",
                pl.lit("naive").alias("rule"),
                pl.col("quoted_premium").alias("quoted"),
                pl.lit(None, dtype=pl.Float64).alias("trigger_probability"),
            ),
            FactorizedQuote(
                condition_record=history.lazy(), condition_column="condition_leg"
            )
            .quote(quote_rows, "conditioned_value", by=("point", "fold"))
            .collect()
            .select(
                "point",
                "fold",
                pl.lit("factorized").alias("rule"),
                pl.col("quoted_premium").alias("quoted"),
                pl.col("condition_probability").alias("trigger_probability"),
            ),
        ]
    )
    .join(realized, on=["point", "fold"])
    .with_columns((pl.col("quoted") - pl.col("realized")).alias("seller_margin"))
    .rename({"point": "hub"})
    .sort("fold", "hub", "rule")
)

fig = figure(
    title="Airport-Triggered Quanto: Quotes Against Settlement",
    x_range=FactorRange(*fold_factors),
    y_axis_label="Premium / Realized Payoff ($/MWh)",
    height=400,
    sizing_mode="stretch_width",
    toolbar_location="above",
)
naive_rows = factorized_test.filter(pl.col("rule") == "naive")
factorized_rows = factorized_test.filter(pl.col("rule") == "factorized")
bar_specs = [
    ("naive quote", naive_rows, "quoted", -0.24, SERIES[0]),
    ("factorized quote", factorized_rows, "quoted", 0.0, SERIES[1]),
    ("realized payoff", naive_rows, "realized", 0.24, SERIES[2]),
]
for label, rows_frame, column, offset, color in bar_specs:
    source = ColumnDataSource(
        {
            "factor": list(zip(rows_frame["fold"].to_list(),
                               rows_frame["hub"].to_list())),
            "value": rows_frame[column].to_list(),
        }
    )
    fig.vbar(x=dodge("factor", offset, range=fig.x_range), top="value",
             width=0.2, color=color, legend_label=label, source=source)
fig.add_tools(HoverTool(tooltips=[("fold, hub", "@factor"),
                                  ("$/MWh", "@value{0.000}")]))
fig.xaxis.major_label_orientation = 0.9
fig.legend.location = "top_right"
show(style(fig))

html_table(
    factorized_test.with_columns(
        (pl.col("seller_margin") * annual_scale).round(1)
        .alias("seller margin ($k/yr, 50 MW)")
    ).select(
        "fold", "hub", "rule",
        pl.col("trigger_probability").round(4).alias("long-record trigger probability"),
        pl.col("quoted").round(3),
        pl.col("realized").round(3),
        pl.col("seller_margin").round(3),
        "seller margin ($k/yr, 50 MW)",
    ).sort("fold", "hub", "rule")
)
12 rows × 8 columns
fold hub rule long-record trigger probability quoted realized seller_margin seller margin ($k/yr, 50 MW)
quote year 2 from year 1 NP15 factorized 0.050 2.050 1.389 0.661 289.70
quote year 2 from year 1 NP15 naive — 1.571 1.389 0.181 79.40
quote year 2 from year 1 SP15 factorized 0.005 — 0.237 — —
quote year 2 from year 1 SP15 naive — 0.000 0.237 −0.237 −104.00
quote year 2 from year 1 ZP26 factorized 0.236 3.839 2.619 1.220 534.60
quote year 2 from year 1 ZP26 naive — 3.205 2.619 0.586 257.00
quote year 3 from years 1-2 NP15 factorized 0.053 1.428 0.227 1.202 526.70
quote year 3 from years 1-2 NP15 naive — 1.480 0.227 1.253 549.40
quote year 3 from years 1-2 SP15 factorized 0.006 0.091 0.000 0.091 39.70
quote year 3 from years 1-2 SP15 naive — 0.118 0.000 0.118 51.90
quote year 3 from years 1-2 ZP26 factorized 0.242 2.943 1.507 1.436 629.20
quote year 3 from years 1-2 ZP26 naive — 2.912 1.507 1.405 616.00

The Factorized-Quote Verdict#

The airport series tracks the weather build’s Fresno station at 0.983 correlation with a 1.09-CDD mean absolute difference over the 1,126 overlapping days. The airport trigger raises NP15’s in-sample covariance share to 0.65 (from 0.43 on its own zonal index), leaves ZP26 flat at 0.24 and costs SP15 0.89 down to 0.81, both SP15 figures resting on the same six days the condition is met. The trigger redesign does more work than the quoting rule: NP15’s first fold goes from undercharging by 1.8 times the premium under the zonal contract to overcharging by a third of it under the airport contract, an eighth under the naive rule.

The factorization fixes only the half it targets. The trigger probability is never again zero (SP15’s long-record estimate is 0.5% where the naive rule quoted 0.0), but payoff-given-trigger still needs days the condition is met in the market history: SP15’s first fold returns no quote at all, a refusal to price rather than a price of zero. Where the trigger is frequent the long record can mislead - ZP26’s 0.236 is above the 0.20 the quote year implies, lifting the fold-1 quote from 3.21 to 3.84. What remains is settlement variance: every fold-2 quote overshot under every rule, and the plain HRCO, with no weather leg, overshot just as hard in 6.4. A practical premium needs a risk load (pricing.evaluation.quoting.RiskLoadedPremium, not run here); the next lever is estimating payoff-given-trigger from drivers (the notebook 11 outage model, gas levels) rather than averaging the handful of days the condition was met.