13. Quanto Pricing - Weather-Conditioned Spark Spreads#
Authors: Brian Nguyen · Claire Kim ·
Created: 2026-08-22 ·
Tags: #Pricing #Quanto #Spark-Spread #Weather
2. Objective & Input Framework#
Measure how much of a weather-conditioned spark option’s value comes from the dependence between its two legs:
Build the hourly HRCO payoff panel at the three CAISO trading hubs.
Locate where that payoff value sits across daily CDD buckets.
Price the quanto across heat trigger levels, splitting each price into independence value plus covariance.
Test the split against a different trigger index, and across strikes.
Put the covariance in contract-scale dollars.
Extension: repeat the conditioning with outage intensity.
The market series are reached through markets.MarketContext, which owns
the hub-to-gas pairing, the US/Pacific trade-day clock and the degree-day
base; the 6.6 extension’s outage inputs are handed in from outside it.
Setup lists both.
3. Mathematical Model & Analytical Formulation#
The whole notebook hangs on one identity. With \(X\) an option payoff and \(Y\) a 0/1 trigger, the contract that pays \(X\) only when \(Y = 1\) - a quanto - is worth
The first term prices the legs as independent; the excess is the covariance term. The covariance share \(\mathrm{Cov}(X, Y) / E[XY]\) is the fraction of the value that independence cannot explain, and \(E[X \mid Y = 1] = E[XY] / E[Y]\) is the payoff per hour on days the condition is met.
3.1 The Payoff X#
The hourly spark spread at strike heat rate \(H\), and the heat rate call option (HRCO) payoff applied at the settlement frequency of the data (hourly), per MWh at zero VOM:
The gas leg is the day’s single citygate index carried across the day’s hours - so all intraday shape in \(X\) is the power leg - paired to each trading hub by the market’s own \(g^{*}(z)\) mapping.
3.2 The Trigger Y#
The trigger is a daily binary carried onto each of the day’s hours - heat here, and outage intensity in the 6.6 extension:
3.3 Variable Definitions#
Symbol |
Meaning |
Units |
Code |
|---|---|---|---|
\(P_{E,h}\) |
hourly DAM LMP |
$/MWh |
|
\(P_{G,d}\) |
daily citygate gas index |
$/MMBtu |
|
\(H\) |
strike heat rate |
MMBtu/MWh |
|
\(\text{CDD}_d\) |
zonal daily degree days |
degree-days |
|
\(K\) |
trigger level |
trigger units |
|
\(X_{h,d}\) |
hourly HRCO payoff |
$/MWh |
|
\(Y_d\) |
trigger indicator |
0/1 |
|
\(X_{h,d} Y_d\) |
conditioned payoff |
$/MWh |
|
\(E[XY]\), \(E[X]E[Y]\), \(\mathrm{Cov}\) |
the decomposition |
$/MWh |
|
\(\mathrm{Cov}/E[XY]\) |
covariance share |
fraction |
|
\(E[X \mid Y=1]\) |
conditional value per hour |
$/MWh |
|
Setup: Inputs and Source Data#
Open the market, declare the terms, and build the settlement book. Timestamps on disk are naive UTC; trade days are US/Pacific.
Dataset |
Granularity |
Role |
|---|---|---|
|
hourly (hub APNode) |
the power leg (backfilled to the OASIS retention floor, 2023-05-11) |
|
hourly (fuel region) |
the citygate gas legs, averaged to daily indices (backfilled to 2018) |
|
daily (station) |
the load-weighted station blend behind each zone’s CDD65/HDD65, from Claire Kim’s |
|
daily (drainage basin) |
the Sacramento Drainage proxy index of the 6.3 trigger check |
CNOG (curtailed non-operational generator) report + processed weather + crosswalk |
county-day |
inputs to the county outage panel behind the 6.6 extension |
from datetime import date
import polars as pl
from bokeh.io import output_notebook, show
from bokeh.models import ColumnDataSource, HoverTool, Span
from bokeh.plotting import figure
from bokeh.resources import INLINE
from bokeh.transform import dodge
from caiso_outage_nb_pipeline import (
COUNTY_DAILY_FEATURES,
build_caiso_county_nb_panel,
rollup_county_predictions_to_market_zone,
)
from caiso_outage_specification_search import fit_nb1_model
from chart_theme import SERIES, html_table, style
from markets import MarketContext
from markets.adapters.caiso.weather import CaisoWeatherSource
from pricing import (
MatchedProbabilityTrigger,
QuantoDecompositionEngine,
ScenarioGrid,
scenario,
)
from pricing.core.schedule import SettlementSchedule
from pricing.evaluation.profile import payoff_profile
from pricing.evaluation.statistics import DecompositionStatistic, measure
from settings import config
DATA_DIR = config("DATA_DIR")
output_notebook(INLINE, hide_banner=True)
Declare: Market, Date Range, Strikes, and Triggers#
DATE_RANGE- Pacific trade days from 2023-06-01, the first clean month after the OASIS LMP retention floor of 2023-05-11, through 2026-06-30, where the zonal weather build ends.HUBS- the three full-history CAISO trading hubs.CCGT_HR- the primary strike heat rate (an efficient CCGT), withSTRIKE_GRIDfor the 6.4 sweep.CDD_TRIGGERS- the heat-trigger sweep in daily zonal CDD65, withCDD_TRIGGERthe reference contract’s level.VOLUME_MW- the reference contract size for the 6.5 dollars.
DATE_RANGE = (date(2023, 6, 1), date(2026, 6, 30))
HUBS = ("NP15", "SP15", "ZP26")
CCGT_HR = 7.0
STRIKE_GRID = [5.5, 7.0, 8.5, 10.0, 11.5]
CDD_TRIGGERS = [5.0, 10.0, 15.0, 20.0]
CDD_TRIGGER = 15.0
VOLUME_MW = 50.0
market = MarketContext.for_market("CAISO", span=DATE_RANGE)
html_table(
pl.DataFrame(
{
"trading hub": HUBS,
"gas leg": [market.definition.pair(hub).gas_hub.label for hub in HUBS],
"fuel region series": [
market.definition.pair(hub).gas_hub.identifier for hub in HUBS
],
"weather region": [
", ".join(market.definition.pair(hub).weather_labels)
for hub in HUBS
],
}
),
kind="reference",
)
| trading hub | gas leg | fuel region series | weather region |
|---|---|---|---|
| NP15 | PG&E Citygate | FRPGE1 | NP15 |
| SP15 | SoCal Citygate | FRSCE1 | SP15 |
| ZP26 | PG&E Citygate | FRPGE1 | ZP26 |
Source: The Priced Instruments#
calls- the plain hourly HRCO at each trading hub, whose power leg, gas leg and clock the market resolves from its own registry.quantos- the same option conditioned on that hub’s daily CDD65 reachingCDD_TRIGGER.book- the per-interval settlement book of all three, stacked: \(X\), \(Y\), \(XY\), and the market series later sections read.
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
}
def settlement_book(hub: str) -> pl.LazyFrame:
"""The per-interval book of one hub's quanto, with its market data series named.
Parameters
----------
hub : str
Trading hub label.
Returns
-------
pl.LazyFrame
One row per settled hour carrying ``price_leg``, ``condition``,
``settled_value`` and the three series under names shared across
hubs, so the three books stack into one long frame.
"""
priced = quantos[hub]
market_data = priced.market_data
gas_leg = market.definition.pair(hub).gas_hub.label
return (
priced.instrument.compile(market_data)
.book()
.select(
"point",
"trade_day",
"hour_of_day",
"condition",
"price_leg",
"settled_value",
market.cooling_degree_days(hub)
.expression(market_data.schema, market_data.clock)
.alias("cooling_degree_days"),
market.power(hub)
.expression(market_data.schema, market_data.clock)
.alias("power_price"),
market.gas(gas_leg)
.expression(market_data.schema, market_data.clock)
.alias("gas_price"),
)
)
book = pl.concat([settlement_book(hub) for hub in HUBS]).collect()
4. Quantitative Data Exploration & Assumption Testing#
4.1 Panel Coverage#
What the market assembled per hub, against the clock’s interval grid:
market_data_audits = {
hub: market.audit(
market.spark_spread(hub, CCGT_HR), market.cooling_degree_days(hub)
)
for hub in HUBS
}
join_audit = pl.DataFrame(
{
"hub": HUBS,
"hours": [market_data_audits[hub].rows for hub in HUBS],
"hours on the clock grid": [
market_data_audits[hub].expected_rows for hub in HUBS
],
"trade days": [market_data_audits[hub].trade_days for hub in HUBS],
"duplicate natural keys": [
market_data_audits[hub].duplicate_natural_keys for hub in HUBS
],
"missing intervals": [market_data_audits[hub].missing_intervals for hub in HUBS],
"interval count anomalies": [
market_data_audits[hub].interval_count_anomalies for hub in HUBS
],
"unjoined series prints": [
sum(count for _, count in market_data_audits[hub].null_counts) for hub in HUBS
],
"non-positive gas prints": [
sum(count for _, count in market_data_audits[hub].non_positive_counts)
for hub in HUBS
],
}
)
html_table(join_audit)
| hub | hours | hours on the clock grid | trade days | duplicate natural keys | missing intervals | interval count anomalies | unjoined series prints | non-positive gas prints |
|---|---|---|---|---|---|---|---|---|
| NP15 | 27,024 | 27,024 | 1,126 | 0 | 0 | 0 | 1 | 0 |
| SP15 | 27,024 | 27,024 | 1,126 | 0 | 0 | 0 | 1 | 0 |
| ZP26 | 27,024 | 27,024 | 1,126 | 0 | 0 | 0 | 1 | 0 |
Every trading hub carries its full 27,024 hours with no duplicate key, no missing interval and no impossible day length; the one unjoined print per hub is the day-ahead hour the source never published on 2025-11-14. It stays null, so a probability over \(Y\) counts that hour while an expectation over \(X\) skips it, and each hub’s trigger probability sits on a denominator one interval larger than the payoff’s.
4.2 The Trigger and the Spread, Before Any Pricing#
How often each heat condition is met, and how profitable the spread is:
swept_triggers = {
hub: market.heat_rate_call(hub, CCGT_HR, volume_mw=VOLUME_MW).conditional_on(
market.cooling_degree_days(hub).above(scenario("cdd_trigger"))
)
for hub in HUBS
}
TRIGGER_GRID = ScenarioGrid(cdd_trigger=CDD_TRIGGERS)
trigger_decompositions = pl.concat(
[
swept_triggers[hub]
.with_engine(QuantoDecompositionEngine())
.sweep(TRIGGER_GRID)
for hub in HUBS
]
)
condition_audit = (
trigger_decompositions.select(
"point",
pl.format("pct days CDD >= {}", pl.col("cdd_trigger").cast(pl.Int64)).alias(
"trigger"
),
(100 * pl.col("condition_probability")).round(1).alias("share"),
)
.pivot(on="trigger", index="point", values="share")
.rename({"point": "hub"})
.sort("hub")
)
html_table(condition_audit)
| hub | pct days CDD >= 5 | pct days CDD >= 10 | pct days CDD >= 15 | pct days CDD >= 20 |
|---|---|---|---|---|
| NP15 | 28.10 | 10.40 | 4.30 | 0.40 |
| SP15 | 22.90 | 5.60 | 0.50 | 0.30 |
| ZP26 | 46.00 | 36.10 | 26.10 | 14.60 |
spread_audit = (
book.group_by("point")
.agg(
pl.len().alias("hours"),
(pl.col("power_price") - CCGT_HR * pl.col("gas_price"))
.mean()
.round(2)
.alias("mean spread ($/MWh)"),
(
100
* ((pl.col("power_price") - CCGT_HR * pl.col("gas_price")) > 0).mean()
)
.round(1)
.alias("pct hours ITM"),
pl.col("price_leg").mean().round(2).alias("HRCO value ($/MWh)"),
)
.rename({"point": "hub"})
.sort("hub")
)
html_table(spread_audit)
| hub | hours | mean spread ($/MWh) | pct hours ITM | HRCO value ($/MWh) |
|---|---|---|---|---|
| NP15 | 27,024 | 4.68 | 67.80 | 8.530 |
| SP15 | 27,024 | −18.24 | 14.60 | 2.690 |
| ZP26 | 27,024 | −1.64 | 59.40 | 7.160 |
6. Quantitative Analysis & Validation#
6.1 Where the Option Value Lives#
Air conditioning is the demand channel: hot days raise load and deepen the evening ramp, so gas units set the price for more hours and at higher implied heat rates. Mean hourly HRCO payoff by daily CDD bucket:
CDD_BIN_EDGES = (0.0, 5.0, 10.0, 15.0)
value_profile = payoff_profile(
book,
"cooling_degree_days",
edges=CDD_BIN_EDGES,
value_column="price_leg",
by=("point",),
).frame.with_columns(
pl.when(pl.col("bucket_end").is_infinite())
.then(pl.format("{} and above", pl.col("bucket_start").cast(pl.Int64)))
.otherwise(
pl.format(
"[{}, {})",
pl.col("bucket_start").cast(pl.Int64),
pl.col("bucket_end").cast(pl.Int64, strict=False),
)
)
.alias("cdd_bin")
)
CDD_BIN_LABELS = (
value_profile.filter(pl.col("point") == HUBS[0]).sort("bucket")["cdd_bin"].to_list()
)
fig = figure(
title="HRCO Value by Daily CDD Bucket",
x_range=CDD_BIN_LABELS,
x_axis_label="Daily Zonal CDD65 Bin",
y_axis_label="Mean Hourly HRCO Value ($/MWh)",
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_bins = value_profile.filter(pl.col("point") == hub).sort("bucket")
source = ColumnDataSource(
{
"bin": hub_bins["cdd_bin"].to_list(),
"value": hub_bins["value"].to_list(),
"hours": hub_bins["settled_periods"].to_list(),
}
)
renderers.append(
fig.vbar(x=dodge("bin", offset, range=fig.x_range), top="value", width=0.22,
color=color, legend_label=hub, name=hub, source=source)
)
fig.add_tools(
HoverTool(renderers=renderers, tooltips=[("hub", "$name"), ("bin", "@bin"),
("value", "@value{0.00} $/MWh"), ("hours", "@hours{0,0}")])
)
fig.legend.location = "top_left"
show(style(fig))
html_table(
value_profile.pivot(on="cdd_bin", index="point", values="value")
.with_columns(pl.col(pl.Float64).round(2))
.rename({"point": "hub"})
.sort("hub")
)
| hub | [0, 5) | [5, 10) | [10, 15) | 15 and above |
|---|---|---|---|---|
| NP15 | 7.600 | 7.520 | 17.82 | 15.06 |
| SP15 | 1.280 | 5.010 | 13.89 | 25.05 |
| ZP26 | 6.970 | 4.650 | 4.77 | 9.43 |
What the CDD Buckets Say#
At SP15 the heat gradient is the value gradient: mean hourly payoff climbs across every bucket, 1.28 with no cooling demand to 25.05 $/MWh at 15 CDD and above. NP15 peaks one bucket earlier, 17.82 in [10, 15), and eases to 15.06 above it. ZP26 is the U-shaped one: 6.97 at zero CDD, a 4.65 trough in mild weather, 9.43 at the top. The zero-CDD bucket is where the winter days sit, so a hub paying there is being paid for cold the CDD axis cannot separate. ZP26 also reaches 15 CDD on 26% of its days, so heat is in its everyday spread rather than in a tail.
6.2 Pricing the Quanto Across Heat Triggers#
Solid lines are the quanto value \(E[XY]\), dashed the independence value \(E[X]E[Y]\) in the matching hub color; the gap is the covariance.
fig = figure(
title="Quanto Value vs Independence Value Across Heat Triggers",
x_axis_label="CDD65 Trigger",
y_axis_label="Expected Payoff ($/MWh)",
height=380,
sizing_mode="stretch_width",
toolbar_location="above",
)
renderers = []
for color, hub in zip(SERIES, HUBS):
hub_rows = trigger_decompositions.filter(
pl.col("point") == hub
).sort("cdd_trigger")
renderers.append(
fig.line(hub_rows["cdd_trigger"].to_numpy(), hub_rows["quanto_value"].to_numpy(),
color=color, line_width=2.2, legend_label=hub, name=hub)
)
fig.line(hub_rows["cdd_trigger"].to_numpy(),
hub_rows["independence_value"].to_numpy(),
color=color, line_width=1.6, line_dash="dashed")
fig.add_tools(
HoverTool(renderers=renderers, tooltips=[("hub", "$name"), ("trigger", "@x{0}"),
("quanto value", "@y{0.000} $/MWh")])
)
fig.legend.location = "top_right"
show(style(fig))
html_table(
trigger_decompositions.select(
pl.col("point").alias("hub"),
"cdd_trigger",
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),
pl.col("conditional_value").round(2),
).sort("hub", "cdd_trigger")
)
| hub | cdd_trigger | condition_probability | quanto_value | independence_value | covariance | covariance_share | conditional_value |
|---|---|---|---|---|---|---|---|
| NP15 | 5.00 | 0.281 | 3.062 | 2.393 | 0.669 | 0.219 | 10.91 |
| NP15 | 10.00 | 0.104 | 1.734 | 0.886 | 0.848 | 0.489 | 16.69 |
| NP15 | 15.00 | 0.043 | 0.642 | 0.363 | 0.279 | 0.434 | 15.06 |
| NP15 | 20.00 | 0.004 | 0.104 | 0.038 | 0.066 | 0.637 | 23.48 |
| SP15 | 5.00 | 0.229 | 1.705 | 0.617 | 1.088 | 0.638 | 7.44 |
| SP15 | 10.00 | 0.056 | 0.837 | 0.151 | 0.686 | 0.820 | 14.95 |
| SP15 | 15.00 | 0.005 | 0.133 | 0.014 | 0.119 | 0.893 | 25.05 |
| SP15 | 20.00 | 0.003 | 0.074 | 0.007 | 0.067 | 0.903 | 27.84 |
| ZP26 | 5.00 | 0.460 | 3.400 | 3.294 | 0.106 | 0.031 | 7.39 |
| ZP26 | 10.00 | 0.361 | 2.942 | 2.588 | 0.353 | 0.120 | 8.14 |
| ZP26 | 15.00 | 0.261 | 2.463 | 1.870 | 0.593 | 0.241 | 9.43 |
| ZP26 | 20.00 | 0.146 | 1.808 | 1.043 | 0.765 | 0.423 | 12.41 |
Reading the Decomposition#
The scarcer the heat, the more of the contract is coincidence value: at the 15-CDD trigger the covariance share is 0.89 at SP15 (whose index reaches 15 on 0.5% of days), 0.43 at NP15 and 0.24 at ZP26. The share rises monotonically with \(K\) at SP15 and ZP26. NP15 is the exception, dipping from 0.49 at 10 CDD to 0.43 at 15 before reaching 0.64 at 20, a level met on four days in a thousand.
The sign belongs to the settlement frequency and the price series: hour by hour against real-time prices, cooling degree hours and SP15 prices co-move negatively (the duck curve), while conditioning on the day’s heat and settling the day-ahead strip makes it strongly positive at every hub. A term sheet has to pin both.
6.3 Does the Trigger Index Matter?#
NP15’s zonal index blends hot Sacramento against the cool coast, diluting the trigger. Re-price the NP15 quanto on the Sacramento Drainage proxy at the level met with the same probability as the blended index’s 15-CDD trigger:
PROXY_INDEX = "Sacramento Drainage"
candidate_indices = {
index.temperature.regions[0]: index
for index in CaisoWeatherSource().candidate_degree_day_indices()
}
blended_probability = (
trigger_decompositions.filter(
(pl.col("point") == "NP15") & (pl.col("cdd_trigger") == CDD_TRIGGER)
)["condition_probability"].item()
)
proxy_quanto = calls["NP15"].conditional_on(
MatchedProbabilityTrigger(
candidate_indices[PROXY_INDEX], blended_probability, market.span
)
)
index_check = pl.concat(
[
quantos["NP15"]
.decomposition()
.frame.with_columns(
pl.lit("blended NP15 index").alias("trigger index"),
pl.lit(CDD_TRIGGER).alias("trigger"),
),
proxy_quanto.decomposition().frame.with_columns(
pl.lit(f"{PROXY_INDEX} proxy").alias("trigger index"),
pl.lit(
float(proxy_quanto.instrument.trigger.calibrated_threshold)
).alias("trigger"),
),
]
)
html_table(
index_check.select(
"trigger index",
pl.col("trigger").round(2).alias("trigger (CDD65)"),
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),
)
)
| trigger index | trigger (CDD65) | condition_probability | quanto_value | independence_value | covariance | covariance_share |
|---|---|---|---|---|---|---|
| blended NP15 index | 15.00 | 0.043 | 0.642 | 0.363 | 0.279 | 0.434 |
| Sacramento Drainage proxy | 19.51 | 0.043 | 0.978 | 0.363 | 0.615 | 0.629 |
At matched probability, the inland proxy lifts NP15’s covariance share from 0.43 to 0.63, so two fifths of NP15’s gap to SP15 was the index rather than the economics, and a zonal blend is a conservative floor on the coincidence value. ZP26 has no such correction - its zonal index already is the San Joaquin proxy - so its lower share is genuine.
6.5 The Covariance in Dollars: Contract-Scale Economics#
The reference contract is 50 MW settled hourly around the clock for a year, annualized on the schedule’s own count of hourly intervals in a Pacific year (8,765.82, not 8,760, because of daylight saving):
HOURS_PER_YEAR = SettlementSchedule.hourly().settled_intervals_per_year(
market.definition.clock
)
annual_scale = VOLUME_MW * HOURS_PER_YEAR / 1000.0
contract_economics = (
trigger_decompositions.filter(pl.col("cdd_trigger") == CDD_TRIGGER)
.with_columns(
(pl.col("quanto_value") * annual_scale).alias("fair_premium"),
(pl.col("independence_value") * annual_scale).alias("independence_premium"),
(pl.col("covariance") * annual_scale).alias("covariance"),
(pl.col("price_leg_value") * annual_scale).alias("plain_hrco_premium"),
)
.with_columns(
(100 * pl.col("quanto_value") / pl.col("price_leg_value"))
.alias("quanto_cost_pct")
)
.sort("point")
)
fig = figure(
title="Fair vs Independence Premiums, 50 MW Annual Contract",
x_range=list(HUBS),
y_axis_label="Annual Premium ($ thousands)",
height=380,
sizing_mode="stretch_width",
toolbar_location="above",
)
premium_source = ColumnDataSource(
{
"hub": contract_economics["point"].to_list(),
"fair": contract_economics["fair_premium"].to_list(),
"independence": contract_economics["independence_premium"].to_list(),
"covariance": contract_economics["covariance"].to_list(),
}
)
fig.vbar(x=dodge("hub", -0.17, range=fig.x_range), top="fair", width=0.3,
color=SERIES[0], legend_label="fair quanto premium",
name="fair", source=premium_source)
fig.vbar(x=dodge("hub", 0.17, range=fig.x_range), top="independence", width=0.3,
color=SERIES[1], legend_label="independence-priced premium",
name="independence", source=premium_source)
fig.add_tools(
HoverTool(tooltips=[("hub", "@hub"), ("fair ($k/yr)", "@fair{0,0.0}"),
("independence ($k/yr)", "@independence{0,0.0}"),
("covariance ($k/yr)", "@covariance{0,0.0}")])
)
fig.legend.location = "top_left"
show(style(fig))
html_table(
contract_economics.select(
pl.col("point").alias("hub"),
pl.col("fair_premium").round(1).alias("fair quanto premium ($k/yr)"),
pl.col("independence_premium").round(1)
.alias("independence premium ($k/yr)"),
pl.col("covariance").round(1).alias("covariance ($k/yr)"),
pl.col("plain_hrco_premium").round(0).alias("plain HRCO premium ($k/yr)"),
pl.col("quanto_cost_pct").round(1).alias("quanto cost, pct of plain HRCO"),
)
)
| hub | fair quanto premium ($k/yr) | independence premium ($k/yr) | covariance ($k/yr) | plain HRCO premium ($k/yr) | quanto cost, pct of plain HRCO |
|---|---|---|---|---|---|
| NP15 | 281.40 | 159.30 | 122.10 | 3,737.00 | 7.50 |
| SP15 | 58.50 | 6.30 | 52.20 | 1,180.00 | 5.00 |
| ZP26 | 1,079.50 | 819.50 | 260.00 | 3,139.00 | 34.40 |
What the Dollars Say#
Share and dollars rank the hubs in opposite orders. ZP26 has the smallest covariance share (0.24) and the largest covariance in money, about 260 thousand dollars a year on the 50 MW reference against 122 at NP15 and 52 at SP15. The conditioned contract costs about 5% of the plain HRCO premium at SP15 and 7.5% at NP15, but 34% at ZP26, where hot days carry that much of the total value. These are historical expected payoffs over three years: no risk premium, no margin or credit costs, and the covariance is realized only against a counterparty pricing the two legs as independent.
6.6 Extension: Conditioning on Outage Intensity#
Does a second conditioning variable - the county outage model’s fitted NB1 intensity, rolled up to zone-day and handed in as a raw frame because the registry provides outage capacity only system-wide - produce a covariance of its own, and does stacking it on heat add anything? The intensity is fitted in sample, so it conditions descriptively rather than forecasting, and the county panel zero-fills its calendar, so an unpublished date reads as a calm day - hence the covered-day count below. The build takes a few minutes.
county_panel, _, _, county_zone_weights = build_caiso_county_nb_panel(DATA_DIR)
county_fit_nb1 = fit_nb1_model(county_panel, list(COUNTY_DAILY_FEATURES))
county_rollup = rollup_county_predictions_to_market_zone(
county_fit_nb1.fitted_df, county_zone_weights
)
outage_intensity = (
pl.from_pandas(county_rollup[["date", "region", "capacity_weighted_county_mu"]])
.with_columns(pl.col("date").cast(pl.Date))
.rename(
{
"date": "trade_day",
"region": "point",
"capacity_weighted_county_mu": "outage_intensity",
}
)
.filter(pl.col("trade_day").is_between(*DATE_RANGE))
)
html_table(
outage_intensity.group_by("point")
.agg(pl.col("trade_day").n_unique().alias("days with outage intensity"))
.rename({"point": "hub"})
.sort("hub")
)
| hub | days with outage intensity |
|---|---|
| NP15 | 1,095 |
| SP15 | 1,095 |
| ZP26 | 1,095 |
Inputs: the settlement book and the fitted outage intensity. Operation: left-join the intensity onto the book by trading hub and trade day, mark each hub’s top fifth of intensity as an outage day, and reshape the three triggers - heat, outage, and both - into one long frame with a single trigger column. Validation: rows the outage layer does not cover carry a null and are dropped from the outage rows alone, so the heat rows keep the full panel priced above.
OUTAGE_TRIGGER_QUANTILE = 0.80
CONDITION_LABELS = ["hot day", "outage day", "hot AND outage day"]
conditioned = (
book.join(outage_intensity, on=["point", "trade_day"], how="left")
.with_columns(
(
pl.col("outage_intensity")
>= pl.col("outage_intensity")
.quantile(OUTAGE_TRIGGER_QUANTILE)
.over("point")
)
.cast(pl.Float64)
.alias("outage day")
)
.with_columns(
pl.col("condition").alias("hot day"),
(pl.col("condition") * pl.col("outage day")).alias("hot AND outage day"),
)
.unpivot(
index=["point", "price_leg"],
on=CONDITION_LABELS,
variable_name="conditioning",
value_name="condition_leg",
)
.filter(pl.col("condition_leg").is_not_null())
.with_columns(
(pl.col("price_leg") * pl.col("condition_leg")).alias("conditioned_value")
)
)
The same decomposition under three triggers - a hot day (CDD65 at or above 15), an outage day (the hub’s top fifth of fitted intensity), and both at once, read as value per hour on days the condition is met:
condition_comparison = measure(
DecompositionStatistic(
value_column="conditioned_value",
price_leg_column="price_leg",
condition_column="condition_leg",
),
conditioned.lazy(),
by=("conditioning", "point"),
).collect()
fig = figure(
title="Conditional Value per Hour by Conditioning",
x_range=CONDITION_LABELS,
y_axis_label="Conditional HRCO Value ($/MWh)",
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 = condition_comparison.filter(pl.col("point") == hub)
source = ColumnDataSource(
{
"conditioning": hub_rows["conditioning"].to_list(),
"value": hub_rows["conditional_value"].to_list(),
"probability": hub_rows["condition_probability"].to_list(),
}
)
renderers.append(
fig.vbar(x=dodge("conditioning", offset, range=fig.x_range), top="value",
width=0.22, color=color, legend_label=hub, name=hub, source=source)
)
fig.add_tools(
HoverTool(renderers=renderers, tooltips=[("hub", "$name"),
("conditioning", "@conditioning"),
("conditional value", "@value{0.00} $/MWh"),
("trigger probability", "@probability{0.000}")])
)
fig.legend.location = "top_left"
show(style(fig))
html_table(
condition_comparison.select(
"conditioning",
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("conditional_value").round(2).alias("conditional value ($/MWh)"),
).sort("conditioning", "hub")
)
| conditioning | hub | condition_probability | quanto_value | independence_value | covariance | conditional value ($/MWh) |
|---|---|---|---|---|---|---|
| hot AND outage day | NP15 | 0.021 | 0.370 | 0.181 | 0.188 | 17.61 |
| hot AND outage day | SP15 | 0.005 | 0.137 | 0.015 | 0.122 | 25.05 |
| hot AND outage day | ZP26 | 0.080 | 1.139 | 0.582 | 0.557 | 14.17 |
| hot day | NP15 | 0.043 | 0.642 | 0.363 | 0.279 | 15.06 |
| hot day | SP15 | 0.005 | 0.133 | 0.014 | 0.119 | 25.05 |
| hot day | ZP26 | 0.261 | 2.463 | 1.870 | 0.593 | 9.43 |
| outage day | NP15 | 0.201 | 2.175 | 1.735 | 0.440 | 10.83 |
| outage day | SP15 | 0.201 | 1.504 | 0.554 | 0.949 | 7.48 |
| outage day | ZP26 | 0.201 | 2.105 | 1.455 | 0.650 | 10.48 |
Heat vs Outage Conditioning#
Outage-day conditioning carries pricing information at every hub - at SP15 the outage-day quanto is worth 1.50 against an independence value of 0.55 - but heat is sharper in the south: SP15’s hot hours are worth 25.05 against 7.48 on outage days. Stacking the two changes nothing at SP15, whose handful of hot days are all outage days already, and lifts the conditional value per hour from 15.06 to 17.61 at NP15. At ZP26, where one day in four is hot, requiring both lifts it from 9.43 to 14.17.
A live version needs the county model’s day-ahead forecast in place of the in-sample intensity, and the county rollup is handed in from outside the market facade until an adapter source publishes it per delivery point.