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:
Split the decomposition by contract year - does the covariance replicate?
Put a bootstrap interval on the covariance.
Score a stylized exposed book unhedged, plain-HRCO-hedged and quanto-hedged on tail relief per premium dollar.
Quote each instrument from trailing history and settle it on the following contract year.
Sweep every trigger geography at matched trigger probability.
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\):
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]\):
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:
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:
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:
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\):
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 |
|
\(X_{h,d} Y_d\) |
conditioned payoff |
$/MWh |
|
\(E[XY]\), \(E[X]E[Y]\) |
quanto value, independence value |
$/MWh |
|
\(\mathrm{Cov}(X,Y)\), share |
the covariance and its share of quanto value |
$/MWh, ratio |
|
\(C_d\) |
exposed book’s daily cost |
$/MW-day |
|
\(H_d\), \(\pi\) |
daily hedge payoff, fair daily premium |
$/MW-day |
|
\(\mathrm{ES}_{0.9}\) |
expected shortfall, worst tenth of days |
$/MW-day |
|
efficiency |
tail relief per premium dollar |
ratio |
|
\(\pi_Q\), margin |
out-of-time quote and seller margin |
$/MWh |
|
\(L\), replicates |
bootstrap block length and count |
days, count |
|
\(W_d\), \(K_W\) |
candidate trigger index, matched trigger level |
CDD65 |
|
\(p_z\) |
target trigger probability for hub \(z\) |
share of days |
|
\(\hat{P}_{\text{long}}\) |
trigger probability from the full airport record |
share of days |
|
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 |
|---|---|---|
|
hourly (trading hub APNode) |
the power leg and the exposed book’s cost |
|
hourly (fuel region) |
the citygate gas legs, averaged to daily indices |
|
daily (station) |
the seven station CDD indices, and the load-weighted blend behind each market zone |
|
daily (CAISO weather zone) |
the four drainage-basin CDD indices, candidate triggers |
|
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",
)
| 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"}))
| 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)
| 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")
)
| 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")
)
| 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")
)
| 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")
)
| 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",
)
| 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),
)
)
| 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"),
)
)
| 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"))
)
| 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),
)
)
| 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")
)
| 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.