Curves
This page covers the pricing concepts that reconstruct discounting and
projection curves: index conventions (IndexConventionDetailsTable) for
QuantLib index/fixing mechanics, curve rows (CurveTable), curve build details
(CurveBuildingDetailsTable), market-data-set curve bindings, and timestamped
curve observations (DiscountCurvesNode).
Index Conventions
Instruments reference canonical indexes by backend UUID fields such as
floating_rate_index_uid, benchmark_rate_index_uid, or float_leg_index_uid.
They do not store string names.
IndexTable is core market reference data. For fixed income, first register
the interest_rate type through IndexType, then create the index row:
from msm.api.indices import Index, IndexType
from msm.constants import (
INDEX_TYPE_INTEREST_RATE,
INDEX_TYPE_INTEREST_RATE_DEFINITION,
)
IndexType.upsert(**INDEX_TYPE_INTEREST_RATE_DEFINITION.as_payload())
index = Index.upsert(
unique_identifier="USD-SOFR-3M",
index_type=INDEX_TYPE_INTEREST_RATE,
display_name="USD SOFR 3M",
provider="example",
)
Pricing-specific index mechanics live in IndexConventionDetailsTable:
+-----------------------------+ one-to-one extension +-------------------------------+
| IndexTable |-------------------------------->| IndexConventionDetails |
|-----------------------------| index_uid PK/FK |-------------------------------|
| uid PK | | index_uid PK/FK |
| unique_identifier unique | | index_family |
| index_type | | convention_dump |
| display_name | | serialization_format |
| provider | | source |
| metadata | | metadata |
+-----------------------------+ +-------------------------------+
IndexConventionDetailsTable stores index reconstruction mechanics:
- index family, such as
ibororovernight; - currency code;
- tenor or period;
- fixing calendar;
- day counter;
- settlement days;
- business-day convention;
- end-of-month behavior;
- optional fixing identity override.
It does not store curve selection and it does not belong in core msm.models.
from msm_pricing.api import IndexConventionDetails
IndexConventionDetails.upsert(
index_uid=index.uid,
index_family="ibor",
convention_dump={
"currency_code": "USD",
"day_counter_code": "Actual360",
"fixing_calendar_code": "US",
"period": "3M",
"settlement_days": 2,
"business_day_convention": "ModifiedFollowing",
"end_of_month": False,
"fixings_unique_identifier": index.unique_identifier,
},
source="example",
)
Curves
Curves are pricing concepts. They are not assets and do not reference
AssetTable.
CurveTable is a pricing-owned MetaTable with its own uid and
unique_identifier. It is the curve registry. It does not need to belong to an
index and it has no index ownership field. Runtime selection uses explicit
market-data-set curve bindings.
+-----------------------------+ build spec keyed by +-----------------------------+
| CurveTable |<--------------------------------| CurveBuildingDetailsTable |
|-----------------------------| curve_uid |-----------------------------|
| uid PK | | curve_uid PK/FK |
| unique_identifier unique | | builder_type |
| display_name | | quote_convention |
| curve_type | | day_counter_code |
| currency_code | | calendar_code |
| quote_side | | interpolation_method |
| source | | compounding |
| status | | extrapolation_policy |
| metadata | | metadata |
+-----------------------------+ +-----------------------------+
Curve identity rules:
CurveTable.uidis the canonical row identity for MetaTable operations.CurveTable.unique_identifieris the stable curve row key. Curve DataNode storage publishes that value in thecurve_identifiercolumn.- Bulk latest-as-of reads for multiple curve identifiers are executed by the
backend against
DiscountCurvesStoragewith one ranked row percurve_identifier; callers should not materialize full historical ranges just to find the latest row. CurveBuildingDetailsTable.curve_uidstores how observations are turned into a QuantLib term structure.PricingMarketDataSetCurveBindingTablestores valuation-policy selection:market_data_set_uid + role_key + selector_type + selector_key + quote_sideresolves tocurve_uid.curve_uidis not unique inPricingMarketDataSetCurveBindingTable. A curve can be selected by many market-data-set roles, selector types, index UIDs, quote sides, or source sets. The curve row is the target of selection, not the owner of the selector.IndexConventionDetailsTableis only for rebuilding QuantLib indexes and fixings. It is not the source of truth for curve construction.- Do not encode curve selection as an index relationship. Market-data sets may choose different bid/mid/offer, source, scenario, discount, projection, spread, basis, or future volatility curves.
Native curve construction methods
Runtime curve construction honors CurveBuildingDetails.interpolation_method;
it does not silently coerce every curve into log-linear discount space.
Supported methods are intentionally limited to non-deprecated QuantLib
constructors:
log_linear_discount -> ql.DiscountCurve
log_cubic_discount -> ql.LogCubicDiscountCurve
linear_zero -> ql.ZeroCurve with ql.Linear()
cubic_zero -> ql.NaturalCubicZeroCurve with ql.SplineCubic()
natural_cubic_zero -> ql.NaturalCubicZeroCurve with ql.SplineCubic()
monotone_cubic_zero -> ql.MonotonicCubicZeroCurve with ql.MonotonicCubic()
linear_forward -> ql.LinearForwardCurve, only for forward_rate quotes
Zero-rate discount-space methods convert stored zero rates into discount
factors with ql.InterestRate(...).discountFactor(...). Zero-space methods
pass zero rates to the QuantLib zero-curve constructor with the declared
compounding and frequency. rate_unit parsing is the only local scaling step.
Deprecated QuantLib methods are rejected instead of aliased. Do not persist
log_linear_zero, LogLinearZeroCurve, monotonic_log_cubic_discount, or
MonotonicLogCubicDiscountCurve as curve build methods.
Helper-based curve reconstruction
Some curves are not best represented as direct zero or forward nodes at the
source boundary. When the source inputs are helper-style instruments, use the
generic reconstruction machinery under
msm_pricing.pricing_engine.curves.
The primitive API is QuantLib-first and has no MetaTable, DataNode, connector, vendor, currency, or index-name dependency:
import QuantLib as ql
from msm_pricing.pricing_engine.curves import (
CurveObservationExportConfig,
FixedRateBondHelperSpec,
InterestRateFutureHelperSpec,
OISRateHelperSpec,
OvernightDepositHelperSpec,
StaticRateHelperRuntimeResolver,
ZeroCouponBondHelperSpec,
export_curve_observation_nodes,
reconstruct_curve_handle_from_helper_specs,
reconstruct_curve_result_from_key_nodes,
reconstruct_curve_term_structure_from_helper_specs,
)
handle = reconstruct_curve_handle_from_helper_specs(
(
OvernightDepositHelperSpec(quote=0.0475, tenor="1D"),
InterestRateFutureHelperSpec(
quote=95.25,
reference_month="JUN",
reference_year=2026,
reference_frequency="Monthly",
),
OISRateHelperSpec(
quote=0.0480,
tenor="1Y",
settlement_days=2,
overnight_index=ql.Sofr(),
payment_convention="ModifiedFollowing",
payment_frequency="Annual",
payment_calendar=ql.TARGET(),
fixed_payment_frequency="Annual",
fixed_calendar=ql.TARGET(),
averaging_method="Compound",
),
),
valuation_date=valuation_date,
day_counter=ql.Actual360(),
)
nodes = export_curve_observation_nodes(
handle,
valuation_date=valuation_date,
node_days=[7, 30, 90, 180, 365],
)
bond_handle = reconstruct_curve_handle_from_helper_specs(
(
ZeroCouponBondHelperSpec(
quote=97.5,
quote_type="clean_price",
quote_unit="price_per_100",
settlement_days=0,
face_value=100.0,
maturity_date="2026-07-02",
issue_date="2026-01-02",
),
FixedRateBondHelperSpec(
quote=99.0,
quote_type="clean_price",
quote_unit="price_per_100",
coupon_rate=0.05,
issue_date="2026-01-02",
maturity_date="2027-01-02",
tenor="6M",
settlement_days=0,
face_value=100.0,
day_counter=ql.Actual360(),
),
),
valuation_date=valuation_date,
day_counter=ql.Actual360(),
)
bond_nodes = export_curve_observation_nodes(
bond_handle,
valuation_date=valuation_date,
node_days=[181, 365],
)
collateral_handle = ql.YieldTermStructureHandle(
ql.FlatForward(ql.Date(2, 1, 2026), 0.03, ql.Actual360())
)
resolver = StaticRateHelperRuntimeResolver(
yield_curves={"GENERIC-COLLATERAL": collateral_handle},
indexes={
"BASE-OVERNIGHT": ql.OvernightIndex(
"BASE-ON", 0, ql.USDCurrency(), ql.TARGET(), ql.Actual360(), collateral_handle
),
"QUOTE-OVERNIGHT": ql.OvernightIndex(
"QUOTE-ON", 0, ql.EURCurrency(), ql.TARGET(), ql.Actual360(), collateral_handle
),
},
)
xccy_result = reconstruct_curve_result_from_key_nodes(
cross_currency_key_nodes,
valuation_date=valuation_date,
day_counter=ql.Actual360(),
helper_runtime_resolver=resolver,
)
In this example, cross_currency_key_nodes is a rate_helpers@v1 payload with
generic context/provenance nodes plus helper nodes, as shown later in this
section.
For helper-built curves that must be exported back into resolver-compatible
curve observations, derive the export convention from CurveBuildingDetails
instead of hardcoding it beside the connector. Use the term-structure
reconstruction primitive when exporting QuantLib pillar dates; handles are the
right output for pricing but may not expose dates() through every QuantLib
Python binding. The exporter supports pillar dates plus explicit front nodes:
term_structure = reconstruct_curve_term_structure_from_helper_specs(
helper_specs,
valuation_date=valuation_date,
day_counter=ql.Actual360(),
)
export_config = CurveObservationExportConfig.from_curve_building_details(
curve_building_details
)
nodes = export_curve_observation_nodes(
term_structure,
valuation_date=valuation_date,
node_days=[1],
include_pillar_dates=True,
config=export_config,
)
If the persisted build details use source-helper placeholders such as
quote_convention="helper_quote" or rate_unit="helper_unit", the
builder_payload must include explicit output keys such as
output_quote_convention="zero_rate" and output_rate_unit="decimal". Export
compounding comes from the normal build-detail fields, so compounded annual
zero output is represented by
compounding="compounded_annual".
The persistence adapter is narrower. Persisted helper curves use
CurveBuildingDetails.builder_type="rate_helper_curve" and publish generic
helper-shaped dictionaries in DiscountCurvesNode.key_nodes. The adapter reads
the build details, requires builder_payload.helper_schema="rate_helpers@v1",
converts key nodes into runtime helper specs, builds QuantLib rate helpers, and
delegates to reconstruct_curve_handle(...).
rate_helpers@v1 is the canonical helper schema. It supports helper nodes and
generic context/provenance nodes that are needed to build those helpers.
Supported helper key-node types include:
[
{
"source_reference": {
"type": "index",
"identifier": "USD-OVERNIGHT-DEPOSIT-1D"
},
"helper_type": "overnight_deposit_helper",
"quote": 4.75,
"quote_type": "deposit_rate",
"quote_unit": "percent",
"tenor": "1D",
"fixing_days": 0,
"calendar_code": "TARGET",
"business_day_convention": "Following",
"day_counter_code": "Actual360"
},
{
"source_reference": {
"type": "index",
"identifier": "USD-OVERNIGHT-OIS-1Y"
},
"helper_type": "ois_rate_helper",
"quote": 4.80,
"quote_type": "par_swap_rate",
"quote_unit": "percent",
"tenor": "1Y",
"settlement_days": 2,
"floating_index": "USD-OVERNIGHT",
"payment_convention": "ModifiedFollowing",
"payment_frequency": "Annual",
"payment_calendar_code": "TARGET",
"fixed_payment_frequency": "Annual",
"fixed_calendar_code": "TARGET",
"averaging_method": "Compound"
},
{
"source_reference": {
"type": "index",
"identifier": "CME-SOFR-JUN-2026"
},
"helper_type": "sofr_future_rate_helper",
"quote": 95.25,
"quote_type": "futures_price",
"quote_unit": "price",
"reference_month": "JUN",
"reference_year": 2026,
"reference_frequency": "Monthly",
"convexity_adjustment": 0.0
},
{
"source_reference": {
"type": "asset",
"identifier": "EXAMPLE-ZERO-COUPON-BOND-2026"
},
"helper_type": "zero_coupon_bond_helper",
"quote": 97.5,
"quote_type": "clean_price",
"quote_unit": "price_per_100",
"maturity_date": "2026-07-02",
"issue_date": "2026-01-02",
"settlement_days": 0,
"calendar_code": "TARGET",
"face_value": 100.0
},
{
"source_reference": {
"type": "asset",
"identifier": "EXAMPLE-FIXED-RATE-BOND-2027"
},
"helper_type": "fixed_rate_bond_helper",
"quote": 99.0,
"quote_type": "clean_price",
"quote_unit": "price_per_100",
"coupon_rate": 0.05,
"issue_date": "2026-01-02",
"maturity_date": "2027-01-02",
"tenor": "6M",
"settlement_days": 0,
"calendar_code": "TARGET",
"face_value": 100.0,
"day_counter_code": "Actual360"
}
]
Every typed fixed-income helper key node inherits FixedIncomeCurveKeyNode.
Its source_reference is independent of helper construction: bonds can point
to AssetTable.unique_identifier, while deposit, swap, futures, FX, and basis
quotes can point to IndexTable.unique_identifier. Helper-specific fields stay
on their discriminated model, so a futures helper does not carry OIS fields.
The old top-level asset_identifier and index_identifier forms are rejected.
The same rate_helpers@v1 reconstruction path accepts context nodes. It does
not introduce a new schema, builder type, or bootstrap path. The first context
node is helper_type="fx_spot", which supplies construction provenance for FX
swap helpers but is not itself a QuantLib RateHelper.
[
{
"helper_type": "fx_spot",
"quote": 1.1,
"quote_type": "fx_spot",
"quote_unit": "quote_per_base",
"fx_pair": "BASE/QUOTE",
"fx_base_currency": "BASE",
"fx_quote_currency": "QUOTE"
},
{
"helper_type": "fx_swap_rate_helper",
"quote": 0.001,
"quote_type": "fx_forward_points",
"quote_unit": "quote_per_base",
"tenor": "1M",
"fixing_days": 2,
"calendar_code": "TARGET",
"business_day_convention": "ModifiedFollowing",
"end_of_month": false,
"fx_pair": "BASE/QUOTE",
"fx_base_currency": "BASE",
"fx_quote_currency": "QUOTE",
"is_fx_base_currency_collateral_currency": true,
"collateral_curve": "GENERIC-COLLATERAL"
},
{
"helper_type": "const_notional_cross_currency_basis_swap_rate_helper",
"quote": 1.0,
"quote_type": "basis_spread",
"quote_unit": "basis_points",
"tenor": "1Y",
"fixing_days": 2,
"calendar_code": "TARGET",
"business_day_convention": "ModifiedFollowing",
"end_of_month": false,
"base_currency_index": "BASE-OVERNIGHT",
"quote_currency_index": "QUOTE-OVERNIGHT",
"collateral_curve": "GENERIC-COLLATERAL",
"is_fx_base_currency_collateral_currency": true,
"is_basis_on_fx_base_currency_leg": true,
"payment_frequency": "Annual"
}
]
OIS key nodes require the caller to supply a QuantLib overnight index or a
resolver callable at runtime. msm_pricing does not infer an index from curve
names, currencies, vendors, or local product names. Source-specific file
parsing, fallback quote units, and source identifier repairs belong in the
connector or publisher before data becomes generic helper key nodes.
The OIS spec exposes the generic QuantLib OIS schedule/convention surface, including payment lag, payment convention, payment frequency/calendar, forward-start period, overnight spread, pillar choice, averaging method, end-of-month flag, fixed-leg frequency/calendar, and observation-shift fields. Those fields exist for parity with market conventions; callers should pass them explicitly when the source curve depends on them instead of rebuilding curves through connector-local helper constructors.
Interest-rate future helpers consume futures prices, not rates. Future key
nodes therefore require quote_type="futures_price" and quote_unit="price".
The v1 schema supports helper_type="sofr_future_rate_helper" through the
generic InterestRateFutureHelperSpec with explicit reference month, year,
frequency, convexity adjustment, pillar choice, and custom pillar date fields.
Source-specific contract-code parsing stays in the connector or publisher that
creates the generic key node.
Bond helpers consume clean or dirty prices, not rates. The v1 schema supports
helper_type="zero_coupon_bond_helper" and
helper_type="fixed_rate_bond_helper" with explicit quote_type,
quote_unit, face value, dates, calendars, day counters, and schedule fields.
Generic price units are price, price_per_face, and price_per_100.
Source-specific scales must be normalized by the connector before publication.
Fixed-rate helpers can use tenor, coupon_period_days, coupon_frequency,
explicit schedule_dates, or a serialized schedule. These helpers are still
QuantLib RateHelper objects, so they use the same
builder_type="rate_helper_curve" and helper_schema="rate_helpers@v1"
adapter as OIS, deposit, and futures helpers.
Cross-currency helpers still use helper_schema="rate_helpers@v1" even though
they mix helper nodes and context nodes. FX forward points use explicit
quote_type="fx_forward_points" and are not normalized as rates. Raw
point/pip source quotes require a point_scale; already normalized FX-pair
units are consumed directly. Constant-notional cross-currency basis helpers use
quote_type="basis_spread" and accept explicit decimal, percent, or basis
point units. Collateral curves and base/quote currency indexes are resolved by
a RateHelperRuntimeResolver; the static mapping resolver is useful for
offline examples and connector-owned tests, while application/runtime adapters
can resolve those identifiers from their prepared curve/index context.
reconstruct_curve_result_from_key_nodes(...) returns the term structure,
built helpers, parsed specs, context nodes, and captured helper quote errors
for diagnostics.
Observation export is also generic. export_curve_observation_nodes(...)
exports resolver-compatible nodes from a QuantLib handle or term structure on
an explicit node_days grid, or from pillar dates when the submitted QuantLib
object exposes them. zero_rate is the initial exported quote convention;
future curve families should add quote conventions to the exporter rather than
forking product-specific export functions.
from msm_pricing.api import Curve, CurveBuildingDetails, PricingMarketDataSetCurveBinding
curve = Curve.upsert(
unique_identifier="USD-SOFR-3M-PROJECTION",
display_name="USD SOFR 3M Projection Curve",
curve_type="projection",
currency_code="USD",
quote_side="mid",
source="example",
)
CurveBuildingDetails.upsert(
curve_uid=curve.uid,
builder_type="zero_rate_curve",
quote_convention="zero_rate",
rate_unit="decimal",
day_counter_code="Actual360",
calendar_code="TARGET",
interpolation_method="log_linear_discount",
compounding="simple",
extrapolation_policy="enabled",
)
PricingMarketDataSetCurveBinding.upsert_index_curve_selection(
market_data_set_uid=market_data_set.uid,
role_key="projection",
index_uid=index.uid,
quote_side="mid",
curve_uid=curve.uid,
)
For benchmark z-spread analytics, bind the benchmark index UID with the
z_spread_base role. The curve row may still have curve_type="discount" or
another physical curve type; the role describes why the curve is selected.
benchmark_curve = Curve.upsert(
unique_identifier="USD-SOFR-ZSPREAD-BASE",
display_name="USD SOFR Z-Spread Base Curve",
curve_type="discount",
currency_code="USD",
source="example",
)
CurveBuildingDetails.upsert(
curve_uid=benchmark_curve.uid,
builder_type="zero_rate_curve",
quote_convention="zero_rate",
rate_unit="decimal",
day_counter_code="Actual360",
calendar_code="TARGET",
interpolation_method="log_linear_discount",
compounding="simple",
extrapolation_policy="enabled",
)
PricingMarketDataSetCurveBinding.upsert_index_curve_selection(
market_data_set_uid=market_data_set.uid,
role_key="z_spread_base",
index_uid=benchmark_index.uid,
quote_side=None,
curve_uid=benchmark_curve.uid,
)
The instrument then stores only benchmark_rate_index_uid=benchmark_index.uid.
At runtime, z_spread(...) resolves the curve through the index curve
selection above. The helper writes selector_type="index" and
selector_key=str(benchmark_index.uid) into the generic binding table
internally; callers should not pass those selector fields in normal
index-based workflows.
When an observed z-spread should be applied back to a curve handle for scenario or line valuation, keep that as derived runtime state:
from msm_pricing.pricing_engine import apply_z_spread_to_curve
spreaded_curve = apply_z_spread_to_curve(base_curve_handle, z_spread_decimal)
The helper expects the decimal spread returned by Bond.z_spread(...), quoted
as a continuous zero-rate spread. It does not change the persisted curve nodes,
key_nodes, CurveTable, or CurveBuildingDetails.
Curve Scenarios
Curve scenarios live under msm_pricing.scenarios.curves. They shock curves by
Curve.unique_identifier, not by backend curve UID, index UID, role key, quote
side, or provider-local curve name. A curve that is selected by several roles
or lines is rebuilt once for the scenario and reused wherever that same curve
identity is resolved.
from msm_pricing.scenarios.curves import CurveBumpSpec, CurveScenario
scenario = CurveScenario(
name="parallel-up-25bp",
shocks_by_curve_identifier={
"USD-SOFR-3M-PROJECTION": CurveBumpSpec(parallel_bp=25.0),
},
)
CurveBumpSpec supports:
parallel_bp: a basis-point shift applied to every usable source key-node rate or yield;keyrate_bp: tenor labels such as"3M"or positive day counts mapped to basis-point shifts; key-rate values are linearly interpolated and flat extrapolated;metadata_json: caller-owned scenario metadata.
The bump helpers operate on copied key_nodes dictionaries. They never mutate
the submitted observation, the persisted DiscountCurvesNode row, or the
prepared valuation context. Supported key-node rate sources are:
yieldoryield_valuewith an explicit decimal or percent unit;- rate-like
quotevalues whenquote_typeis a rate convention such aszero_rate,forward_rate,par_rate, orswap_rate; implied_ratewith an explicit decimal or percent unit.
Unsupported source quote conversions, such as clean_price or
price_per_100 to a rate, stay outside core msm_pricing. Those conversions
depend on vendor/source interpretation and belong in the producer or connector
adapter that understands the source instrument.
Scenario runtime nodes are built from the bumped key nodes and the runtime
CurveBuildingDetails output convention:
quote_convention = zero_rate -> {"days_to_maturity": ..., "zero": ...}
quote_convention = forward_rate -> {"days_to_maturity": ..., "forward": ...}
If persisted build details use source placeholders such as
quote_convention="key_node_quote" or rate_unit="key_node_unit", the
builder_payload must explicitly declare the runtime output convention and
unit. The scenario helper fails instead of guessing.
Connector-specific curve rebuilds remain connector-owned when they interpret
source files, source identifiers, or vendor quote policy. Once a connector has
generic helper key nodes, builder_type="rate_helper_curve" can be shocked and
rebuilt by core msm_pricing without importing connector code. OIS helper
curves still need a QuantLib overnight index at runtime; callers can pass
overnight_index or overnight_index_resolver to
price_curve_scenario(...), and the high-level scenario loop forwards that
resolver to build_scenario_curve_handle(...). Cross-currency helper curves
also need helper_runtime_resolver so copied scenario key nodes can resolve
collateral curves and base/quote currency indexes while staying on the generic
helper-reconstruction path. If a connector needs source-only interpretation
before generic helper key nodes exist, it can build connector-owned scenario
handles and then call lower-level pricing helpers.
For bond helper curves, no-op scenario reconstruction is supported, but
non-empty yield shocks on price-quoted bond helpers raise a diagnostic until
generic yield-to-price conversion is implemented with explicit bond
conventions. The scenario engine does not bump a stored clean or dirty price as
though it were a rate.
When the caller already has exact base and scenario curve handles, use
price_resolved_curve_scenario(...) instead of rebuilding handles from
key_nodes. That workflow accepts typed LineCurveResolution records, applies
the same deterministic line-role selection and observed z-spread overlays, and
returns the same CurveScenarioResult shape as price_curve_scenario(...).
CurveScenarioResult also exposes the selected
base_curve_handles_by_line and scenario_curve_handles_by_line maps so
callers can reuse the exact scenario handles for local analytics or reporting
without duplicating curve-selection logic.
For broader dashboard or API workflows that need base valuation, multiple
scenario runs, partial-success line diagnostics, analytics, cashflows, carry
impacts, and observed dirty-price z-spread overlay records, use
msm_pricing.scenarios.valuation.run_valuation_scenario_workflow(...). That
valuation workflow delegates its curve runtime override preparation back to
msm_pricing.scenarios.curves; it does not duplicate curve construction.
See Curve Scenarios,
Valuation Scenario Workflow, and
examples/msm_pricing/resolved_curve_scenario.py.
Curve Observations
DiscountCurvesNode publishes timestamped curve observations. Its storage key
is CurveTable.unique_identifier, exposed on the DataNode row as
curve_identifier.
+-----------------------------+ observations keyed by +-----------------------------+
| CurveTable |<--------------------------------| DiscountCurvesNode |
|-----------------------------| curve_identifier |-----------------------------|
| uid PK | | time_index |
| unique_identifier unique | | curve_identifier |
| curve_type | | curve |
| currency_code | | key_nodes |
| | | metadata_json |
| | +-----------------------------+
+-----------------------------+
The curve DataNode contract is:
DiscountCurvesNode
index: (time_index, curve_identifier)
cadence: 1d
columns: curve, key_nodes, metadata_json
FK: curve_identifier -> CurveTable.unique_identifier
curve is required. It remains the serialized compressed pricing payload
consumed by runtime valuation. It stores the constructed curve nodes keyed by
days to maturity; the quote meaning comes from
CurveBuildingDetails.quote_convention, rate_unit, compounding, and the
related build fields. Do not normalize curve points into a different row grain
without a separate decision.
key_nodes records the input quotes used to build that specific curve
observation. It is row-level construction provenance, not another convention
source. Publishers pass producer-owned JSON that satisfies the base contract
below; the DataNode compresses that JSON into the storage column, and read/API
helpers return it decompressed. msm_pricing.data_nodes.CurveKeyNode is the
recommended helper when the standard fields fit the source:
[
{
"maturity_date": "2031-05-27",
"source_reference": {
"type": "asset",
"identifier": "EXAMPLE_BOND_2031"
},
"instrument_type": "fixed_rate_bond",
"quote": 99.25,
"quote_type": "clean_price",
"quote_unit": "price_per_100",
"quote_side": "mid"
},
{
"maturity_date": "2027-05-27",
"instrument_type": "direct_zero_rate",
"quote": 0.105,
"quote_type": "zero_rate",
"quote_unit": "decimal",
"quote_side": "mid",
"yield": 0.105
}
]
Rules:
- Publisher
key_nodesvalues must be JSON object/list provenance with JSON-serializable nested values. They are stored as compressed text and returned by helpers as decompressed JSON. - The shared storage layer does not enforce one fixed per-node financial schema because mixed curves may combine bonds, zero-coupon instruments, swaps, deposits, direct zero rates, and source-specific payloads.
- Prefer the standard fields
source_reference,maturity_date,instrument_type,quote,quote_type,quote_unit, andquote_sidewhen they fit the source. Discount-curve producers that think in yields may also include the optionalyieldfield. source_reference.type="asset"selects anAssetTable.unique_identifier;source_reference.type="index"selects anIndexTable.unique_identifier. This is quote provenance and does not create curve ownership or select a projection/discount valuation role.- Source publishers that need more structure should encode that structure in
their own
CurveKeyNode-compatible extensions or enforce it throughnormalize_key_nodes(...)/set_key_nodes_validator(...). quote_typeandquote_unitdescribe the raw source input for that key node.CurveBuildingDetailsstill describes how the final storedcurveis built and interpreted by pricing.
When a producer needs stricter source-specific validation, extend the DataNode
validator instead of tightening the shared storage contract. The base
DiscountCurvesNode calls normalize_key_nodes(...) for each row after
storage-level JSON normalization. Subclass it for source-owned rules:
from msm_pricing.data_nodes import CurveKeyNode, DiscountCurvesNode
class SourceDiscountCurvesNode(DiscountCurvesNode):
def normalize_key_nodes(self, value, *, row, curve_identifier):
nodes = super().normalize_key_nodes(
value,
row=row,
curve_identifier=curve_identifier,
)
return [
CurveKeyNode.model_validate(node).model_dump(
mode="json",
by_alias=True,
exclude_none=True,
)
for node in nodes
]
For runtime composition, attach a callable with
node.set_key_nodes_validator(...). The callable receives the normalized
value, the full row, and the curve_identifier, and must return JSON
object/list provenance. Do not put validator callables in CurveConfig; they
are execution behavior, not hashed dataset identity.
A DiscountCurvesNode publisher passes the values as ordinary DataFrame
columns. Keep key_nodes beside curve; do not nest it inside the compressed
pricing payload and do not precompress it in the publisher:
return pd.DataFrame(
[
{
"time_index": valuation_timestamp,
"curve_identifier": curve.unique_identifier,
"curve": {
30: 0.0500,
90: 0.0508,
180: 0.0512,
},
"key_nodes": [
{
"maturity_date": "2026-06-26",
"quote": 0.0500,
"quote_type": "zero_rate",
"quote_unit": "decimal",
"yield": 0.0500,
},
{
"maturity_date": "2026-08-25",
"source_reference": {
"type": "index",
"identifier": "USD_SOFR_SWAP_3M",
},
"instrument_type": "ois_swap",
"quote": 0.0508,
"quote_type": "par_rate",
"quote_unit": "decimal",
},
],
"metadata_json": {"source_snapshot": "example-2026-05-27"},
}
]
)
metadata_json stores optional row diagnostics such as provider snapshot IDs,
quality flags, workflow labels, or raw-source checksums. It should not be used
to override the pricing interpretation of curve or key_nodes.
Source publishers should subclass DiscountCurvesNode or inject runtime curve
builder callables. The builder is execution wiring; it is not persisted dataset
identity.