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:

  1. Build the hourly HRCO payoff panel at the three CAISO trading hubs.

  2. Locate where that payoff value sits across daily CDD buckets.

  3. Price the quanto across heat trigger levels, splitting each price into independence value plus covariance.

  4. Test the split against a different trigger index, and across strikes.

  5. Put the covariance in contract-scale dollars.

  6. 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

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

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:

\[S_{h,d}(H) = P_{E,h} - H\,P_{G,d}, \qquad X_{h,d} = \max\{0,\ S_{h,d}(H)\}\]

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:

\[Y_d = \mathbf{1}\{C_d \geq K\}, \qquad (XY)_{h,d} = X_{h,d}\,Y_d\]

3.3 Variable Definitions#

Symbol

Meaning

Units

Code

\(P_{E,h}\)

hourly DAM LMP

$/MWh

power_price

\(P_{G,d}\)

daily citygate gas index

$/MMBtu

gas_price

\(H\)

strike heat rate

MMBtu/MWh

CCGT_HR, STRIKE_GRID

\(\text{CDD}_d\)

zonal daily degree days

degree-days

cooling_degree_days

\(K\)

trigger level

trigger units

CDD_TRIGGERS, OUTAGE_TRIGGER_QUANTILE

\(X_{h,d}\)

hourly HRCO payoff

$/MWh

price_leg

\(Y_d\)

trigger indicator

0/1

condition, condition_leg

\(X_{h,d} Y_d\)

conditioned payoff

$/MWh

settled_value, conditioned_value

\(E[XY]\), \(E[X]E[Y]\), \(\mathrm{Cov}\)

the decomposition

$/MWh

quanto_value, independence_value, covariance

\(\mathrm{Cov}/E[XY]\)

covariance share

fraction

covariance_share

\(E[X \mid Y=1]\)

conditional value per hour

$/MWh

conditional_value

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

caiso_lmp_day_ahead_hourly

hourly (hub APNode)

the power leg (backfilled to the OASIS retention floor, 2023-05-11)

caiso_fuel_prices

hourly (fuel region)

the citygate gas legs, averaged to daily indices (backfilled to 2018)

caiso_weather_daily_location

daily (station)

the load-weighted station blend behind each zone’s CDD65/HDD65, from Claire Kim’s collab_weather_pipeline

caiso_weather_daily_weather_zone

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), with STRIKE_GRID for the 6.4 sweep.

  • CDD_TRIGGERS - the heat-trigger sweep in daily zonal CDD65, with CDD_TRIGGER the 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",
)
reference · 3 rows × 4 columns
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 reaching CDD_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)
3 rows × 9 columns
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)
3 rows × 5 columns
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)
3 rows × 5 columns
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")
)
3 rows × 5 columns
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")
)
12 rows × 8 columns
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),
    )
)
2 rows × 7 columns
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.4 Is the Covariance Share a One-Strike Artifact?#

The same decomposition at \(K = 15\), swept across strike heat rates from an efficient CCGT to an inefficient peaker:

swept_strikes = {
    hub: market.heat_rate_call(
        hub, scenario("strike_heat_rate"), volume_mw=VOLUME_MW
    ).conditional_on(market.cooling_degree_days(hub).above(CDD_TRIGGER))
    for hub in HUBS
}
strike_sweep = pl.concat(
    [
        swept_strikes[hub]
        .with_engine(QuantoDecompositionEngine())
        .sweep(ScenarioGrid(strike_heat_rate=STRIKE_GRID))
        for hub in HUBS
    ]
)

fig = figure(
    title="Covariance Share of Quanto Value Across Strikes",
    x_axis_label="Strike Heat Rate (MMBtu/MWh)",
    y_axis_label="Covariance Share of Quanto Value",
    height=380,
    sizing_mode="stretch_width",
    toolbar_location="above",
)
renderers = []
for color, hub in zip(SERIES, HUBS):
    hub_rows = strike_sweep.filter(pl.col("point") == hub).sort("strike_heat_rate")
    renderers.append(
        fig.line(hub_rows["strike_heat_rate"].to_numpy(),
                 hub_rows["covariance_share"].to_numpy(),
                 color=color, line_width=2.2, legend_label=hub, name=hub)
    )
fig.add_layout(Span(location=0, dimension="width", line_color="#c3c2b7", line_width=1))
fig.add_tools(
    HoverTool(renderers=renderers, tooltips=[("hub", "$name"), ("strike", "@x{0.0}"),
              ("covariance share", "@y{0.000}")])
)
fig.legend.location = "top_left"
show(style(fig))

html_table(
    strike_sweep.select(
        pl.col("point").alias("hub"),
        pl.col("strike_heat_rate").alias("hr_strike"),
        pl.col("quanto_value").round(3),
        pl.col("covariance").round(3),
        pl.col("covariance_share").round(3),
    ).sort("hub", "hr_strike")
)
15 rows × 5 columns
hub hr_strike quanto_value covariance covariance_share
NP15 5.50 0.936 0.322 0.344
NP15 7.00 0.642 0.279 0.434
NP15 8.50 0.448 0.263 0.589
NP15 10.00 0.339 0.247 0.727
NP15 11.50 0.263 0.210 0.800
SP15 5.50 0.166 0.136 0.820
SP15 7.00 0.133 0.119 0.893
SP15 8.50 0.114 0.106 0.927
SP15 10.00 0.103 0.097 0.942
SP15 11.50 0.094 0.089 0.949
ZP26 5.50 3.872 0.699 0.180
ZP26 7.00 2.463 0.593 0.241
ZP26 8.50 1.480 0.560 0.378
ZP26 10.00 0.989 0.538 0.544
ZP26 11.50 0.727 0.472 0.649

Across the Strike Grid#

The covariance share rises monotonically with the strike at every hub (NP15 from 0.34 to 0.80, ZP26 from 0.18 to 0.65, SP15 from 0.82 to 0.95) and is never negative: deep out-of-the-money spark options in CAISO are weather derivatives already, written that way or not.

Two follow-ups: a Margrabe variant with spread volatility re-estimated on hot-day subsamples, and a real-time settled version, where the same conditioning sits on the spikier leg.

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"),
    )
)
3 rows × 6 columns
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")
)
3 rows × 2 columns
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")
)
9 rows × 7 columns
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.