Skip to content

ZeroSpread quotes are not ordered by resolved maturity, causing non-deterministic yield curves #356

Description

@ChEnYuE0821

Hi there,
I found this while building historical ORE scenarios for VaR: the same historical date produced different ZeroSpread yield-curve discount factors when generated separately versus within the full historical run. Repeating the identical single-date configuration in fresh ORE processes confirmed that only YieldCurve/* outputs differed, while discount, index and FX curves matched. Using tenor-based quotes with true did not resolve it, so I reviewed buildZeroSpreadedCurve() and found that resolved dates and quote handles are passed to the curve constructor without being explicitly sorted by maturity.

Please find below for more details.
If you need any more information or any supporting documents, please let me know.

Thank you!


Description

When a yield curve is built from a ZeroSpread segment, repeated ORE runs with identical inputs can produce different curve discount factors. The differences are confined to YieldCurve/* factors; index curves, discount curves, FX and other factors remain identical.

Setting the Zero convention to <TenorBased>true</TenorBased> and using quote tenors such as 6M, 1Y, 2Y, etc. does not resolve the problem. TenorBased correctly converts each tenor into a date, but the resulting date/quote pairs are not ordered by resolved maturity before constructing PiecewiseZeroSpreadedTermStructure.

Environment

  • ORE Python package: open-source-risk-engine 1.8.16.0
  • Python: 3.13.5
  • OS: Windows 11, build 26100
  • Evaluation date used in the reproduction: 2025-10-30
  • The same construction is present on the current master branch as checked on 2026-08-21.

Reproduction

  1. Define a yield curve containing a ZeroSpread segment with multiple spread quotes and a reference yield curve.
  2. Use either explicit maturity dates with TenorBased=false, or tenor quote identifiers with TenorBased=true.
  3. Run the same ORE base-scenario configuration twice in two fresh Python processes.
  4. Compare the two raw scenario.csv files.

The test uses the same market data, curve configuration, conventions, valuation date and ORE configuration in both processes. No post-processing is applied to the raw ORE output.

Relevant source

The issue appears to be in YieldCurve::buildZeroSpreadedCurve().

The function:

  1. stores the loaded market data in std::set<shared_ptr<MarketDatum>>;
  2. iterates over that set;
  3. appends each resolved maturity and quote handle to parallel vectors; and
  4. passes those vectors directly to PiecewiseZeroSpreadedTermStructure.

The set is ordered by the smart pointers, not by the resolved quote maturities. The parallel dates and quoteHandles vectors are not explicitly sorted before curve construction. With tenor-based quotes, the maturity is resolved during step 3, but this still does not impose chronological order.

Expected result

  • Each spread quote should remain paired with its resolved maturity.
  • The nodes should be sorted chronologically by resolved maturity before the term structure is constructed.
  • Repeated runs with identical inputs should produce identical zero-spread yield curves and identical raw scenario output.
  • TenorBased=true and TenorBased=false should both be deterministic.

Impact

This affects reproducibility of zero-spread yield curves and any downstream pricing or historical scenario generation that consumes their discount factors. In the observed case, it caused material differences in historical yield-curve scenarios between otherwise identical ORE runs.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions