From 285590ef033af6159343d59acbf29d595301eb0e Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Mon, 13 Jul 2026 03:46:47 +0800 Subject: [PATCH 01/38] docs(xccy): add MTM implementation design and plan Define fixing, reset, in-progress valuation, and three-block calibration contracts. Include executable test, Jacobian, binding, and benchmark acceptance criteria. --- ...13-xccy-resettable-mtm-plan-corrections.md | 93 ++ .../plans/2026-07-13-xccy-resettable-mtm.md | 922 ++++++++++++++++++ ...3-xccy-in-progress-rate-fixing-addendum.md | 44 + ...-xccy-resettable-mtm-benchmark-addendum.md | 79 ++ .../2026-07-13-xccy-resettable-mtm-design.md | 356 +++++++ 5 files changed, 1494 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-xccy-resettable-mtm-plan-corrections.md create mode 100644 docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md create mode 100644 docs/superpowers/specs/2026-07-13-xccy-in-progress-rate-fixing-addendum.md create mode 100644 docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-benchmark-addendum.md create mode 100644 docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-design.md diff --git a/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm-plan-corrections.md b/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm-plan-corrections.md new file mode 100644 index 000000000..62b274adb --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm-plan-corrections.md @@ -0,0 +1,93 @@ +# XCCY Resettable/MTM Implementation Plan — Normative Corrections + +This file is part of +`docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md`. Apply these corrections +when executing the plan. They resolve issues found by the writing-plans type and benchmark +self-review; the Windows sandbox prevented in-place updates to the newly created plan. + +## 1. Core Joint Spec Uses Core Fields + +Replace the `JointXccyCalibrationSpec_` definition in Task 6 with: + +```cpp +struct JointXccyCalibrationSpec_ { + DateTime_ valuationTime_; + CurrencyPair_ pair_; + Ccy_ collateralCurrency_; + double fxSpot_ = 0.0; + JointCurrencyCurveSpec_ domestic_; + JointCurrencyCurveSpec_ foreign_; + XccyBasisCurveDeclaration_ basis_; + Handle_ fixings_; + double tolerance_ = 1.0e-8; + double fitTolerance_ = 1.0e-6; + double initialGuess_ = 0.0; + int maxEvaluations_ = 200; + int maxRestarts_ = 20; + CurveSolveMode_ solveMode_ = CurveSolveMode_::Value_::EXACT; +}; +``` + +Core `dal-cpp` must not depend on `CurveSolverOptions_`, which is declared in +`dal-public/src/curvespec.hpp`. In Task 7, `JointXccyCalibrationSpecBuilder_` may own one +public `CurveSolverOptions_`; its `Build()` method copies those values into the flat core +fields above. + +## 2. Use `std::optional` + +The `MarketFixingSnapshot_::Find` signature and every local fixing lookup in Tasks 2 and +4 use `std::optional`, not an unqualified `optional`: + +```cpp +[[nodiscard]] std::optional Find(const String_& indexName, + const DateTime_& fixingTime) const; +``` + +Include `` in `fixingsnapshot.hpp` and `xccypricing.hpp` where required. + +## 3. Construct Global Fixing Test Data Explicitly + +Replace the `FixHistory_({{...}})` expressions in Task 2 with C++17-compatible aggregate +setup: + +```cpp +FixHistory_ first; +first.vals_ = {{fixing, 1.10}}; +XGLOBAL::StoreFixings("FX[EUR/USD]", first, false); + +const auto snapshot = SnapshotGlobalFixings({{"FX[EUR/USD]", fixing}}); + +FixHistory_ replacement; +replacement.vals_ = {{fixing, 1.20}}; +XGLOBAL::StoreFixings("FX[EUR/USD]", replacement, false); +ASSERT_NEAR(snapshot->Require("FX[EUR/USD]", fixing, "test"), 1.10, 1.0e-12); +``` + +## 4. Capture a Legacy XCCY Performance Baseline Before Task 1 + +Execute the following benchmark phase before changing production code: + +1. Create `dal-cpp/benchmarks/xccy_perf/` with only baseline-compatible workloads: + fixed 10Y precompute, fixed 10Y passive pricing, fixed ten-instrument basket, and the + existing basis-only calibration. +2. Register and build `xccy_perf` at the current feature-start commit. +3. Run at least 10 samples in Release and retain min/median/max plus workload sizes. +4. Commit the baseline-compatible benchmark separately with message + `perf(xccy): establish fixed pricing baseline`. + +Task 10 then extends that same target with resettable, MTM, in-progress, analytic/bumped, +and three-block workloads. Compare unchanged fixed rows against the feature-start commit, +not against a merge base that lacks the target. Continue to compare the eight standard +DAL benchmark gates against the merge base with `master` using the paired interleaved +protocol. + +## 5. Benchmark Completion Evidence + +The final performance report contains two baselines: + +- `xccy_perf` fixed rows: feature-start benchmark commit versus final branch; +- the eight standard DAL gates: merge base with `master` versus final branch. + +New reset/MTM and three-block rows publish absolute results and become the first future +baseline. A greater-than-4% delta is actionable only after paired, interleaved runs with at +least 10 samples per binary. diff --git a/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md b/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md new file mode 100644 index 000000000..ca5a58beb --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md @@ -0,0 +1,922 @@ +# XCCY Resettable/MTM Notional and In-Progress Trades Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add fixed, resettable, and mark-to-market XCCY notionals; deterministic fixing-aware valuation of in-progress trades; staged basis calibration; and simultaneous domestic/foreign/basis calibration with analytic Jacobians across C++, Python, and Excel. + +**Architecture:** Precompute an immutable XCCY cashflow plan, resolve historical values from one immutable market-fixing snapshot, and value the plan through one scalar-templated pricing kernel. Basis-only and three-block calibration both call that kernel; the joint solver builds all active curves from one parameter vector and never nests calibration calls. + +**Tech Stack:** C++17, DAL curve/discount abstractions, DAL AAD tape and `Underdetermined` solver, Google Test, pybind11/pytest, Machinist-generated Excel bindings, CMake, and DAL's `Bench::Run` micro-benchmark harness. + +## Global Constraints + +- `XccyNotionalMode_` has exactly `FIXED`, `RESETTABLE`, and `MARK_TO_MARKET`; do not retain two independent notional-mode booleans. +- The foreign notional is fixed. The domestic coupon notional resets from the second domestic accrual period at `foreignNotional * FX(fixingTime)`. +- `MARK_TO_MARKET` additionally exchanges `newDomesticNotional - previousDomesticNotional` on the reset effective date. +- Reset effective dates equal domestic coupon accrual starts. Independent reset schedules, intraperiod resets, and foreign-leg resets are out of scope. +- Pricing and collateral currency must equal the currency pair's domestic currency. Reject third-currency collateral. +- One immutable `MarketFixingSnapshot_` carries domestic rate, foreign rate, and FX reset fixings. +- A fixing strictly before valuation time is mandatory; at valuation time use supplied fixing or the active forward; after valuation time use the active forward. +- Exclude cashflows with `paymentDate < valuationDate`; include `paymentDate == valuationDate` at discount factor one. +- Historical fixings are passive AAD constants. Future rates and FX resets remain differentiable through their active curves. +- Preserve existing fixed-notional C++/Python builders and the staged basis-only calibration entry point. +- The three-block solve uses one parameter vector ordered as domestic curves, foreign curves, then basis; residuals are domestic instruments, foreign instruments, then XCCY instruments. +- Analytic Jacobian requests on the new XCCY paths fail with an eligibility reason instead of silently changing to bumped mode. +- C++ follows `.clang-format` and `.codex/skills/dal-agent-team/references/shared-rules.md`. Tests use `TEST`, `ASSERT_*`, and explicit AAD tape cleanup. +- Functional and Jacobian tests must pass before benchmark measurement. + +--- + +## File Map + +### Core protocol, fixings, and pricing + +- Create `dal-cpp/dal/curve/xccynotionalmode.hpp`: Machinist enum source. +- Modify `dal-cpp/dal/protocol/rateconvention.hpp`: remove the two notional booleans from `CrossCurrencyConvention_`. +- Modify `dal-cpp/dal/curve/xccyinstrument.hpp`: fixing identities, reset convention, swap config, cashflow-plan access, and legacy constructor adapter. +- Modify `dal-cpp/dal/curve/xccyinstrument.cpp`: plan construction and passive `Rate_` adapter. +- Create `dal-cpp/dal/indice/fixingsnapshot.hpp` and `.cpp`: immutable normalized snapshot and global-store capture. +- Modify `dal-cpp/dal/indice/index/fx.hpp` and `.cpp`: public canonical direct/reverse FX index-name helpers. +- Create `dal-cpp/dal/curve/xccypricing.hpp` and `.cpp`: cashflow-plan structs, typed market view, fixing resolution, and pricing kernel. + +### Calibration + +- Modify `dal-cpp/dal/curve/xccycalibration.hpp` and `.cpp`: valuation context, domestic collateral currency, fixing snapshot, analytic/bumped options, and kernel integration. +- Create `dal-cpp/dal/curve/jointcalibration_internal.hpp`: reusable curve-slot, parameter slicing, curve construction, smoothing, and diagnostics helpers extracted from `jointcalibration.cpp`. +- Modify `dal-cpp/dal/curve/jointcalibration.cpp`: consume the extracted helpers without behavior change. +- Create `dal-cpp/dal/curve/xccyjointcalibration.hpp` and `.cpp`: the single domestic/foreign/basis solve and layout metadata. + +### Tests and surfaces + +- Create `dal-cpp/tests/indice/test_fixingsnapshot.cpp`. +- Create `dal-cpp/tests/curve/test_xccypricing.cpp`. +- Modify `dal-cpp/tests/curve/test_xccymarket.cpp`. +- Create `dal-cpp/tests/curve/test_xccyjointcalibration.cpp` and `test_xccyjointjacobian.cpp`. +- Modify `dal-cpp/tests/currency/test_currencydata.cpp`. +- Modify `dal-public/src/curveprotocol.hpp`, `curveinstrument.hpp`, `xccycalibration.hpp`, and `xccycalibration.cpp`. +- Modify `dal-public/tests/test_curve_protocol.cpp`, `test_curve_instrument.cpp`, and `test_xccy_calibration.cpp`. +- Modify `dal-python/src/bindings/curve.cpp`; create `dal-python/tests/test_xccy_resettable.py` and `test_xccy_joint.py`. +- Modify `dal-excel/src/__curve_storable.hpp`, `__curveprotocol.cpp`, `__curveinstrument.cpp`, and `__xccycalibration.cpp`; regenerate core and Excel `dal-*/auto/MG_*` outputs. + +### Benchmark and documentation + +- Create `dal-cpp/benchmarks/xccy_perf/CMakeLists.txt` and `xccy_perf.cpp`; modify `dal-cpp/benchmarks/CMakeLists.txt`. +- Create `dal-cpp/examples/xccy_mtm_calibration/CMakeLists.txt` and `xccy_mtm_calibration.cpp`; modify `dal-cpp/examples/CMakeLists.txt`. +- Modify `docs/methodology/xccy_calibration.md`, `docs/methodology/yield_curve_jacobian.md`, `docs/public-api.md`, `docs/README.md`, `dal-python/README.md`, `dal-excel/README.md`, and `CHANGELOG.md`. + +--- + +### Task 1: Replace Boolean Notional Flags with a Generated Mode and Config Types + +**Files:** +- Create: `dal-cpp/dal/curve/xccynotionalmode.hpp` +- Modify: `dal-cpp/dal/protocol/rateconvention.hpp` +- Modify: `dal-cpp/dal/curve/xccyinstrument.hpp` +- Modify: `dal-cpp/tests/currency/test_currencydata.cpp` +- Modify: `dal-cpp/tests/curve/test_xccymarket.cpp` +- Generate: `dal-cpp/dal/auto/MG_XccyNotionalMode_enum.hpp` +- Generate: `dal-cpp/dal/auto/MG_XccyNotionalMode_enum.inc` +- Generate: matching `dal-excel/auto/MG_XccyNotionalMode_*` artifacts + +**Interfaces:** +- Produces: `XccyNotionalMode_`, `FixingIdentity_`, `FxResetConvention_`, and `CrossCurrencySwapConfig_`. +- Preserves: the current `CrossCurrencySwap_` constructor as a `FIXED` adapter until the public facade migrates. + +- [ ] **Step 1: Write the failing enum/config tests** + +Add assertions that the currency default is one single mode and that reset timing is explicit: + +```cpp +TEST(CurrencyDataTest, TestXcsDefaultConventionHasNoNotionalModeBooleans) { + const CrossCurrencyConvention_& xcs = Ccy::Conventions::Xcs()(Ccy_("USD")); + ASSERT_TRUE(xcs.initialNotionalExchange_); + ASSERT_TRUE(xcs.finalNotionalExchange_); + ASSERT_TRUE(xcs.spreadOnForeignLeg_); +} + +TEST(XccyMarketTest, TestResetConfigRequiresExplicitFixingIdentity) { + CrossCurrencySwapConfig_ config; + config.notionalMode_ = XccyNotionalMode_::Value_::RESETTABLE; + ASSERT_THROW(static_cast(CrossCurrencySwap_(Date_(2024, 1, 2), Date_(2024, 1, 4), Date_(2025, 1, 4), + 0.0, config)), + Dal::Exception_); +} +``` + +- [ ] **Step 2: Build to verify the new types are missing** + +Run: `cmake --build build --target dal_cpp_tests -j 4` + +Expected: compilation fails because `CrossCurrencySwapConfig_` and `XccyNotionalMode_` are undefined. + +- [ ] **Step 3: Add Machinist enum markup and core config structs** + +Create `xccynotionalmode.hpp` with: + +```cpp +/*IF-------------------------------------------------------------------------- +enumeration XccyNotionalMode + Cross-currency notional evolution rule +switchable +alternative FIXED +alternative RESETTABLE +alternative MARK_TO_MARKET +-IF-------------------------------------------------------------------------*/ + +#pragma once +#include +``` + +In `xccyinstrument.hpp`, add: + +```cpp +struct FixingIdentity_ { + String_ indexName_; + int fixingHour_ = -1; + int fixingMinute_ = -1; +}; + +struct FxResetConvention_ { + int fixingLag_ = -1; + Holidays_ fixingHolidays_; + BizDayConvention_ fixingConvention_ = BizDayConvention_("Preceding"); + int fixingHour_ = -1; + int fixingMinute_ = -1; +}; + +struct CrossCurrencySwapConfig_ { + CurrencyPair_ pair_; + double domesticNotional_ = 100.0; + double foreignNotional_ = 100.0; + CrossCurrencyConvention_ convention_; + XccyNotionalMode_ notionalMode_ = XccyNotionalMode_::Value_::FIXED; + FxResetConvention_ fxReset_; + FixingIdentity_ domesticRateFixing_; + FixingIdentity_ foreignRateFixing_; +}; +``` + +Delete `resettableNotional_` and `markToMarketNotional_` from `CrossCurrencyConvention_`. Add constructor validation: positive finite notionals, distinct pair currencies, valid maturity, and explicit reset lag/time for non-`FIXED` modes. + +- [ ] **Step 4: Regenerate and check generated sources** + +Run: `cmake --build build --target dal_generate -j 4` + +Run: `cmake --build build --target dal_check_generated -j 4` + +Expected: both commands succeed and core plus Excel generated enum files are present. + +- [ ] **Step 5: Run the protocol tests** + +Run: `bin/dal_cpp_tests --gtest_filter=CurrencyDataTest.*:XccyMarketTest.TestResetConfigRequiresExplicitFixingIdentity` + +Expected: all selected tests pass. + +- [ ] **Step 6: Commit the protocol unit** + +```bash +git add dal-cpp/dal/curve/xccynotionalmode.hpp dal-cpp/dal/protocol/rateconvention.hpp dal-cpp/dal/curve/xccyinstrument.hpp dal-cpp/dal/auto dal-excel/auto dal-cpp/tests/currency/test_currencydata.cpp dal-cpp/tests/curve/test_xccymarket.cpp +git commit -m "feat(xccy): define notional modes and reset config" +``` + +--- + +### Task 2: Add Immutable Market Fixing Snapshots + +**Files:** +- Create: `dal-cpp/dal/indice/fixingsnapshot.hpp` +- Create: `dal-cpp/dal/indice/fixingsnapshot.cpp` +- Modify: `dal-cpp/dal/indice/index/fx.hpp` +- Modify: `dal-cpp/dal/indice/index/fx.cpp` +- Create: `dal-cpp/tests/indice/test_fixingsnapshot.cpp` + +**Interfaces:** +- Consumes: `CurrencyPair_`, `DateTime_`, and the existing `Global::Fixings_::History` store. +- Produces: + +```cpp +struct FixingRequest_ { String_ indexName_; DateTime_ fixingTime_; }; + +class MarketFixingSnapshot_ { +public: + using history_t = std::map; + using values_t = std::map; + explicit MarketFixingSnapshot_(const values_t& values = values_t()); + [[nodiscard]] optional Find(const String_& indexName, const DateTime_& fixingTime) const; + [[nodiscard]] double Require(const String_& indexName, const DateTime_& fixingTime, const String_& context) const; +}; + +Handle_ SnapshotGlobalFixings(const Vector_& requests); +String_ FxIndexName(const CurrencyPair_& pair); +String_ ReverseFxIndexName(const CurrencyPair_& pair); +``` + +- [ ] **Step 1: Write failing snapshot tests** + +Cover direct lookup, reverse FX reciprocal, consistent/inconsistent two-way quotes, invalid numbers, missing required values, and global-store immutability: + +```cpp +TEST(FixingSnapshotTest, TestSnapshotDoesNotObserveLaterGlobalMutation) { + const DateTime_ fixing(Date_(2024, 1, 2), 11, 0); + XGLOBAL::StoreFixings("FX[EUR/USD]", FixHistory_({{fixing, 1.10}}), false); + const auto snapshot = SnapshotGlobalFixings({{"FX[EUR/USD]", fixing}}); + XGLOBAL::StoreFixings("FX[EUR/USD]", FixHistory_({{fixing, 1.20}}), false); + ASSERT_NEAR(snapshot->Require("FX[EUR/USD]", fixing, "test"), 1.10, 1.0e-12); +} +``` + +- [ ] **Step 2: Run the test and verify failure** + +Run: `cmake --build build --target dal_cpp_tests -j 4` + +Expected: compilation fails because `fixingsnapshot.hpp` and its types do not exist. + +- [ ] **Step 3: Implement normalized immutable storage** + +The constructor copies input, checks every value with `std::isfinite(value) && value > 0.0`, and checks direct/reverse FX pairs with: + +```cpp +REQUIRE(std::fabs(direct * reverse - 1.0) <= 1.0e-10, + "Inconsistent direct/reverse FX fixings for " + directName + " at " + DateTime::ToString(fixingTime)); +``` + +`SnapshotGlobalFixings` deduplicates requests, reads the global histories once, and stores only requested timestamps. `Require` names the index, timestamp, and supplied context in its exception. + +- [ ] **Step 4: Expose canonical FX names** + +Move the current private naming formula into free functions used by both `Index::Fx_` and the snapshot resolver: + +```cpp +String_ FxIndexName(const Ccy_& domestic, const Ccy_& foreign) { + return String_("FX[") + foreign.String() + "/" + domestic.String() + "]"; +} +``` + +- [ ] **Step 5: Run fixing tests** + +Run: `bin/dal_cpp_tests --gtest_filter=FixingSnapshotTest.*:IndexFxTest.*` + +Expected: all selected tests pass. + +- [ ] **Step 6: Commit the snapshot unit** + +```bash +git add dal-cpp/dal/indice/fixingsnapshot.hpp dal-cpp/dal/indice/fixingsnapshot.cpp dal-cpp/dal/indice/index/fx.hpp dal-cpp/dal/indice/index/fx.cpp dal-cpp/tests/indice/test_fixingsnapshot.cpp +git commit -m "feat(fixings): add immutable market snapshots" +``` + +--- + +### Task 3: Precompute XCCY Cashflow and Reset Plans + +**Files:** +- Create: `dal-cpp/dal/curve/xccypricing.hpp` +- Create: `dal-cpp/dal/curve/xccypricing.cpp` +- Modify: `dal-cpp/dal/curve/xccyinstrument.hpp` +- Modify: `dal-cpp/dal/curve/xccyinstrument.cpp` +- Create: `dal-cpp/tests/curve/test_xccypricing.cpp` + +**Interfaces:** +- Consumes: `CrossCurrencySwapConfig_`, `BuildLegPeriods`, and `SchedulePeriod_`. +- Produces: + +```cpp +struct XccyCouponPeriod_ { + SchedulePeriod_ schedule_; + AccrualPeriod_ accrual_; + String_ rateIndexName_; + DateTime_ rateFixingTime_; +}; + +struct XccyResetEvent_ { + Date_ effectiveDate_; + DateTime_ fxFixingTime_; + int domesticPeriodIndex_ = 0; +}; + +struct XccyCashflowPlan_ { + Vector_ domesticPeriods_; + Vector_ foreignPeriods_; + Vector_ resets_; + CrossCurrencySwapConfig_ config_; + Date_ start_; + Date_ maturity_; +}; + +XccyCashflowPlan_ BuildXccyCashflowPlan(const Date_& start, const Date_& maturity, const CrossCurrencySwapConfig_& config); +Vector_ RequiredHistoricalFixings(const XccyCashflowPlan_& plan, const DateTime_& valuationTime); +``` + +- [ ] **Step 1: Write failing plan tests** + +Add exact assertions for quarterly periods, a short stub, fixing lag/holiday adjustment, first-period no-reset behavior, second-period reset, and MTM event dates: + +```cpp +TEST(XccyPricingTest, TestQuarterlyMtmPlanResetsFromSecondPeriod) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 1, 4), MakeQuarterlyMtmConfig()); + ASSERT_EQ(plan.domesticPeriods_.size(), 4U); + ASSERT_EQ(plan.resets_.size(), 3U); + ASSERT_EQ(plan.resets_[0].effectiveDate_, plan.domesticPeriods_[1].schedule_.accrualStart_); + ASSERT_EQ(plan.resets_[0].domesticPeriodIndex_, 1); +} +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `cmake --build build --target dal_cpp_tests -j 4` + +Expected: compilation fails because the plan API is missing. + +- [ ] **Step 3: Implement plan construction only** + +Build both coupon schedules with `BuildLegPeriods`, combine each schedule fixing date with the configured hour/minute, and generate reset events only for domestic indices `1..N-1`. Do not access curves or fixings in this function. + +For `FIXED`, return an empty reset vector. For non-fixed modes, validate each reset effective date equals the target domestic accrual start and that reset events are strictly increasing. + +- [ ] **Step 4: Implement fixing-request enumeration** + +Filter coupons with `paymentDate >= valuationTime.Date()`. Add rate requests only when `rateFixingTime_ < valuationTime`; add FX requests only when `fxFixingTime_ < valuationTime`. Sort by index then time and deduplicate. + +- [ ] **Step 5: Run plan tests** + +Run: `bin/dal_cpp_tests --gtest_filter=XccyPricingTest.Test*Plan*:XccyPricingTest.TestRequiredHistoricalFixings*` + +Expected: all selected tests pass. + +- [ ] **Step 6: Commit the planning unit** + +```bash +git add dal-cpp/dal/curve/xccypricing.hpp dal-cpp/dal/curve/xccypricing.cpp dal-cpp/dal/curve/xccyinstrument.hpp dal-cpp/dal/curve/xccyinstrument.cpp dal-cpp/tests/curve/test_xccypricing.cpp +git commit -m "feat(xccy): precompute reset and cashflow plans" +``` + +--- + +### Task 4: Implement the Typed Pricing Kernel and In-Progress Valuation + +**Files:** +- Modify: `dal-cpp/dal/curve/xccypricing.hpp` +- Modify: `dal-cpp/dal/curve/xccypricing.cpp` +- Modify: `dal-cpp/dal/curve/xccyinstrument.cpp` +- Modify: `dal-cpp/tests/curve/test_xccypricing.cpp` +- Modify: `dal-cpp/tests/curve/test_xccymarket.cpp` + +**Interfaces:** +- Consumes: `XccyCashflowPlan_`, `MarketFixingSnapshot_`, and domestic/foreign/basis typed curves. +- Produces: + +```cpp +template struct XccyMarketView_ { + DateTime_ valuationTime_; + CurrencyPair_ pair_; + Ccy_ collateralCurrency_; + T_ fxSpot_; + const Tape::JointCurveBlock_* domestic_ = nullptr; + const Tape::JointCurveBlock_* foreign_ = nullptr; + const Tape::DiscountCurve_* basis_ = nullptr; +}; + +template +T_ PriceXccyParSpread(const XccyCashflowPlan_& plan, + const XccyMarketView_& market, + const MarketFixingSnapshot_& fixings); +``` + +- [ ] **Step 1: Write failing fixing-source tests** + +Cover past/equal/future rate and FX timestamps. Assert missing historical values throw and future values respond to changed forecast/basis curves. + +- [ ] **Step 2: Write the failing hand-calculated MTM test** + +Use two domestic periods, fixed foreign notional `100`, initial domestic notional `110`, second-period FX fixing `1.20`, unit discount factors, zero floating coupons, and enabled initial/final exchanges. Assert the domestic MTM delta is `+10`, the final domestic exchange is `120`, and the kernel par-spread numerator matches the explicit signed cashflow sum. + +```cpp +ASSERT_NEAR(resolved.domesticNotionals_[0], 110.0, 1.0e-12); +ASSERT_NEAR(resolved.domesticNotionals_[1], 120.0, 1.0e-12); +ASSERT_NEAR(resolved.mtmDeltas_[0], 10.0, 1.0e-12); +ASSERT_NEAR(actualParSpread, explicitNumerator / explicitForeignAnnuity, 1.0e-12); +``` + +- [ ] **Step 3: Write failing in-progress tests** + +Construct a trade with one paid coupon, one past-fixed/future-paid coupon, one valuation-date payment, and one future coupon. Assert the first is absent, the second uses the snapshot, the valuation-date discount factor is one, and the future coupon changes with its forecast curve. + +- [ ] **Step 4: Implement scalar-templated resolution** + +Use this branch rule for rate and FX values: + +```cpp +if (fixingTime < market.valuationTime_) + return T_(fixings.Require(indexName, fixingTime, context)); +if (fixingTime == market.valuationTime_) { + const optional supplied = fixings.Find(indexName, fixingTime); + if (supplied) + return T_(*supplied); +} +return activeForward(); +``` + +Resolve domestic notionals first, then coupons and notional events. For `RESETTABLE`, omit intermediate deltas. For `MARK_TO_MARKET`, discount each delta on its effective date. Filter historical payment events before fixing lookup. + +- [ ] **Step 5: Replace the fixed-only `CrossCurrencySwapRate_` body** + +Keep `CrossCurrencySwap_::Rate_::operator()(const CrossCurrencyMarket_&)` for compatibility, but make it adapt the passive market to `XccyMarketView_` and call `PriceXccyParSpread`. Remove the two “not implemented” checks and the “in-progress swaps not supported” check. + +- [ ] **Step 6: Run pricing tests** + +Run: `bin/dal_cpp_tests --gtest_filter=XccyPricingTest.*:XccyMarketTest.TestCrossCurrencySwap*` + +Expected: fixing, manual MTM, in-progress, fixed regression, missing-fixing, and matured-trade tests pass. + +- [ ] **Step 7: Commit the pricing unit** + +```bash +git add dal-cpp/dal/curve/xccypricing.hpp dal-cpp/dal/curve/xccypricing.cpp dal-cpp/dal/curve/xccyinstrument.cpp dal-cpp/tests/curve/test_xccypricing.cpp dal-cpp/tests/curve/test_xccymarket.cpp +git commit -m "feat(xccy): price reset and in-progress cashflows" +``` + +--- + +### Task 5: Integrate the Kernel into Basis-Only Calibration and Add Analytic Jacobians + +**Files:** +- Modify: `dal-cpp/dal/curve/xccycalibration.hpp` +- Modify: `dal-cpp/dal/curve/xccycalibration.cpp` +- Modify: `dal-cpp/tests/curve/test_xccymarket.cpp` +- Create: `dal-cpp/tests/curve/test_xccybasisjacobian.cpp` + +**Interfaces:** +- Adds to `CrossCurrencyCalibrationSpec_`: `DateTime_ valuationTime_`, `Ccy_ collateralCurrency_`, and `Handle_ fixings_`. +- Produces: + +```cpp +struct CrossCurrencyCalibrationOptions_ { + CurveJacobianMode_ jacobianMode_ = CurveJacobianMode_::Value_::ANALYTIC; + bool computeEffJacobianInverse_ = true; + bool computeForwardJacobian_ = true; +}; + +CrossCurrencyCalibrationResult_ CalibrateCrossCurrencyMarket( + const CrossCurrencyCalibrationSpec_& spec, + const CrossCurrencyCalibrationOptions_& options); +``` + +- [ ] **Step 1: Write failing staged-calibration tests** + +Add one future-start MTM instrument and one in-progress MTM instrument with a supplied snapshot. Generate quotes from a known flat basis, calibrate from zero, and assert both model rates reprice within `1.0e-8`. + +- [ ] **Step 2: Write the failing basis Jacobian comparison** + +At a fixed parameter vector, evaluate `XccyCalibrationFunc_::Gradient` in analytic mode and compare every element with central differences using `h = 1.0e-6` and tolerance `1.0e-7`. Assert columns associated only with historical FX resets do not acquire extra fixing sensitivity. + +- [ ] **Step 3: Run and verify failure** + +Run: `cmake --build build --target dal_cpp_tests -j 4` + +Expected: compilation fails because the options overload and analytic XCCY residual path are missing. + +- [ ] **Step 4: Make valuation context explicit** + +Stop reading `Global::Dates_::EvaluationDate()` inside `CrossCurrencyMarket_`. Store the valuation date/time and collateral currency in the market/spec, validate `collateralCurrency_ == basisPair_.domestic_`, and keep the existing `today_` adapter for callers that do not set `valuationTime_`. + +- [ ] **Step 5: Add the AAD gradient** + +Build the basis curve with `BuildDiscountCurveT`, build passive domestic/foreign typed views, call `PriceXccyParSpread` for every residual, and return `XCurveJacobian_(HarvestCurveJacobian(...))`. If the requested basis parameterization or instrument plan is ineligible, throw an exception naming the reason. + +- [ ] **Step 6: Return diagnostics according to options** + +Pass `effJacobianInverse_` and forward Jacobian pointers to `Underdetermined::Find` only when requested. Keep them empty in approximate mode and state that behavior in result comments. + +- [ ] **Step 7: Run staged and Jacobian tests** + +Run: `bin/dal_cpp_tests --gtest_filter=XccyMarketTest.*:XccyBasisJacobianTest.*` + +Expected: staged fixed/reset/MTM calibration, started-trade calibration, analytic/central-difference comparison, and bumped mode pass. + +- [ ] **Step 8: Commit basis calibration integration** + +```bash +git add dal-cpp/dal/curve/xccycalibration.hpp dal-cpp/dal/curve/xccycalibration.cpp dal-cpp/tests/curve/test_xccymarket.cpp dal-cpp/tests/curve/test_xccybasisjacobian.cpp +git commit -m "feat(xccy): calibrate basis with reset-aware AAD pricing" +``` + +--- + +### Task 6: Add the Three-Block Domestic/Foreign/Basis Joint Solver + +**Files:** +- Create: `dal-cpp/dal/curve/jointcalibration_internal.hpp` +- Modify: `dal-cpp/dal/curve/jointcalibration.cpp` +- Create: `dal-cpp/dal/curve/xccyjointcalibration.hpp` +- Create: `dal-cpp/dal/curve/xccyjointcalibration.cpp` +- Create: `dal-cpp/tests/curve/test_xccyjointcalibration.cpp` +- Create: `dal-cpp/tests/curve/test_xccyjointjacobian.cpp` +- Modify: `dal-cpp/tests/curve/test_joint_calibration.cpp` +- Modify: `dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp` + +**Interfaces:** +- Consumes: `JointCurveDeclaration_`, the typed XCCY kernel, `Underdetermined`, and `MarketFixingSnapshot_`. +- Produces: + +```cpp +struct CalibrationBlockRange_ { + String_ name_; + int offset_ = 0; + int size_ = 0; +}; + +struct JointCurrencyCurveSpec_ { + Ccy_ ccy_; + DayBasis_ liborBasis_ = DayBasis_("ACT_365F"); + Vector_ curves_; +}; + +struct XccyBasisCurveDeclaration_ { + String_ curveName_ = "xccy_basis"; + Vector_> instruments_; + Vector_ knotDates_; + CurveParameterization_ parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + double smoothingWeight_ = 1.0; + Vector_ initialGuessPerNode_; +}; + +struct JointXccyCalibrationSpec_ { + DateTime_ valuationTime_; + CurrencyPair_ pair_; + Ccy_ collateralCurrency_; + double fxSpot_ = 0.0; + JointCurrencyCurveSpec_ domestic_; + JointCurrencyCurveSpec_ foreign_; + XccyBasisCurveDeclaration_ basis_; + Handle_ fixings_; + CurveSolverOptions_ solver_; +}; +``` + +`JointXccyCalibrationResult_` contains domestic/foreign curve-block handles, basis curve, FX forwards, grouped diagnostics, `jacobianAtSolution_`, `effJacobianInverse_`, and vectors of parameter/residual `CalibrationBlockRange_`. + +- [ ] **Step 1: Write a failing synthetic recovery test** + +Build known domestic and foreign OIS/3M curves plus a known basis curve. Quote domestic instruments, foreign instruments, and 1Y–5Y resettable/MTM XCCY swaps from that market. Start all curve parameters away from the truth, solve once, and assert every instrument group reprices within `1.0e-8`. + +- [ ] **Step 2: Write failing validation tests** + +Cover mismatched currencies, third-currency collateral, unordered knots, missing curve slots, no remaining XCCY annuity, invalid spot, duplicate declarations, and empty instrument groups. Error assertions must match the offending pair or slot name. + +- [ ] **Step 3: Write the failing stacked Jacobian test** + +Compare analytic and central-difference matrices for every domestic, foreign, and basis column. Assert the range metadata exactly partitions all columns and rows without gaps or overlap. + +- [ ] **Step 4: Extract reusable joint-curve helpers without changing existing behavior** + +Move slot definitions, validation, parameter slicing, typed curve construction, smoothing assembly, and diagnostics from the anonymous namespace in `jointcalibration.cpp` into `jointcalibration_internal.hpp`. Keep the original `CalibrateJointMultiCurve` public types and output unchanged. + +Run: `bin/dal_cpp_tests --gtest_filter=JointCalibrationTest.*:JointAnalyticJacobianTest.*` + +Expected: all existing joint tests pass before adding XCCY solve logic. + +- [ ] **Step 5: Implement one residual function** + +Build the unknown vector in this exact order: + +```text +domestic declaration 0..N | foreign declaration 0..M | basis nodes +``` + +Build one active domestic block, one active foreign block, and one active basis curve per evaluation. Evaluate domestic and foreign `YCInstrument_` residuals through their existing typed rates, then XCCY residuals through `PriceXccyParSpread`. Do not call `CalibrateJointMultiCurve` or `CalibrateCrossCurrencyMarket` from the residual function. + +- [ ] **Step 6: Add exact, approximate, analytic, and bumped paths** + +Use block-diagonal smoothing over all declarations. Exact mode returns both effective inverse and optional forward Jacobian; approximate mode returns neither inverse and marks diagnostics accordingly. Analytic eligibility must include every domestic/foreign instrument plus every XCCY plan. + +- [ ] **Step 7: Run joint XCCY tests** + +Run: `bin/dal_cpp_tests --gtest_filter=XccyJointCalibrationTest.*:XccyJointJacobianTest.*:JointCalibrationTest.*:JointAnalyticJacobianTest.*` + +Expected: synthetic recovery, validation, exact/approximate, analytic/bumped, layout, and all pre-existing joint tests pass. + +- [ ] **Step 8: Commit the joint solver unit** + +```bash +git add dal-cpp/dal/curve/jointcalibration_internal.hpp dal-cpp/dal/curve/jointcalibration.cpp dal-cpp/dal/curve/xccyjointcalibration.hpp dal-cpp/dal/curve/xccyjointcalibration.cpp dal-cpp/tests/curve/test_xccyjointcalibration.cpp dal-cpp/tests/curve/test_xccyjointjacobian.cpp dal-cpp/tests/curve/test_joint_calibration.cpp dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp +git commit -m "feat(xccy): jointly calibrate domestic foreign and basis curves" +``` + +--- + +### Task 7: Add Public C++ Builders and Compatibility Tests + +**Files:** +- Modify: `dal-public/src/curveprotocol.hpp` +- Modify: `dal-public/src/curveinstrument.hpp` +- Modify: `dal-public/src/xccycalibration.hpp` +- Modify: `dal-public/src/xccycalibration.cpp` +- Modify: `dal-public/tests/test_curve_protocol.cpp` +- Modify: `dal-public/tests/test_curve_instrument.cpp` +- Modify: `dal-public/tests/test_xccy_calibration.cpp` + +**Interfaces:** +- Produces: `FxResetConventionNew`, `MarketFixingSnapshotNew`, `CrossCurrencySwapConfigBuilder_`, the config overload of `CrossCurrencySwapNew`, `JointXccyCalibrationSpecBuilder_`, `CalibrateJointXccyMarket`, and result accessors. +- Preserves: the current long `CrossCurrencySwapNew` overload as `FIXED`. + +- [ ] **Step 1: Write failing builder round-trip tests** + +Set every reset, fixing identity, collateral, snapshot, curve declaration, solver, and instrument field; call `Build()`; assert each core spec field is preserved. Add a compile-and-run test for the legacy long overload. + +- [ ] **Step 2: Run and verify failure** + +Run: `cmake --build build --target dal_public_tests -j 4` + +Expected: compilation fails because the new public builders are undefined. + +- [ ] **Step 3: Implement config-focused public factories** + +Keep required dates/rate as function arguments and move optional behavior into config: + +```cpp +Handle_ CrossCurrencySwapNew(const Date_& tradeDate, + const Date_& start, + const Date_& maturity, + double marketRate, + const CrossCurrencySwapConfig_& config); +``` + +`MarketFixingSnapshotNew` accepts `std::map>`. The joint builder owns flat solver fields only if Python member-pointer compatibility requires it; `Build()` converts them into one `CurveSolverOptions_`. + +- [ ] **Step 4: Run public tests** + +Run: `bin/dal_public_tests --gtest_filter=CurveProtocolTest.*:CurveInstrumentTest.TestCrossCurrency*:XccyCalibrationTest.*` + +Expected: old and new instrument builders, snapshot, staged builder, joint builder, and result layout pass. + +- [ ] **Step 5: Commit the public API unit** + +```bash +git add dal-public/src/curveprotocol.hpp dal-public/src/curveinstrument.hpp dal-public/src/xccycalibration.hpp dal-public/src/xccycalibration.cpp dal-public/tests/test_curve_protocol.cpp dal-public/tests/test_curve_instrument.cpp dal-public/tests/test_xccy_calibration.cpp +git commit -m "feat(public): expose resettable and joint XCCY builders" +``` + +--- + +### Task 8: Add Python Bindings and Tests + +**Files:** +- Modify: `dal-python/src/bindings/curve.cpp` +- Create: `dal-python/tests/test_xccy_resettable.py` +- Create: `dal-python/tests/test_xccy_joint.py` +- Modify: `dal-python/tests/test_xccy_calibration.py` + +**Interfaces:** +- Consumes: all public builders and result types from Task 7. +- Produces: Python enum/config/snapshot/joint builder classes and keyword-based functions. + +- [ ] **Step 1: Write failing Python tests** + +Create a nested fixing mapping, build an in-progress MTM swap, assert missing historical values raise, run a small joint calibration, and read parameter/residual ranges plus the Jacobian dimensions: + +```python +snapshot = dal.MarketFixingSnapshot_New({ + "USD-SOFR-3M": {rate_fixing_time: 0.0525}, + "EUR-EURIBOR-3M": {foreign_fixing_time: 0.0310}, + "FX[EUR/USD]": {fx_fixing_time: 1.10}, +}) +assert snapshot is not None +assert result.jacobianAtSolution_.rows() == sum(r.size_ for r in result.residualRanges_) +assert result.jacobianAtSolution_.cols() == sum(r.size_ for r in result.parameterRanges_) +``` + +- [ ] **Step 2: Run and verify failure** + +Run: `python -m pytest dal-python/tests/test_xccy_resettable.py dal-python/tests/test_xccy_joint.py -q` + +Expected: import or attribute failures for the new bindings. + +- [ ] **Step 3: Bind enum, configs, snapshots, builders, and results** + +Expose new fields with snake_case aliases while retaining existing underscore field names used by current tests. Convert nested Python dictionaries to the C++ snapshot map and convert instrument lists to `Handle_` collections with the existing const-pointer ownership pattern. + +- [ ] **Step 4: Keep the old Python call unchanged** + +Retain the current `CrossCurrencySwap_New(trade_date, start, maturity, market_rate, currencies, ...)` registration. Add a config overload distinguished by its final argument type; do not reorder the old arguments. + +- [ ] **Step 5: Run Python XCCY tests** + +Run: `python -m pytest dal-python/tests/test_xccy_calibration.py dal-python/tests/test_xccy_resettable.py dal-python/tests/test_xccy_joint.py -q` + +Expected: all selected tests pass. + +- [ ] **Step 6: Commit the Python unit** + +```bash +git add dal-python/src/bindings/curve.cpp dal-python/tests/test_xccy_calibration.py dal-python/tests/test_xccy_resettable.py dal-python/tests/test_xccy_joint.py +git commit -m "feat(python): bind resettable and joint XCCY APIs" +``` + +--- + +### Task 9: Add Excel Handles, Functions, and Generated Sources + +**Files:** +- Modify: `dal-excel/src/__curve_storable.hpp` +- Modify: `dal-excel/src/__curveprotocol.cpp` +- Modify: `dal-excel/src/__curveinstrument.cpp` +- Modify: `dal-excel/src/__xccycalibration.cpp` +- Generate: `dal-excel/auto/MG_XccyResetConvention_New_*` +- Generate: `dal-excel/auto/MG_MarketFixingSnapshot_New_*` +- Generate: `dal-excel/auto/MG_CrossCurrencySwap_Config_New_*` +- Generate: `dal-excel/auto/MG_Calibrate_JointXccy_*` +- Generate: joint result accessor sources and HTML help + +**Interfaces:** +- Produces worksheet functions `XCCYRESETCONVENTION.NEW`, `MARKETFIXINGSNAPSHOT.NEW`, `CROSSCURRENCYSWAP.CONFIG.NEW`, `CALIBRATE.JOINTXCCY`, and joint result accessors. +- Preserves `CROSSCURRENCYSWAP.NEW` and `CALIBRATE.XCCYMARKET` registrations. + +- [ ] **Step 1: Add storable wrappers** + +Add `StorableCrossCurrencySwapConfig_`, `StorableMarketFixingSnapshot_`, and `StorableJointXccyCalibrationResult_`. The result wrapper retains handles to both solved curve blocks and the basis curve so Excel accessors cannot outlive them. + +- [ ] **Step 2: Add Machinist public declarations** + +Use handles for nested configs and results. `MARKETFIXINGSNAPSHOT.NEW` accepts parallel index-name, fixing-datetime, and value arrays and validates equal length before building the normalized map. `CALIBRATE.JOINTXCCY` keeps solver values in the existing two-column settings dictionary. + +- [ ] **Step 3: Implement result accessors** + +Expose `domesticBlock`, `foreignBlock`, `basisCurve`, `fxForwards`, `marketRates`, `modelRates`, `residuals`, `jacobian`, `parameterRanges`, and `residualRanges`. Unknown attributes list all accepted names in the error. + +- [ ] **Step 4: Regenerate and verify generated drift** + +Run: `cmake --build build --target dal_generate -j 4` + +Run: `cmake --build build --target dal_check_generated -j 4` + +Expected: generation and drift check succeed. + +- [ ] **Step 5: Build Excel on Windows** + +Run: `cmake --build build --config Release --target dal_excel` + +Expected: the add-in and all generated public functions compile successfully. + +- [ ] **Step 6: Commit the Excel unit** + +```bash +git add dal-excel/src/__curve_storable.hpp dal-excel/src/__curveprotocol.cpp dal-excel/src/__curveinstrument.cpp dal-excel/src/__xccycalibration.cpp dal-excel/auto dal-cpp/dal/auto +git commit -m "feat(excel): expose resettable and joint XCCY functions" +``` + +--- + +### Task 10: Add `xccy_perf` and Run Regression Benchmarks + +**Files:** +- Create: `dal-cpp/benchmarks/xccy_perf/CMakeLists.txt` +- Create: `dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp` +- Modify: `dal-cpp/benchmarks/CMakeLists.txt` + +**Interfaces:** +- Consumes: passive pricing, basis-only calibration, joint calibration, analytic/bumped options, and immutable snapshots. +- Produces: normalized precompute/price/basket rows and absolute calibration rows through `Bench::Print`. + +- [ ] **Step 1: Register an initially failing benchmark target** + +Add `xccy_perf` to `DAL_BENCHMARK_TARGETS` and create its `CMakeLists.txt` using the same DAL/AAD/thread link pattern as `curve_calibration_perf`. + +Run: `cmake --build build --target xccy_perf -j 4` + +Expected: build fails until `xccy_perf.cpp` is added. + +- [ ] **Step 2: Implement validated pricing workloads** + +Use 3 warmups and 10 repeats. Build 10Y quarterly fixed/resettable/MTM instruments, an in-progress MTM instrument with historical domestic-rate, foreign-rate, and FX fixings, and ten-instrument baskets. Normalize batched results to per-operation or per-instrument nanoseconds. Require finite, nonzero checksums before printing. + +- [ ] **Step 3: Implement validated calibration workloads** + +Use 15 XCCY instruments. Add basis-only exact analytic solve-only, analytic plus diagnostics, and bumped plus diagnostics cases. Add the same three cases for full three-block exact calibration plus one approximate analytic case. Before timing, run once and require every reported residual norm is within the configured tolerance. + +- [ ] **Step 4: Build and smoke-run the new benchmark** + +Run: `cmake --build build --config Release --target xccy_perf -j 4` + +Run: `build/dal-cpp/benchmarks/xccy_perf/xccy_perf` + +Expected: every required named row prints min/median/max/repeat columns and the process exits zero. + +- [ ] **Step 5: Establish the branch/baseline comparison** + +At execution time, use `superpowers:using-git-worktrees` to create a clean merge-base benchmark worktree. Build both baseline and branch in Release. Run paired interleaved samples, baseline then branch then branch then baseline, until each binary has at least 10 samples. Use build-tree binaries only and reduce each row to the best observed minimum. + +Report: + +```text +Benchmark | Baseline min | Branch min | Delta | Verdict | Samples/environment +``` + +Flag only deltas greater than 4%. For reset/MTM and three-block rows that do not exist at merge base, report absolute min/median/max and mark them “new baseline”. + +- [ ] **Step 6: Run the standard DAL regression set** + +Run the same paired protocol for `tape_perf`, `jacobian_perf`, `pde_perf`, `rng_perf`, `interp_perf`, `krylov_perf`, `banded_perf`, and `cholesky_perf`. + +Expected: no repeatable branch regression greater than 4%; otherwise stop packaging and investigate the responsible hot path. + +- [ ] **Step 7: Commit benchmark coverage** + +```bash +git add dal-cpp/benchmarks/CMakeLists.txt dal-cpp/benchmarks/xccy_perf/CMakeLists.txt dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp +git commit -m "perf(xccy): benchmark reset pricing and joint calibration" +``` + +--- + +### Task 11: Add Example, Documentation, Changelog, and Final Verification + +**Files:** +- Create: `dal-cpp/examples/xccy_mtm_calibration/CMakeLists.txt` +- Create: `dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp` +- Modify: `dal-cpp/examples/CMakeLists.txt` +- Modify: `docs/methodology/xccy_calibration.md` +- Modify: `docs/methodology/yield_curve_jacobian.md` +- Modify: `docs/public-api.md` +- Modify: `docs/README.md` +- Modify: `dal-python/README.md` +- Modify: `dal-excel/README.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: the final public C++, Python, and Excel surfaces. +- Produces: one compilable current-state example and reconciled documentation. + +- [ ] **Step 1: Add a compiling end-to-end example** + +The example must be 20–50 lines in its main call sequence and show: explicit fixing identities, one snapshot containing rate and FX values, an in-progress MTM swap, domestic/foreign/basis declarations, `CalibrateJointXccyMarket`, and Jacobian range output. Generate synthetic quotes from a known market so it runs without external data. + +- [ ] **Step 2: Build and run the example** + +Run: `cmake --build build --target xccy_mtm_calibration -j 4` + +Run: `build/dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration` + +Expected: the program reports converged domestic, foreign, and basis blocks; maximum residual below `1.0e-8`; and nonempty Jacobian layout. + +- [ ] **Step 3: Reconcile methodology and API docs** + +Document the exact notional formulas, reset/fixing timing, payment-date rule, generalized fixing snapshot, domestic-collateral restriction, staged versus joint calibration, parameter/residual layout, and analytic/bumped behavior. Keep historical migration detail out of methodology docs. + +- [ ] **Step 4: Add changelog entry** + +Record the new XCCY capability and the breaking replacement of the two public convention booleans by `XccyNotionalMode_`. Include compatibility of the old convenience builder and new Python/Excel functions. + +- [ ] **Step 5: Run targeted functional suites** + +Run: `bin/dal_cpp_tests --gtest_filter=FixingSnapshotTest.*:XccyPricingTest.*:XccyMarketTest.*:XccyBasisJacobianTest.*:XccyJointCalibrationTest.*:XccyJointJacobianTest.*:JointCalibrationTest.*:JointAnalyticJacobianTest.*` + +Run: `bin/dal_public_tests --gtest_filter=CurveProtocolTest.*:CurveInstrumentTest.*:XccyCalibrationTest.*` + +Run: `python -m pytest dal-python/tests/test_xccy_calibration.py dal-python/tests/test_xccy_resettable.py dal-python/tests/test_xccy_joint.py -q` + +Expected: every selected suite passes. + +- [ ] **Step 6: Run full build, tests, generation check, and benchmark smoke** + +Run: `bash ./build_linux.sh` + +Run: `cmake --build build --target dal_check_generated -j 4` + +Run: `(cd build && ctest --output-on-failure)` + +Run: `build/dal-cpp/benchmarks/xccy_perf/xccy_perf` + +Expected: full build and CTest pass, no generated drift exists, and the benchmark exits zero. + +- [ ] **Step 7: Run formatting and diff checks** + +Format only changed C++ files with the repository `.clang-format`, then run: + +Run: `git diff --check` + +Expected: no whitespace errors and no unrelated user changes in the diff. + +- [ ] **Step 8: Request DAL review and performance gate** + +Use `dal-reviewer` for correctness, style, bindings, generated files, docs, and merge readiness. Use `dal-performancer` to publish the paired regression table with sample counts and environmental notes. Resolve every blocking or significant finding and rerun the affected verification command. + +- [ ] **Step 9: Commit documentation and example** + +```bash +git add dal-cpp/examples/xccy_mtm_calibration dal-cpp/examples/CMakeLists.txt docs/methodology/xccy_calibration.md docs/methodology/yield_curve_jacobian.md docs/public-api.md docs/README.md dal-python/README.md dal-excel/README.md CHANGELOG.md +git commit -m "docs(xccy): document MTM valuation and joint calibration" +``` + +--- + +## Completion Evidence + +Before declaring the feature complete, retain these artifacts in the implementation handoff: + +- targeted and full test command outputs; +- generated-source drift check output; +- analytic-versus-central-difference Jacobian maximum absolute difference; +- joint synthetic recovery residuals and parameter block layout; +- the `xccy_perf` absolute result table; +- paired branch/baseline tables for existing fixed XCCY cases and the eight standard DAL benchmark gates; +- reviewer verdict and any remaining nonblocking caveats. diff --git a/docs/superpowers/specs/2026-07-13-xccy-in-progress-rate-fixing-addendum.md b/docs/superpowers/specs/2026-07-13-xccy-in-progress-rate-fixing-addendum.md new file mode 100644 index 000000000..34239aea6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-xccy-in-progress-rate-fixing-addendum.md @@ -0,0 +1,44 @@ +# XCCY In-Progress Trades — Rate Fixing Addendum + +This addendum is part of +`docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-design.md` and is normative +for the implementation plan. + +## Generalized Snapshot + +Use one immutable `MarketFixingSnapshot_`, not an FX-only snapshot. Its canonical +mapping is `index name -> DateTime_ -> positive finite value` and it contains: + +- domestic floating-rate fixings; +- foreign floating-rate fixings; +- FX reset fixings. + +The XCCY swap configuration carries explicit canonical domestic and foreign rate-index +names. A resettable or mark-to-market configuration also identifies the canonical FX +index through its currency pair. No canonical name may be inferred from tenor alone. + +## Coupon Resolution + +For a coupon whose payment date is on or after the valuation date: + +- `rateFixingTime < valuationTime`: a historical rate fixing is mandatory; +- `rateFixingTime == valuationTime`: use a supplied fixing when present, otherwise use + the active forecast curve; +- `rateFixingTime > valuationTime`: use the active forecast curve. + +Historical rate fixings are passive constants in AAD evaluation. Future or today-unfixed +rates retain the active scalar type and curve sensitivities. Coupons whose payment date is +before the valuation date are removed before fixing resolution and therefore do not +require historical fixing data. + +## Validation and Tests + +- Reject a missing canonical rate-index name when an unpaid coupon has a historical + fixing time. +- Reject missing, nonpositive, NaN, or infinite historical rate fixings with instrument, + leg, index name, and fixing timestamp in the message. +- Test a started trade with a past-fixed/future-paid coupon on each leg. +- Verify historical rate-fixing rows have zero curve sensitivity while future coupon rows + retain domestic or foreign forecast-curve sensitivity. +- The in-progress `xccy_perf` workload must include both historical rate fixings and + historical FX reset fixings in one snapshot. diff --git a/docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-benchmark-addendum.md b/docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-benchmark-addendum.md new file mode 100644 index 000000000..99198e2d7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-benchmark-addendum.md @@ -0,0 +1,79 @@ +# XCCY Resettable/MTM — Benchmark Design Addendum + +This addendum is part of +`docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-design.md` and is normative +for the implementation plan. + +## Benchmark Target + +Add `dal-cpp/benchmarks/xccy_perf/` and register `xccy_perf` in +`dal-cpp/benchmarks/CMakeLists.txt`. A dedicated target is required because +`ycinstrument_perf` does not price XCCY instruments and `curve_calibration_perf` +does not exercise FX-reset resolution, basis calibration, or the three-block joint +solver. + +Every timed workload must first validate finite, nonzero checksums and the expected +repricing tolerance. Batches must be large enough that timer overhead is immaterial. + +## Required Workloads + +### Instrument Planning and Pricing + +- 10Y quarterly `FIXED`, `RESETTABLE`, and `MARK_TO_MARKET` precompute operations. +- Passive price operations for the same three all-future-reset instruments. +- An in-progress 10Y `MARK_TO_MARKET` price containing both historical and future resets + resolved from one immutable fixing snapshot. +- Ten-instrument pricing baskets for each notional mode. + +Report per-operation or per-instrument timings after batch normalization so the three +notional modes are directly comparable. + +### Calibration + +- Basis-only exact calibration with 15 XCCY instruments: + - analytic solve-only; + - analytic plus diagnostics/Jacobian; + - bumped plus diagnostics/Jacobian. +- Full domestic/foreign/basis exact joint calibration: + - analytic solve-only; + - analytic plus diagnostics/Jacobian; + - bumped plus diagnostics/Jacobian. +- Full three-block approximate joint calibration to cover regularized residual + evaluation. + +The analytic and bumped variants must use identical instruments, knots, initial guesses, +solver tolerances, and repricing acceptance. Timing must not compare workloads that do +different numerical work. + +## Regression Protocol + +- Use the merge base with `master` as the baseline unless another baseline is explicitly + selected. +- Build baseline and branch in Release with benchmarks enabled. +- Run binaries from their respective build trees, never from stale installed `bin/` + locations. +- Use paired, interleaved baseline/branch runs with at least 10 samples per binary. +- Reduce to best-of-N minima and treat a delta as actionable only when it exceeds 4%, the + conservative end of DAL's calibrated 2–4% benchmark noise floor. +- Record sample count, environment quietness, min/median/max timings, workload size, and + any inconclusive result. + +## Acceptance Criteria + +- [ ] Existing fixed-notional XCCY pricing and basis-only calibration do not regress by + more than 4% relative to the merge-base implementation. +- [ ] The standard eight DAL gates (`tape_perf`, `jacobian_perf`, `pde_perf`, `rng_perf`, + `interp_perf`, `krylov_perf`, `banded_perf`, and `cholesky_perf`) have no + greater-than-4% branch regression under the same paired protocol. +- [ ] All new `xccy_perf` workloads publish absolute min/median/max results and normalized + per-operation metrics. +- [ ] The first merged reset/MTM and three-block measurements establish the future + regression baseline; they are not compared with nonexistent pre-feature cases. +- [ ] Analytic and bumped calibration cases meet equivalent repricing tolerances before + their timing ratio is reported. + +## Delivery Gate + +Run functional and Jacobian tests before benchmarks. Performance measurement is a +post-correctness gate and must be completed before Python/Excel packaging is declared +finished, so late binding changes cannot hide a core regression. diff --git a/docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-design.md b/docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-design.md new file mode 100644 index 000000000..7dcdb7e8d --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-design.md @@ -0,0 +1,356 @@ +# XCCY Resettable/MTM Notional and In-Progress Trades — Design + +## Source + +- User request and design decisions confirmed on 2026-07-13. +- Related methodology: `docs/methodology/xccy_calibration.md` and `docs/methodology/yield_curve_jacobian.md`. + +## Problem Statement + +The current cross-currency swap implementation supports only fixed domestic and foreign +notionals and rejects valuation after the swap start. It also reads no historical FX +fixings and calibrates only a basis curve over frozen domestic and foreign curve blocks. +The extension must support period-by-period resettable and mark-to-market notionals, +deterministic valuation of in-progress trades, staged basis calibration, and a single +joint solve of domestic, foreign, and basis curves with an analytic residual Jacobian. + +## Goals + +- Define unambiguous notional, FX fixing, reset timing, historical cashflow, and notional + exchange rules. +- Use one pricing formula for passive `double` valuation and active AAD valuation. +- Preserve the existing fixed-notional public builders and basis-only calibration entry + point. +- Add public C++, Python, and Excel surfaces for reset configuration, fixing snapshots, + and three-block joint calibration. +- Make missing or contradictory fixing data fail deterministically before solving. +- Expose enough Jacobian layout metadata that callers do not infer parameter or residual + block positions. + +## Non-Goals + +- Third-currency collateral, collateral switching, or cheapest-to-deliver collateral. +- Resetting the foreign leg. +- Independent reset frequency, intraperiod resets, or reset dates not aligned with the + domestic coupon accrual starts. +- Intraday settlement status for cashflows or separate clean/accrued valuation. +- A general-purpose multi-currency cashflow graph. + +## Product Semantics + +### Notional Modes + +Replace the existing `resettableNotional_` and `markToMarketNotional_` booleans with one +generated enum, `XccyNotionalMode_`: + +- `FIXED`: both notionals remain fixed for the full trade. +- `RESETTABLE`: the foreign notional remains fixed; the domestic coupon notional is reset + for each domestic accrual period. No intermediate notional-difference cashflow is paid. +- `MARK_TO_MARKET`: uses the same reset domestic coupon notionals as `RESETTABLE` and also + exchanges each change in domestic notional on the reset effective date. + +The first domestic coupon period uses the builder-supplied domestic notional. Starting +with the second domestic coupon period, + +```text +domesticNotional[i] = foreignNotional * FX(fixingTime[i]) +``` + +where FX is quoted in domestic currency units per one foreign currency unit. For +`MARK_TO_MARKET`, the domestic notional exchange at the reset effective date is + +```text +domesticNotional[i] - domesticNotional[i - 1] +``` + +with the same receive/pay orientation as the domestic final-notional exchange. The +pricing kernel applies the overall leg signs consistently when forming the par spread. + +### Reset Timing + +- The domestic leg is the resetting leg; the foreign leg notional is fixed. +- Each reset effective date is the corresponding domestic coupon accrual start. +- The FX fixing date is obtained by moving backward by `fixingLag_` business days on + `fixingHolidays_`, then applying `fixingConvention_`. +- The fixing timestamp combines that date with configured `fixingHour_` and + `fixingMinute_`. +- The first period uses the supplied initial domestic notional rather than recomputing it + from a fixing. +- Domestic and foreign coupon frequencies may differ. The domestic reset schedule must + align exactly with domestic coupon accrual starts. + +### Initial, Intermediate, and Final Notional Exchanges + +- `initialNotionalExchange_` controls the builder-supplied initial domestic and foreign + exchanges at the trade start for every notional mode. +- Only `MARK_TO_MARKET` generates intermediate domestic notional-difference exchanges. +- `finalNotionalExchange_` exchanges the last outstanding domestic notional and the fixed + foreign notional at maturity. +- Historical notional exchanges follow the same payment-date filtering rule as coupons. + +## Fixing Data Contract + +### Immutable Snapshot + +`FxFixingSnapshot_` is an immutable mapping from normalized FX index name and `DateTime_` +to a positive finite value. Calibration and valuation consume a snapshot, never live +global fixing state. + +Public builders accept an optional snapshot. If none is supplied, the valuation entry +point copies all required FX fixings from the existing process-wide fixing store once, +before precomputation or solver evaluation. Subsequent global-store changes cannot alter +that valuation or calibration. + +### Naming and Orientation + +- Canonical keys retain the existing `Index::Fx_` naming convention: + `FX[foreign/domestic]` for a domestic-per-foreign quote. +- A reverse fixing may be used by taking its reciprocal. +- If both orientations exist at the same timestamp, their product must equal one within + a fixed numerical tolerance; otherwise snapshot construction fails. +- Zero, negative, NaN, and infinite fixing values are rejected. + +### Historical and Future Resolution + +- `fixingTime < valuationTime`: a historical fixing is mandatory; missing data is an + error and cannot be replaced with a curve-implied value. +- `fixingTime == valuationTime`: use a supplied fixing if present; otherwise treat the + reset as unfixed and use the current FX forward. +- `fixingTime > valuationTime`: use the current basis-adjusted FX forward. + +Historical resolved values are passive constants. Today-unfixed and future values retain +the pricing scalar type so their derivatives flow through domestic, foreign, and basis +curve parameters. + +## Historical Cashflow Rules + +The first version uses date-level settlement because the existing XCCY market exposes a +valuation date but no intraday payment-settlement state. + +- `paymentDate < valuationDate`: the cashflow is settled and excluded from PV, par + spread, residuals, and Jacobians. +- `paymentDate == valuationDate`: the cashflow is unsettled, included with discount + factor one. +- A coupon fixed in the past but paid on or after the valuation date uses its historical + fixing and remains in PV. +- Historical initial and MTM notional-adjustment cashflows are excluded. +- A future final exchange uses the last outstanding domestic notional and the fixed + foreign notional. +- Valuation is dirty/full PV; no separate accrued-interest output is introduced. + +A matured trade may be represented and has zero remaining PV. It cannot be used as a +calibration instrument when it has no remaining spread annuity. + +## Collateral and Currency Scope + +- The pricing currency and collateral currency are both the currency pair's domestic + currency. +- `CrossCurrencyMarket_` and both calibration specifications record the collateral + currency explicitly. +- The first implementation rejects `collateralCurrency != pair.domestic_`. +- Foreign cashflows are converted to domestic currency with the basis-adjusted FX forward + generated by the same market view used to discount and forecast them. + +This explicit restriction prevents a missing third-currency curve from being silently +substituted by an OIS fallback. + +## Internal Architecture + +### `XccyCashflowPlan_` + +An immutable plan created by `CrossCurrencySwap_::Precompute()` contains both legs' +coupon periods, accrual factors, fixing and payment dates, domestic reset dates, all +potential notional exchanges, notional mode, and spread-leg selection. It describes +events but performs no curve or fixing lookup. + +### `FxResetResolver_` + +The resolver takes a valuation time, fixing snapshot, and current typed XCCY market view. +It produces period domestic notionals and MTM differences using historical constants or +active FX forwards according to the fixing contract. + +### `XccyPricingKernel` + +One templated kernel computes coupon PVs, initial/intermediate/final notional PVs, +foreign-to-domestic conversion, annuities, par spreads, and calibration residuals. +`T_ = double` supports passive pricing and manual cashflow tests; +`T_ = AAD::Number_` supports analytic Jacobians. Historical cashflows are filtered before +summation. + +### Joint Residual Function + +The joint unknown vector has the stable partition + +```text +[domestic curve parameters | foreign curve parameters | basis parameters] +``` + +Each residual evaluation builds all three typed curve views once and evaluates a stacked +residual vector containing domestic instruments, foreign instruments, and XCCY +instruments. It never calls a nested calibration routine. Future resets therefore refer +to the active curves from the current solver iteration, eliminating procedural curve +cycles while retaining the mathematical coupling in one nonlinear system. + +The existing basis-only calibration uses the same pricing kernel with frozen domestic and +foreign blocks and only the basis parameter partition active. + +## Public API + +### Instrument Configuration + +The new public shape is a configuration object rather than more positional arguments: + +```cpp +struct FxResetConvention_ { + XccyNotionalMode_ mode_ = XccyNotionalMode_::Value_::FIXED; + int fixingLag_ = 0; + Holidays_ fixingHolidays_; + BizDayConvention_ fixingConvention_ = BizDayConvention_("Preceding"); + int fixingHour_ = 0; + int fixingMinute_ = 0; +}; + +struct CrossCurrencySwapConfig_ { + CurrencyPair_ pair_; + double domesticNotional_ = 100.0; + double foreignNotional_ = 100.0; + CrossCurrencyConvention_ convention_; + FxResetConvention_ reset_; +}; +``` + +The new overload is: + +```cpp +Handle_ CrossCurrencySwapNew(const Date_& tradeDate, + const Date_& start, + const Date_& maturity, + double marketRate, + const CrossCurrencySwapConfig_& config); +``` + +The current long overload remains source compatible and delegates to a `FIXED` config. +`CrossCurrencySwapConfigBuilder_` supplies a binding-friendly construction path. + +### Basis-Only Calibration + +`CrossCurrencyCalibrationSpecBuilder_` adds `valuationTime_`, +`collateralCurrency_`, and optional `fxFixings_`. The existing `today_` field remains: +when `valuationTime_` is not set, it maps to midnight on `today_`. + +### Three-Block Joint Calibration + +`JointXccyCalibrationSpecBuilder_` contains: + +- valuation time, currency pair, domestic collateral currency, and FX spot; +- domestic and foreign `JointCurveDeclaration_` collections; +- one basis curve declaration; +- domestic, foreign, and XCCY instrument collections; +- optional FX fixing snapshot; +- one shared `CurveSolverOptions_`. + +The implementation factors the existing joint curve declaration and solver machinery into +reusable internal helpers rather than duplicating solver control fields. + +The joint result carries solved domestic and foreign `CurveBlock_` objects, basis and FX +forward curves, diagnostics grouped by residual class, the stacked residual Jacobian, and +explicit parameter/residual block ranges. + +### Python and Excel + +- Python mirrors the enum, reset convention, swap config/builder, fixing snapshot, joint + builder, result, diagnostics, and range metadata. Snapshot construction accepts a + nested `{index_name: {datetime: value}}` mapping. +- Excel retains the existing `CROSSCURRENCYSWAP.NEW`. New worksheet functions create an + FX reset convention/config handle and build a configured XCCY swap without changing the + old registration. Another function creates an immutable FX fixing snapshot from index, + datetime, and value ranges. +- `CALIBRATE.JOINTXCCY` accepts curve and instrument handles plus a settings dictionary. + Result accessors expose curves, diagnostics, Jacobian, and block ranges. +- Machinist regenerates both core and Excel enum artifacts. + +## Error Contract + +Errors name the instrument where applicable and include the offending currency pair, +collateral currency, fixing timestamp, or curve slot. Validation rejects: + +- unsupported collateral currency; +- missing historical fixing; +- inconsistent direct/reverse fixings; +- nonpositive or nonfinite fixing/notional/spot values; +- invalid fixing hour/minute or negative fixing lag; +- reset dates not aligned with domestic coupon accrual starts; +- currency-pair, curve-block, basis-orientation, or valuation-date mismatch; +- a calibration instrument with no remaining spread annuity; +- an analytic Jacobian request for an ineligible parameterization, without silent fallback. + +## Test Strategy and Acceptance Criteria + +### Cashflow and Fixing Unit Tests + +- [ ] `FIXED`, `RESETTABLE`, and `MARK_TO_MARKET` plans contain the expected coupon and + notional events. +- [ ] Fixing lag, holidays, business-day adjustment, and a stub schedule produce exact + expected dates. +- [ ] Direct, reverse, duplicate-consistent, duplicate-inconsistent, missing, nonpositive, + and nonfinite fixings follow the contract. +- [ ] Past, equal-to-valuation, and future fixing timestamps select the intended source. +- [ ] Changing the global fixing store after snapshot creation cannot change valuation. + +### Pricing and In-Progress Trade Tests + +- [ ] Existing fixed-notional pricing results remain unchanged. +- [ ] Domestic notionals reset period by period to fixed foreign notional times FX. +- [ ] A two-period MTM swap matches a hand calculation of every coupon and notional + exchange cashflow. +- [ ] An in-progress swap excludes settled payments, retains past-fixed/future-paid + coupons, and includes valuation-date payments at discount factor one. +- [ ] A matured trade has zero PV and is rejected as a calibration instrument. + +### Calibration and Jacobian Tests + +- [ ] Existing basis-only calibration continues to converge and reprice. +- [ ] A synthetic joint system recovers domestic, foreign, and basis curve parameters in + one solve and reprices all three instrument groups within tolerance. +- [ ] Exact and approximate solve modes are both covered. +- [ ] The analytic stacked Jacobian matches a central-difference Jacobian element by + element across domestic, foreign, and basis parameter blocks. +- [ ] Historical resets have no fixing sensitivity; future resets have the expected curve + sensitivities. +- [ ] Quote-to-parameter effective inverse results agree with explicit quote bump and + recalibration. +- [ ] AAD tape state is cleared before and after each AAD test. + +### Binding Tests + +- [ ] Existing C++ and Python fixed-notional calls remain valid. +- [ ] C++/Python builders round-trip every new field. +- [ ] Python constructs a fixing snapshot, MTM swap, joint specification, and reads the + Jacobian layout. +- [ ] Excel generated sources compile and smoke tests cover configured swap construction, + fixing snapshot input, and joint calibration/result accessors. + +## Delivery Sequence + +1. Add the enum, reset/config types, immutable fixing snapshot, and validation. +2. Add cashflow planning, resolver, and passive/AAD pricing kernel with unit tests. +3. Integrate in-progress valuation and basis-only calibration without changing legacy + fixed-notional results. +4. Add the single-system domestic/foreign/basis joint solve and Jacobian diagnostics. +5. Add public C++ builders, Python bindings, Excel functions/generated artifacts, examples, + methodology documentation, public API documentation, and changelog entry. + +Each stage must pass its targeted tests plus existing XCCY and joint-calibration regression +tests before the next stage begins. + +## Risks and Mitigations + +- **Fixing data contract:** immutable normalized snapshots isolate valuation from mutable + process-wide state. +- **Collateral convention:** an explicit domestic-currency-only check prevents accidental + third-currency curve fallback. +- **Curve cycles:** one stacked residual function and one unknown vector replace nested + calibration calls. +- **Passive/AAD drift:** one templated pricing kernel owns all formulas. +- **API growth:** config/builders contain optional behavior while legacy overloads remain. +- **Scope growth:** nonaligned resets and third-currency collateral are explicit non-goals. From 9904249e1c4de7eaec83bd64fadf96beaa8c7240 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Mon, 13 Jul 2026 03:59:28 +0800 Subject: [PATCH 02/38] docs(xccy): fix public test command paths Use the dal_public_tests executable from the CMake build tree so the implementation plan passes documentation integrity checks. --- docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md b/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md index ca5a58beb..582b1cac1 100644 --- a/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md +++ b/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md @@ -645,7 +645,7 @@ Handle_ CrossCurrencySwapNew(const Date_& tradeDate, - [ ] **Step 4: Run public tests** -Run: `bin/dal_public_tests --gtest_filter=CurveProtocolTest.*:CurveInstrumentTest.TestCrossCurrency*:XccyCalibrationTest.*` +Run: `build/dal-public/dal_public_tests --gtest_filter=CurveProtocolTest.*:CurveInstrumentTest.TestCrossCurrency*:XccyCalibrationTest.*` Expected: old and new instrument builders, snapshot, staged builder, joint builder, and result layout pass. @@ -870,7 +870,7 @@ Record the new XCCY capability and the breaking replacement of the two public co Run: `bin/dal_cpp_tests --gtest_filter=FixingSnapshotTest.*:XccyPricingTest.*:XccyMarketTest.*:XccyBasisJacobianTest.*:XccyJointCalibrationTest.*:XccyJointJacobianTest.*:JointCalibrationTest.*:JointAnalyticJacobianTest.*` -Run: `bin/dal_public_tests --gtest_filter=CurveProtocolTest.*:CurveInstrumentTest.*:XccyCalibrationTest.*` +Run: `build/dal-public/dal_public_tests --gtest_filter=CurveProtocolTest.*:CurveInstrumentTest.*:XccyCalibrationTest.*` Run: `python -m pytest dal-python/tests/test_xccy_calibration.py dal-python/tests/test_xccy_resettable.py dal-python/tests/test_xccy_joint.py -q` From 2f913d54b14f3280ed2144b48bb4c40a61d706ae Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Mon, 13 Jul 2026 11:08:53 +0800 Subject: [PATCH 03/38] perf(xccy): establish fixed pricing baseline --- dal-cpp/benchmarks/CMakeLists.txt | 1 + dal-cpp/benchmarks/xccy_perf/CMakeLists.txt | 21 ++ dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp | 262 ++++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 dal-cpp/benchmarks/xccy_perf/CMakeLists.txt create mode 100644 dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp diff --git a/dal-cpp/benchmarks/CMakeLists.txt b/dal-cpp/benchmarks/CMakeLists.txt index c69c8d6d1..e39dd9413 100644 --- a/dal-cpp/benchmarks/CMakeLists.txt +++ b/dal-cpp/benchmarks/CMakeLists.txt @@ -13,6 +13,7 @@ set(DAL_BENCHMARK_TARGETS black_perf iv_brent_perf script_mc_perf + xccy_perf ycinstrument_perf curve_calibration_perf threadpool_perf diff --git a/dal-cpp/benchmarks/xccy_perf/CMakeLists.txt b/dal-cpp/benchmarks/xccy_perf/CMakeLists.txt new file mode 100644 index 000000000..74d9b37b7 --- /dev/null +++ b/dal-cpp/benchmarks/xccy_perf/CMakeLists.txt @@ -0,0 +1,21 @@ +file(GLOB_RECURSE XCCY_PERF_FILES CONFIGURE_DEPENDS "*.hpp" "*.cpp") + +add_executable(xccy_perf ${XCCY_PERF_FILES}) + +target_link_libraries(xccy_perf dal_library) + +if(DAL_USE_XAD_AAD) + target_link_libraries(xccy_perf XAD::xad) +elseif(DAL_USE_CODIPACK_AAD) + target_link_libraries(xccy_perf CoDiPack) +elseif(DAL_USE_ADEPT_AAD) + target_link_libraries(xccy_perf adept) +endif() + +if(NOT MSVC) + target_link_libraries(xccy_perf pthread) +endif() + +install(TARGETS xccy_perf + RUNTIME DESTINATION bin + PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE) diff --git a/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp b/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp new file mode 100644 index 000000000..ff3363399 --- /dev/null +++ b/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp @@ -0,0 +1,262 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Dal; + +namespace { + constexpr int kWarmups = 3; + constexpr int kRepeats = 10; + constexpr int kPrecomputeBatch = 2000; + constexpr int kPriceBatch = 2000; + constexpr int kBasketPasses = 250; + constexpr double kFxSpot = 1.10; + + using SwapHandle_ = Handle_; + using RateHandle_ = Handle_; + + struct Fixture_ { + Date_ today_; + Handle_ domesticBlock_; + Handle_ foreignBlock_; + Vector_ basisKnots_; + CrossCurrencyConvention_ convention_; + }; + + Bench::Result_ Normalize(const Bench::Result_& raw, const std::string& name, int64_t divisor) { + REQUIRE(divisor > 0, "XCCY benchmark normalization divisor must be positive"); + return Bench::Result_{name, raw.medianNs / divisor, raw.minNs / divisor, raw.maxNs / divisor, raw.repeats}; + } + + Handle_ MakeFlatCurve(const String_& name, const String_& ccy, const Date_& today, double rate) { + const Vector_ knots = { + Date::AddMonths(today, 6), Date::AddMonths(today, 12), Date::AddMonths(today, 24), + Date::AddMonths(today, 60), Date::AddMonths(today, 120), Date::AddMonths(today, 240), + }; + const Vector_<> values(knots.size(), rate); + return Handle_(NewDiscountPWLF(name, ccy, PiecewiseLinear_(knots, values, values))); + } + + Handle_ MakeBlock(const String_& name, const String_& ccy, const Date_& today, double rate) { + return Handle_(new CurveBlock_(MakeFlatCurve(name, ccy, today, rate))); + } + + RateIndexConvention_ MakeIndex() { + RateIndexConvention_ result; + result.spotLag_ = 0; + result.fixingLag_ = 0; + result.useProjectionCurve_ = true; + result.forecastTenor_ = PeriodLength_("3M"); + result.dayBasis_ = DayBasis_("ACT_365F"); + result.businessDayConvention_ = BizDayConvention_("Unadjusted"); + result.fixingHolidays_ = Holidays::None(); + result.accrualHolidays_ = Holidays::None(); + result.collateral_ = CollateralType_(CollateralType_::Value_::OIS); + return result; + } + + RateLegConvention_ MakeLeg() { + RateLegConvention_ result; + result.paymentLag_ = 0; + result.paymentFrequency_ = PeriodLength_("3M"); + result.dayBasis_ = DayBasis_("ACT_365F"); + result.businessDayConvention_ = BizDayConvention_("Unadjusted"); + result.paymentConvention_ = BizDayConvention_("Unadjusted"); + result.accrualHolidays_ = Holidays::None(); + result.paymentHolidays_ = Holidays::None(); + return result; + } + + Fixture_ MakeFixture() { + Fixture_ result; + result.today_ = Date_(2024, 1, 15); + result.domesticBlock_ = MakeBlock("xccy_perf_usd", "USD", result.today_, 0.02); + result.foreignBlock_ = MakeBlock("xccy_perf_eur", "EUR", result.today_, 0.01); + result.basisKnots_ = { + Date::AddMonths(result.today_, 6), Date::AddMonths(result.today_, 12), Date::AddMonths(result.today_, 24), + Date::AddMonths(result.today_, 60), Date::AddMonths(result.today_, 120), + }; + result.convention_.initialNotionalExchange_ = true; + result.convention_.finalNotionalExchange_ = true; + result.convention_.spreadOnForeignLeg_ = true; + result.convention_.domesticIndex_ = MakeIndex(); + result.convention_.domesticLeg_ = MakeLeg(); + result.convention_.foreignIndex_ = MakeIndex(); + result.convention_.foreignLeg_ = MakeLeg(); + return result; + } + + CrossCurrencyMarket_ MakeMarket(const Fixture_& fixture, double basisRate) { + CrossCurrencyMarket_ result(fixture.domesticBlock_, fixture.foreignBlock_, kFxSpot); + if (basisRate != 0.0) { + const Vector_<> values(fixture.basisKnots_.size(), basisRate); + result.SetBasisCurve(Handle_(NewDiscountPWC("xccy_perf_basis", "USD", PiecewiseConstant_(fixture.basisKnots_, values)))); + } + return result; + } + + SwapHandle_ MakeSwap(const Fixture_& fixture, int maturityMonths, double marketRate = 0.0) { + return SwapHandle_(new CrossCurrencySwap_(fixture.today_, fixture.today_, Date::AddMonths(fixture.today_, maturityMonths), marketRate, + CurrencyPair_(Ccy_("USD"), Ccy_("EUR")), 110.0, 100.0, fixture.convention_)); + } + + std::vector MakePricingBasket(const Fixture_& fixture) { + std::vector result; + result.reserve(10); + for (int year = 1; year <= 10; ++year) + result.push_back(MakeSwap(fixture, 12 * year)); + return result; + } + + std::vector PrecomputeAll(const std::vector& instruments) { + std::vector result; + result.reserve(instruments.size()); + for (const auto& instrument : instruments) + result.push_back(instrument->Precompute()); + return result; + } + + CrossCurrencyCalibrationSpec_ MakeCalibrationSpec(const Fixture_& fixture) { + const CrossCurrencyMarket_ quoteMarket = MakeMarket(fixture, 0.0020); + const Vector_ maturities = {6, 12, 18, 24, 30, 36, 42, 48, 54, 60, 72, 84, 96, 108, 120}; + + CrossCurrencyCalibrationSpec_ result; + result.today_ = fixture.today_; + result.basisPair_ = CurrencyPair_(Ccy_("USD"), Ccy_("EUR")); + result.domesticCurveBlock_ = fixture.domesticBlock_; + result.foreignCurveBlock_ = fixture.foreignBlock_; + result.fxSpot_ = kFxSpot; + result.knotDates_ = fixture.basisKnots_; + result.tolerance_ = 1.0e-8; + result.fitTolerance_ = 1.0e-7; + result.initialGuess_ = 0.0; + result.instruments_.reserve(maturities.size()); + for (const int maturity : maturities) { + const SwapHandle_ prototype = MakeSwap(fixture, maturity); + const double quote = (*prototype->Precompute())(quoteMarket); + result.instruments_.push_back(MakeSwap(fixture, maturity, quote)); + } + return result; + } + + void ValidatePricing(const RateHandle_& fixedRate, const std::vector& basket, const CrossCurrencyMarket_& market) { + const double fixedValue = (*fixedRate)(market); + REQUIRE(std::isfinite(fixedValue) && std::fabs(fixedValue) > 0.0, "Fixed XCCY benchmark requires a finite, non-zero 10Y price"); + REQUIRE(basket.size() == 10, "Fixed XCCY benchmark requires exactly ten basket instruments"); + double checksum = 0.0; + for (const auto& rate : basket) { + const double value = (*rate)(market); + REQUIRE(std::isfinite(value), "Fixed XCCY basket requires finite prices"); + checksum += value; + } + REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "Fixed XCCY basket checksum must be finite and non-zero"); + } + + void ValidateCalibration(const CrossCurrencyCalibrationSpec_& spec) { + const auto result = CalibrateCrossCurrencyMarket(spec); + REQUIRE(result.diagnostics_.maxAbsResidual_ <= spec.tolerance_, + "Basis-only XCCY calibration benchmark must reprice inside its configured tolerance"); + REQUIRE(result.diagnostics_.residuals_.size() == spec.instruments_.size(), + "Basis-only XCCY calibration benchmark must report every instrument residual"); + } + + void RunPrecompute(const SwapHandle_& fixedSwap) { + RateHandle_ sink = fixedSwap->Precompute(); + const auto raw = Bench::Run( + "XCCY fixed 10Y PRECOMPUTE", + [&]() { + for (int i = 0; i < kPrecomputeBatch; ++i) + sink = fixedSwap->Precompute(); + }, + kWarmups, kRepeats); + Bench::DoNotOptimize(sink.get()); + REQUIRE(sink, "Fixed XCCY PRECOMPUTE sink must retain a rate handle"); + Bench::Print(Normalize(raw, "XCCY fixed 10Y PRECOMPUTE / operation", kPrecomputeBatch)); + } + + void RunPrice(const RateHandle_& fixedRate, const CrossCurrencyMarket_& market) { + double checksum = 0.0; + const auto raw = Bench::Run( + "XCCY fixed 10Y PRICE", + [&]() { + for (int i = 0; i < kPriceBatch; ++i) + checksum += (*fixedRate)(market); + }, + kWarmups, kRepeats); + Bench::DoNotOptimize(&checksum); + REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "Fixed XCCY PRICE checksum must be finite and non-zero"); + Bench::Print(Normalize(raw, "XCCY fixed 10Y PRICE / operation", kPriceBatch)); + } + + void RunBasket(const std::vector& basket, const CrossCurrencyMarket_& market) { + double checksum = 0.0; + const auto raw = Bench::Run( + "XCCY fixed 10-instrument basket", + [&]() { + for (int pass = 0; pass < kBasketPasses; ++pass) + for (const auto& rate : basket) + checksum += (*rate)(market); + }, + kWarmups, kRepeats); + Bench::DoNotOptimize(&checksum); + REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "Fixed XCCY BASKET checksum must be finite and non-zero"); + Bench::Print(Normalize(raw, "XCCY fixed 10-instrument BASKET / pass", kBasketPasses)); + Bench::Print(Normalize(raw, "XCCY fixed 10-instrument PER-INSTRUMENT", kBasketPasses * static_cast(basket.size()))); + } + + void RunCalibration(const CrossCurrencyCalibrationSpec_& spec) { + double checksum = 0.0; + const auto result = Bench::Run( + "XCCY basis-only CALIBRATION (15 instruments, 5 knots)", + [&]() { + const auto calibration = CalibrateCrossCurrencyMarket(spec); + checksum += calibration.diagnostics_.maxAbsResidual_; + checksum += calibration.fxForwardCurve_.forwards_.back(); + }, + 1, kRepeats); + Bench::DoNotOptimize(&checksum); + REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "Basis-only XCCY CALIBRATION checksum must be finite and non-zero"); + Bench::Print(result); + } +} // namespace + +int main() { + RegisterAll_::Init(); + const Fixture_ fixture = MakeFixture(); + XGLOBAL::SetEvaluationDate(fixture.today_); + + const CrossCurrencyMarket_ quoteMarket = MakeMarket(fixture, 0.0020); + const SwapHandle_ fixedSwap = MakeSwap(fixture, 120); + const RateHandle_ fixedRate = fixedSwap->Precompute(); + const std::vector basket = PrecomputeAll(MakePricingBasket(fixture)); + const CrossCurrencyCalibrationSpec_ calibrationSpec = MakeCalibrationSpec(fixture); + + ValidatePricing(fixedRate, basket, quoteMarket); + ValidateCalibration(calibrationSpec); + + Bench::PrintHeader(); + RunPrecompute(fixedSwap); + RunPrice(fixedRate, quoteMarket); + RunBasket(basket, quoteMarket); + RunCalibration(calibrationSpec); + return 0; +} From dbf26772054094f9349a7d6a434ccf5c7dc17d58 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Mon, 13 Jul 2026 11:35:02 +0800 Subject: [PATCH 04/38] feat(xccy): define notional modes and reset config --- dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp | 1 + dal-cpp/dal/auto/MG_BizDayConvention_enum.inc | 6 ++ dal-cpp/dal/auto/MG_XccyNotionalMode_enum.hpp | 37 ++++++++ dal-cpp/dal/auto/MG_XccyNotionalMode_enum.inc | 67 ++++++++++++++ dal-cpp/dal/auto/java/BizDayConvention.java | 1 + dal-cpp/dal/auto/java/XccyNotionalMode.java | 13 +++ dal-cpp/dal/curve/xccyinstrument.cpp | 91 +++++++++++++++---- dal-cpp/dal/curve/xccyinstrument.hpp | 38 +++++++- dal-cpp/dal/curve/xccynotionalmode.hpp | 18 ++++ dal-cpp/dal/protocol/rateconvention.hpp | 2 - dal-cpp/dal/time/holidays.cpp | 2 + dal-cpp/dal/time/schedules.hpp | 2 + dal-cpp/tests/currency/test_currencydata.cpp | 8 +- dal-cpp/tests/curve/test_xccymarket.cpp | 28 +++--- dal-cpp/tests/time/test_schedules.cpp | 9 +- 15 files changed, 273 insertions(+), 50 deletions(-) create mode 100644 dal-cpp/dal/auto/MG_XccyNotionalMode_enum.hpp create mode 100644 dal-cpp/dal/auto/MG_XccyNotionalMode_enum.inc create mode 100644 dal-cpp/dal/auto/java/XccyNotionalMode.java create mode 100644 dal-cpp/dal/curve/xccynotionalmode.hpp diff --git a/dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp b/dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp index ab15d6ddf..eee55ba29 100644 --- a/dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp +++ b/dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp @@ -8,6 +8,7 @@ class BizDayConvention_ _NOT_SET=-1, Unadjusted, Following, + Preceding, ModifiedFollowing, _N_VALUES } val_; diff --git a/dal-cpp/dal/auto/MG_BizDayConvention_enum.inc b/dal-cpp/dal/auto/MG_BizDayConvention_enum.inc index 1a325612e..85c4c925f 100644 --- a/dal-cpp/dal/auto/MG_BizDayConvention_enum.inc +++ b/dal-cpp/dal/auto/MG_BizDayConvention_enum.inc @@ -10,6 +10,7 @@ Vector_ BizDayConventionListAll() { if (TheBizDayConventionList().empty()) { TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::Unadjusted); TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::Following); + TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::Preceding); TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::ModifiedFollowing); } return TheBizDayConventionList(); @@ -26,6 +27,8 @@ const char* BizDayConvention_::String() const { return "Unadjusted"; case Value_::Following: return "Following"; + case Value_::Preceding: + return "Preceding"; case Value_::ModifiedFollowing: return "ModifiedFollowing"; @@ -48,6 +51,9 @@ struct ReadStringBizDayConvention_ else if (String::Equivalent(src, "FOLLOWING")) *val = BizDayConvention_::Value_::Following; + else if (String::Equivalent(src, "PRECEDING")) + *val = BizDayConvention_::Value_::Preceding; + else if (String::Equivalent(src, "MODIFIEDFOLLOWING")) *val = BizDayConvention_::Value_::ModifiedFollowing; else diff --git a/dal-cpp/dal/auto/MG_XccyNotionalMode_enum.hpp b/dal-cpp/dal/auto/MG_XccyNotionalMode_enum.hpp new file mode 100644 index 000000000..826176e2c --- /dev/null +++ b/dal-cpp/dal/auto/MG_XccyNotionalMode_enum.hpp @@ -0,0 +1,37 @@ +#pragma once + +class XccyNotionalMode_ +{ +public: + enum class Value_ : char + { + _NOT_SET=-1, + FIXED, + RESETTABLE, + MARK_TO_MARKET, + _N_VALUES + } val_; + + XccyNotionalMode_(Value_ val) : val_(val) { + REQUIRE(val < Value_::_N_VALUES, "val is not valid"); + } +private: + friend bool operator==(const XccyNotionalMode_& lhs, const XccyNotionalMode_& rhs); + friend struct ReadStringXccyNotionalMode_; + friend Vector_ XccyNotionalModeListAll(); + friend bool operator<(const XccyNotionalMode_& lhs, const XccyNotionalMode_& rhs) { + return lhs.val_ < rhs.val_; + } +public: + explicit XccyNotionalMode_(const String_& src); + const char* String() const; + Value_ Switch() const {return val_;} + XccyNotionalMode_() : val_(Value_::_NOT_SET) {}; +}; + +Vector_ XccyNotionalModeListAll(); + +bool operator==(const XccyNotionalMode_& lhs, const XccyNotionalMode_& rhs); +inline bool operator!=(const XccyNotionalMode_& lhs, const XccyNotionalMode_& rhs) {return !(lhs == rhs);} +inline bool operator==(const XccyNotionalMode_& lhs, XccyNotionalMode_::Value_ rhs) {return lhs.Switch() == rhs;} +inline bool operator!=(const XccyNotionalMode_& lhs, XccyNotionalMode_::Value_ rhs) {return lhs.Switch() != rhs;} diff --git a/dal-cpp/dal/auto/MG_XccyNotionalMode_enum.inc b/dal-cpp/dal/auto/MG_XccyNotionalMode_enum.inc new file mode 100644 index 000000000..7fb7ebfa8 --- /dev/null +++ b/dal-cpp/dal/auto/MG_XccyNotionalMode_enum.inc @@ -0,0 +1,67 @@ + +bool operator==(const XccyNotionalMode_& lhs, const XccyNotionalMode_& rhs) {return lhs.val_ == rhs.val_;} +namespace { + Vector_& TheXccyNotionalModeList() { + RETURN_STATIC(Vector_); + } +} // leave local + +Vector_ XccyNotionalModeListAll() { + if (TheXccyNotionalModeList().empty()) { + TheXccyNotionalModeList().emplace_back(XccyNotionalMode_::Value_::FIXED); + TheXccyNotionalModeList().emplace_back(XccyNotionalMode_::Value_::RESETTABLE); + TheXccyNotionalModeList().emplace_back(XccyNotionalMode_::Value_::MARK_TO_MARKET); + } + return TheXccyNotionalModeList(); +} + + +const char* XccyNotionalMode_::String() const { + switch (val_) + { + default: + case Value_::_NOT_SET: + return 0; + case Value_::FIXED: + return "FIXED"; + case Value_::RESETTABLE: + return "RESETTABLE"; + case Value_::MARK_TO_MARKET: + return "MARK_TO_MARKET"; + + }} + +struct ReadStringXccyNotionalMode_ +{ + ReadStringXccyNotionalMode_() {} + + bool operator()(const String_& src, XccyNotionalMode_::Value_* val) const // returns true iff recognized input + { + bool ret_val = true; + if (0); // otiose code to allow regular else-if structure + else if (src.empty()) + { ret_val = false; } + + else if (String::Equivalent(src, "FIXED")) + *val = XccyNotionalMode_::Value_::FIXED; + + else if (String::Equivalent(src, "RESETTABLE")) + *val = XccyNotionalMode_::Value_::RESETTABLE; + + else if (String::Equivalent(src, "MARKTOMARKET")) + *val = XccyNotionalMode_::Value_::MARK_TO_MARKET; + else + ret_val = false; + return ret_val; + } +}; + +XccyNotionalMode_::XccyNotionalMode_(const String_& src) { + static const ReadStringXccyNotionalMode_ READ_FIXED; // allows precomputation for speed, in constructor + if (READ_FIXED(src, &val_)) + return; + THROW("'" + src + "' is not a recognizable XccyNotionalMode"); +} + + + diff --git a/dal-cpp/dal/auto/java/BizDayConvention.java b/dal-cpp/dal/auto/java/BizDayConvention.java index 6ab08d451..5fa81b163 100644 --- a/dal-cpp/dal/auto/java/BizDayConvention.java +++ b/dal-cpp/dal/auto/java/BizDayConvention.java @@ -7,6 +7,7 @@ public enum Value { UNADJUSTED, FOLLOWING, + PRECEDING, MODIFIEDFOLLOWING, N_VALUES } diff --git a/dal-cpp/dal/auto/java/XccyNotionalMode.java b/dal-cpp/dal/auto/java/XccyNotionalMode.java new file mode 100644 index 000000000..e76c7e999 --- /dev/null +++ b/dal-cpp/dal/auto/java/XccyNotionalMode.java @@ -0,0 +1,13 @@ + +package types; + +public class XccyNotionalMode +{ + public enum Value + { + FIXED, + RESETTABLE, + MARKTOMARKET, + N_VALUES + } +} diff --git a/dal-cpp/dal/curve/xccyinstrument.cpp b/dal-cpp/dal/curve/xccyinstrument.cpp index de6b50d0f..fae3de1b9 100644 --- a/dal-cpp/dal/curve/xccyinstrument.cpp +++ b/dal-cpp/dal/curve/xccyinstrument.cpp @@ -4,6 +4,8 @@ #include #include + +#include #include #include #include @@ -14,6 +16,8 @@ #include namespace Dal { +#include + namespace { struct XccyCouponPeriod_ { SchedulePeriod_ schedule_; @@ -178,6 +182,46 @@ namespace Dal { return (foreignPv - domesticPv) / domesticSpreadAnnuity; } }; + + CrossCurrencySwapConfig_ FixedConfig(const CurrencyPair_& pair, + double domesticNotional, + double foreignNotional, + const CrossCurrencyConvention_& convention) { + CrossCurrencySwapConfig_ result; + result.pair_ = pair; + result.domesticNotional_ = domesticNotional; + result.foreignNotional_ = foreignNotional; + result.convention_ = convention; + result.notionalMode_ = XccyNotionalMode_::Value_::FIXED; + return result; + } + + void ValidateConfig(const Date_& tradeDate, + const Date_& start, + const Date_& maturity, + double marketRate, + const CrossCurrencySwapConfig_& config) { + REQUIRE(tradeDate.IsValid() && start.IsValid() && maturity.IsValid(), + "CrossCurrencySwap_ requires valid trade, start, and maturity dates"); + REQUIRE(maturity > start, "CrossCurrencySwap_ requires maturity after start"); + REQUIRE(std::isfinite(marketRate), "CrossCurrencySwap_ requires a finite market rate"); + REQUIRE(std::isfinite(config.domesticNotional_) && config.domesticNotional_ > 0.0 && + std::isfinite(config.foreignNotional_) && config.foreignNotional_ > 0.0, + "CrossCurrencySwap_ requires positive finite notionals"); + REQUIRE(!(config.pair_.domestic_ == config.pair_.foreign_), + "CrossCurrencySwap_ requires distinct domestic and foreign currencies"); + REQUIRE(config.notionalMode_ == XccyNotionalMode_::Value_::FIXED || + config.notionalMode_ == XccyNotionalMode_::Value_::RESETTABLE || + config.notionalMode_ == XccyNotionalMode_::Value_::MARK_TO_MARKET, + "CrossCurrencySwap_ requires a valid notional mode"); + if (config.notionalMode_ != XccyNotionalMode_::Value_::FIXED) { + REQUIRE(config.fxReset_.fixingLag_ >= 0, + "Resettable cross-currency notionals require an explicit non-negative FX fixing lag"); + REQUIRE(config.fxReset_.fixingHour_ >= 0 && config.fxReset_.fixingHour_ < 24 && + config.fxReset_.fixingMinute_ >= 0 && config.fxReset_.fixingMinute_ < 60, + "Resettable cross-currency notionals require an explicit valid FX fixing time"); + } + } } // namespace CurrencyPair_::CurrencyPair_() : domestic_(Ccy_::Value_::USD), foreign_(Ccy_::Value_::EUR) {} @@ -206,16 +250,23 @@ namespace Dal { double domesticNotional, double foreignNotional, const CrossCurrencyConvention_& convention) + : CrossCurrencySwap_(tradeDate, + start, + maturity, + marketRate, + FixedConfig(pair, domesticNotional, foreignNotional, convention)) {} + + CrossCurrencySwap_::CrossCurrencySwap_(const Date_& tradeDate, + const Date_& start, + const Date_& maturity, + double marketRate, + const CrossCurrencySwapConfig_& config) : tradeDate_(tradeDate), start_(start), maturity_(maturity), marketRate_(marketRate), - pair_(pair), - domesticNotional_(domesticNotional), - foreignNotional_(foreignNotional), - convention_(convention) { - REQUIRE(domesticNotional_ > 0.0 && foreignNotional_ > 0.0, "CrossCurrencySwap_ requires positive notionals"); - REQUIRE(maturity_ > start_, "CrossCurrencySwap_ requires maturity after start"); + config_(config) { + ValidateConfig(tradeDate_, start_, maturity_, marketRate_, config_); } String_ CrossCurrencySwap_::Name() const { return "CrossCurrencySwap"; } @@ -223,26 +274,26 @@ namespace Dal { pair CrossCurrencySwap_::TimeSpan() const { return {start_, maturity_}; } Handle_ CrossCurrencySwap_::Precompute() const { - REQUIRE(!convention_.resettableNotional_, "Resettable cross-currency notionals are not implemented"); - REQUIRE(!convention_.markToMarketNotional_, "Mark-to-market cross-currency notionals are not implemented"); + REQUIRE(config_.notionalMode_ == XccyNotionalMode_::Value_::FIXED, + "Resettable and mark-to-market cross-currency notionals are not implemented"); const auto domesticPeriods = BuildLegPeriods(start_, maturity_, - convention_.domesticLeg_, - convention_.domesticIndex_.fixingLag_, - convention_.domesticIndex_.fixingHolidays_); + config_.convention_.domesticLeg_, + config_.convention_.domesticIndex_.fixingLag_, + config_.convention_.domesticIndex_.fixingHolidays_); const auto foreignPeriods = BuildLegPeriods(start_, maturity_, - convention_.foreignLeg_, - convention_.foreignIndex_.fixingLag_, - convention_.foreignIndex_.fixingHolidays_); + config_.convention_.foreignLeg_, + config_.convention_.foreignIndex_.fixingLag_, + config_.convention_.foreignIndex_.fixingHolidays_); return Handle_(new CrossCurrencySwapRate_(maturity_, - pair_, - domesticNotional_, - foreignNotional_, + config_.pair_, + config_.domesticNotional_, + config_.foreignNotional_, domesticPeriods, foreignPeriods, - convention_.domesticIndex_, - convention_.foreignIndex_, - convention_)); + config_.convention_.domesticIndex_, + config_.convention_.foreignIndex_, + config_.convention_)); } } // namespace Dal diff --git a/dal-cpp/dal/curve/xccyinstrument.hpp b/dal-cpp/dal/curve/xccyinstrument.hpp index e3e7d262c..c8f128d66 100644 --- a/dal-cpp/dal/curve/xccyinstrument.hpp +++ b/dal-cpp/dal/curve/xccyinstrument.hpp @@ -5,13 +5,17 @@ #pragma once #include + #include +#include #include #include namespace Dal { class CrossCurrencyMarket_; - namespace Tape { template class DiscountCurve_; } + namespace Tape { + template class DiscountCurve_; + } using DiscountCurve_ = Tape::DiscountCurve_; struct CurrencyPair_ { @@ -25,6 +29,31 @@ namespace Dal { [[nodiscard]] bool operator==(const CurrencyPair_& rhs) const; }; + struct FixingIdentity_ { + String_ indexName_; + int fixingHour_ = -1; + int fixingMinute_ = -1; + }; + + struct FxResetConvention_ { + int fixingLag_ = -1; + Holidays_ fixingHolidays_ = Holidays_(""); + BizDayConvention_ fixingConvention_ = BizDayConvention_("Preceding"); + int fixingHour_ = -1; + int fixingMinute_ = -1; + }; + + struct CrossCurrencySwapConfig_ { + CurrencyPair_ pair_; + double domesticNotional_ = 100.0; + double foreignNotional_ = 100.0; + CrossCurrencyConvention_ convention_; + XccyNotionalMode_ notionalMode_ = XccyNotionalMode_::Value_::FIXED; + FxResetConvention_ fxReset_; + FixingIdentity_ domesticRateFixing_; + FixingIdentity_ foreignRateFixing_; + }; + class CrossCurrencySwap_ { public: struct Rate_ : noncopyable { @@ -37,10 +66,7 @@ namespace Dal { Date_ start_; Date_ maturity_; double marketRate_; - CurrencyPair_ pair_; - double domesticNotional_; - double foreignNotional_; - CrossCurrencyConvention_ convention_; + CrossCurrencySwapConfig_ config_; public: CrossCurrencySwap_(const Date_& tradeDate, @@ -51,6 +77,8 @@ namespace Dal { double domesticNotional, double foreignNotional, const CrossCurrencyConvention_& convention = CrossCurrencyConvention_()); + CrossCurrencySwap_( + const Date_& tradeDate, const Date_& start, const Date_& maturity, double marketRate, const CrossCurrencySwapConfig_& config); [[nodiscard]] String_ Name() const; [[nodiscard]] pair TimeSpan() const; [[nodiscard]] double MarketRate() const { return marketRate_; } diff --git a/dal-cpp/dal/curve/xccynotionalmode.hpp b/dal-cpp/dal/curve/xccynotionalmode.hpp new file mode 100644 index 000000000..f67fac7b7 --- /dev/null +++ b/dal-cpp/dal/curve/xccynotionalmode.hpp @@ -0,0 +1,18 @@ +/*IF-------------------------------------------------------------------------- +enumeration XccyNotionalMode + Cross-currency notional evolution rule +switchable +alternative FIXED +alternative RESETTABLE +alternative MARK_TO_MARKET +-IF-------------------------------------------------------------------------*/ + +#pragma once + +#include +#include +#include + +namespace Dal { +#include +} // namespace Dal diff --git a/dal-cpp/dal/protocol/rateconvention.hpp b/dal-cpp/dal/protocol/rateconvention.hpp index c9b163233..ed94c9903 100644 --- a/dal-cpp/dal/protocol/rateconvention.hpp +++ b/dal-cpp/dal/protocol/rateconvention.hpp @@ -36,8 +36,6 @@ namespace Dal { }; struct CrossCurrencyConvention_ { - bool resettableNotional_ = false; - bool markToMarketNotional_ = false; bool initialNotionalExchange_ = false; bool finalNotionalExchange_ = false; bool spreadOnForeignLeg_ = true; diff --git a/dal-cpp/dal/time/holidays.cpp b/dal-cpp/dal/time/holidays.cpp index 992155431..1146d0428 100644 --- a/dal-cpp/dal/time/holidays.cpp +++ b/dal-cpp/dal/time/holidays.cpp @@ -149,6 +149,8 @@ namespace Dal { Date_ Holidays::Adjust(const Holidays_& hols, const Date_& from, const BizDayConvention_& convention) { if (convention == BizDayConvention_("Unadjusted")) return from; + if (convention == BizDayConvention_("Preceding")) + return PrevBus(hols, from); if (convention == BizDayConvention_("Following")) return NextBus(hols, from); if (convention == BizDayConvention_("ModifiedFollowing")) { diff --git a/dal-cpp/dal/time/schedules.hpp b/dal-cpp/dal/time/schedules.hpp index 4983df1a1..1e0ac1a1f 100644 --- a/dal-cpp/dal/time/schedules.hpp +++ b/dal-cpp/dal/time/schedules.hpp @@ -29,6 +29,8 @@ alternative Unadjusted Do not modify the date according to the holidays alternative Following Choose the first business day after the given holiday +alternative Preceding + Choose the first business day before the given holiday alternative ModifiedFollowing Choose the first business day after the given holiday unless it belongs to a different month, in which case choose the first business day before diff --git a/dal-cpp/tests/currency/test_currencydata.cpp b/dal-cpp/tests/currency/test_currencydata.cpp index a8bcde846..ad993b081 100644 --- a/dal-cpp/tests/currency/test_currencydata.cpp +++ b/dal-cpp/tests/currency/test_currencydata.cpp @@ -2,8 +2,8 @@ // Created by wegam on 2022/2/10. // -#include #include + #include #include #include @@ -11,6 +11,7 @@ #include #include #include +#include using namespace Dal; @@ -33,7 +34,6 @@ namespace { } } // namespace - TEST(CurrencyTest, TestFactRead) { ASSERT_EQ(Ccy::Conventions::LiborFixDays()(Ccy_("CNY")), 1); ASSERT_EQ(Ccy::Conventions::LiborFixDays()(Ccy_("USD")), 2); @@ -49,11 +49,9 @@ TEST(CurrencyDataTest, TestFactWrite) { ASSERT_EQ(Ccy::Conventions::LiborFixHolidays()(Ccy_("CNY")).String(), "CN.SSE"); } -TEST(CurrencyDataTest, TestXcsDefaultConvention) { +TEST(CurrencyDataTest, TestXcsDefaultConventionHasNoNotionalModeBooleans) { const CrossCurrencyConvention_& xcs = Ccy::Conventions::Xcs()(Ccy_("USD")); - ASSERT_FALSE(xcs.resettableNotional_); - ASSERT_FALSE(xcs.markToMarketNotional_); ASSERT_TRUE(xcs.initialNotionalExchange_); ASSERT_TRUE(xcs.finalNotionalExchange_); ASSERT_TRUE(xcs.spreadOnForeignLeg_); diff --git a/dal-cpp/tests/curve/test_xccymarket.cpp b/dal-cpp/tests/curve/test_xccymarket.cpp index ea9420e9b..1dda0a01c 100644 --- a/dal-cpp/tests/curve/test_xccymarket.cpp +++ b/dal-cpp/tests/curve/test_xccymarket.cpp @@ -2,10 +2,11 @@ // Created by GitHub Copilot on 2026/6/6. // +#include + #include #include #include -#include #include #include #include @@ -344,22 +345,15 @@ TEST(XccyMarketTest, TestCrossCurrencyCalibrationRepricesInputQuote) { ASSERT_NEAR(result.fxForwardCurve_.forwards_.front(), result.market_.FxForward(spec.knotDates_.front()), 1e-10); } -TEST(XccyMarketTest, TestResettableConventionThrows) { - const Date_ today(2024, 1, 15); - const auto evalDate = XGLOBAL::SetEvaluationDateInScope(today); - CrossCurrencyConvention_ convention; - convention.resettableNotional_ = true; - - const CrossCurrencySwap_ swap(today, - today, - Date::AddMonths(today, 12), - 0.0, - CurrencyPair_(Ccy_("USD"), Ccy_("EUR")), - 110.0, - 100.0, - convention); - - ASSERT_THROW(static_cast(swap.Precompute()), Dal::Exception_); +TEST(XccyMarketTest, TestResetConfigRequiresExplicitFixingIdentity) { + CrossCurrencySwapConfig_ config; + config.notionalMode_ = XccyNotionalMode_::Value_::RESETTABLE; + ASSERT_THROW(static_cast(CrossCurrencySwap_(Date_(2024, 1, 2), + Date_(2024, 1, 4), + Date_(2025, 1, 4), + 0.0, + config)), + Dal::Exception_); } TEST(XccyMarketTest, TestCalibrationWithSingleKnotAndZeroMarketRate) { diff --git a/dal-cpp/tests/time/test_schedules.cpp b/dal-cpp/tests/time/test_schedules.cpp index e7d599fa1..e3a160f70 100644 --- a/dal-cpp/tests/time/test_schedules.cpp +++ b/dal-cpp/tests/time/test_schedules.cpp @@ -2,8 +2,9 @@ // Created by wegam on 2022/10/2. // -#include #include + +#include #include #include #include @@ -77,6 +78,12 @@ TEST(SchedulesTest, TestMakeScheduleSupportsModifiedFollowing) { ASSERT_EQ(calculated[3], Date_(2024, 11, 29)); } +TEST(SchedulesTest, TestAdjustSupportsPreceding) { + const Holidays_ hols(Holidays::None()); + ASSERT_EQ(Holidays::Adjust(hols, Date_(2024, 12, 1), BizDayConvention_("Preceding")), + Date_(2024, 11, 29)); +} + TEST(SchedulesTest, TestMakeSchedulePeriodsBuildsFixingPaymentAndDayCountContext) { const Date_ start(2024, 1, 31); const Date_ maturity(2024, 7, 31); From 471f071da37bc63ebe0ccb8af40fdb69df85154f Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Mon, 13 Jul 2026 11:46:41 +0800 Subject: [PATCH 05/38] feat(fixings): add immutable market snapshots --- dal-cpp/dal/indice/fixingsnapshot.cpp | 120 +++++++++++++++++++ dal-cpp/dal/indice/fixingsnapshot.hpp | 42 +++++++ dal-cpp/dal/indice/index/fx.cpp | 25 ++-- dal-cpp/dal/indice/index/fx.hpp | 23 ++-- dal-cpp/dal/storage/_repository.cpp | 1 + dal-cpp/tests/indice/test_fixingsnapshot.cpp | 101 ++++++++++++++++ 6 files changed, 293 insertions(+), 19 deletions(-) create mode 100644 dal-cpp/dal/indice/fixingsnapshot.cpp create mode 100644 dal-cpp/dal/indice/fixingsnapshot.hpp create mode 100644 dal-cpp/tests/indice/test_fixingsnapshot.cpp diff --git a/dal-cpp/dal/indice/fixingsnapshot.cpp b/dal-cpp/dal/indice/fixingsnapshot.cpp new file mode 100644 index 000000000..d199d7f25 --- /dev/null +++ b/dal-cpp/dal/indice/fixingsnapshot.cpp @@ -0,0 +1,120 @@ +// +// Created by dal-implementer on 2026/7/13. +// + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace Dal { + namespace { + std::optional ReverseCanonicalFxName(const String_& name) { + if (name.size() < 8 || name.compare(0, 3, "FX[") != 0 || name.back() != ']') + return std::nullopt; + const auto slash = name.find('/', 3); + if (slash == String_::npos || slash == 3 || slash + 1 >= name.size() - 1 || name.find('/', slash + 1) != String_::npos) + return std::nullopt; + const String_ numerator(name.begin() + 3, name.begin() + slash); + const String_ denominator(name.begin() + slash + 1, name.end() - 1); + return String_(String_("FX[") + denominator + String_("/") + numerator + String_("]")); + } + + const double* FindValue(const FixHistory_& history, const DateTime_& fixingTime) { + for (const auto& item : history.vals_) { + if (item.first == fixingTime) + return &item.second; + } + return nullptr; + } + } // namespace + + MarketFixingSnapshot_::MarketFixingSnapshot_(const values_t& values) : values_(values) { + for (const auto& indexHistory : values_) { + const String_& indexName = indexHistory.first; + REQUIRE(!indexName.empty(), "Market fixing snapshot requires non-empty index names"); + for (const auto& fixing : indexHistory.second) { + REQUIRE(fixing.first.IsValid(), "Market fixing snapshot requires valid fixing timestamps"); + REQUIRE(std::isfinite(fixing.second) && fixing.second > 0.0, + "Market fixing snapshot requires positive finite values for " + indexName + " at " + DateTime::ToString(fixing.first)); + } + + const std::optional reverseName = ReverseCanonicalFxName(indexName); + if (!reverseName.has_value()) + continue; + const auto reverseHistory = values_.find(*reverseName); + if (reverseHistory == values_.end()) + continue; + for (const auto& direct : indexHistory.second) { + const auto reverse = reverseHistory->second.find(direct.first); + if (reverse == reverseHistory->second.end()) + continue; + REQUIRE(std::fabs(direct.second * reverse->second - 1.0) <= 1.0e-10, + "Inconsistent direct/reverse FX fixings for " + indexName + " at " + DateTime::ToString(direct.first)); + } + } + } + + std::optional MarketFixingSnapshot_::Find(const String_& indexName, const DateTime_& fixingTime) const { + const auto indexHistory = values_.find(indexName); + if (indexHistory != values_.end()) { + const auto fixing = indexHistory->second.find(fixingTime); + if (fixing != indexHistory->second.end()) + return fixing->second; + } + + const std::optional reverseName = ReverseCanonicalFxName(indexName); + if (!reverseName.has_value()) + return std::nullopt; + const auto reverseHistory = values_.find(*reverseName); + if (reverseHistory == values_.end()) + return std::nullopt; + const auto reverse = reverseHistory->second.find(fixingTime); + if (reverse == reverseHistory->second.end()) + return std::nullopt; + return 1.0 / reverse->second; + } + + double MarketFixingSnapshot_::Require(const String_& indexName, const DateTime_& fixingTime, const String_& context) const { + const std::optional value = Find(indexName, fixingTime); + REQUIRE(value.has_value(), "Missing fixing for " + indexName + " at " + DateTime::ToString(fixingTime) + " (" + context + ")"); + return *value; + } + + Handle_ SnapshotGlobalFixings(const Vector_& requests) { + using request_key_t = std::pair; + std::set uniqueRequests; + for (const auto& request : requests) + uniqueRequests.emplace(request.indexName_, request.fixingTime_); + std::map histories; + MarketFixingSnapshot_::values_t values; + + auto history = [&](const String_& indexName) -> const FixHistory_& { + auto found = histories.find(indexName); + if (found == histories.end()) + found = histories.emplace(indexName, Global::Fixings_().History(indexName)).first; + return found->second; + }; + auto copyAt = [&](const String_& indexName, const DateTime_& fixingTime) { + const double* value = FindValue(history(indexName), fixingTime); + if (value) + values[indexName][fixingTime] = *value; + }; + + for (const auto& request : uniqueRequests) { + REQUIRE(!request.first.empty(), "Market fixing snapshot request requires a non-empty index name"); + REQUIRE(request.second.IsValid(), "Market fixing snapshot request requires a valid fixing timestamp"); + copyAt(request.first, request.second); + const std::optional reverseName = ReverseCanonicalFxName(request.first); + if (reverseName.has_value()) + copyAt(*reverseName, request.second); + } + return Handle_(new MarketFixingSnapshot_(values)); + } +} // namespace Dal diff --git a/dal-cpp/dal/indice/fixingsnapshot.hpp b/dal-cpp/dal/indice/fixingsnapshot.hpp new file mode 100644 index 000000000..caecf282f --- /dev/null +++ b/dal-cpp/dal/indice/fixingsnapshot.hpp @@ -0,0 +1,42 @@ +// +// Created by dal-implementer on 2026/7/13. +// + +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace Dal { + class Ccy_; + struct CurrencyPair_; + + struct FixingRequest_ { + String_ indexName_; + DateTime_ fixingTime_; + }; + + class MarketFixingSnapshot_ { + public: + using history_t = std::map; + using values_t = std::map; + + private: + const values_t values_; + + public: + explicit MarketFixingSnapshot_(const values_t& values = values_t()); + [[nodiscard]] std::optional Find(const String_& indexName, const DateTime_& fixingTime) const; + [[nodiscard]] double Require(const String_& indexName, const DateTime_& fixingTime, const String_& context) const; + }; + + Handle_ SnapshotGlobalFixings(const Vector_& requests); + String_ FxIndexName(const Ccy_& domestic, const Ccy_& foreign); + String_ FxIndexName(const CurrencyPair_& pair); + String_ ReverseFxIndexName(const CurrencyPair_& pair); +} // namespace Dal diff --git a/dal-cpp/dal/indice/index/fx.cpp b/dal-cpp/dal/indice/index/fx.cpp index 0fd69e6fb..71ea2dd04 100644 --- a/dal-cpp/dal/indice/index/fx.cpp +++ b/dal-cpp/dal/indice/index/fx.cpp @@ -4,16 +4,23 @@ #include #include +#include +#include #include -namespace Dal::Index { - double Index::Fx_::Fixing(_ENV, const DateTime_& time) const { - const double test = PastFixing(_env, XName(false), time, true); - return test > -Dal::INF ? test : 1.0 / PastFixing(_env, XName(true), time); +namespace Dal { + String_ FxIndexName(const Ccy_& domestic, const Ccy_& foreign) { + return String_("FX[") + foreign.String() + String_("/") + domestic.String() + String_("]"); } - String_ Index::Fx_::XName(bool invert) const { - static const String_ SEP("/"); - return String_("FX[") + (invert ? dom_ : fgn_).String() + SEP + (invert ? fgn_ : dom_).String() + String_("]"); - } -} // namespace Dal::Index + String_ FxIndexName(const CurrencyPair_& pair) { return FxIndexName(pair.domestic_, pair.foreign_); } + + String_ ReverseFxIndexName(const CurrencyPair_& pair) { return FxIndexName(pair.foreign_, pair.domestic_); } + + namespace Index { + double Fx_::Fixing(_ENV, const DateTime_& time) const { + const double test = PastFixing(_env, FxIndexName(dom_, fgn_), time, true); + return test > -Dal::INF ? test : 1.0 / PastFixing(_env, FxIndexName(fgn_, dom_), time); + } + } // namespace Index +} // namespace Dal diff --git a/dal-cpp/dal/indice/index/fx.hpp b/dal-cpp/dal/indice/index/fx.hpp index d1b348589..6783213ae 100644 --- a/dal-cpp/dal/indice/index/fx.hpp +++ b/dal-cpp/dal/indice/index/fx.hpp @@ -7,14 +7,17 @@ #include #include -namespace Dal::Index { - class Fx_ : public Index_ { - Ccy_ dom_, fgn_; - [[nodiscard]] String_ XName(bool invert) const; +namespace Dal { + String_ FxIndexName(const Ccy_& domestic, const Ccy_& foreign); - public: - Fx_(const Ccy_ dom, const Ccy_ fgn): dom_(dom), fgn_(fgn) {} - [[nodiscard]] String_ Name() const override { return XName(false);} - double Fixing(_ENV, const DateTime_& fixingTime) const override; - }; -} // namespace Dal::Index + namespace Index { + class Fx_ : public Index_ { + Ccy_ dom_, fgn_; + + public: + Fx_(const Ccy_ dom, const Ccy_ fgn) : dom_(dom), fgn_(fgn) {} + [[nodiscard]] String_ Name() const override { return FxIndexName(dom_, fgn_); } + double Fixing(_ENV, const DateTime_& fixingTime) const override; + }; + } // namespace Index +} // namespace Dal diff --git a/dal-cpp/dal/storage/_repository.cpp b/dal-cpp/dal/storage/_repository.cpp index 2822c2240..6283963d4 100644 --- a/dal-cpp/dal/storage/_repository.cpp +++ b/dal-cpp/dal/storage/_repository.cpp @@ -135,6 +135,7 @@ namespace Dal { }; RUN_AT_LOAD(Global::SetTheDateStore(new RepoStore_)) + RUN_AT_LOAD(Global::SetTheFixingsStore(new RepoStore_)) } // namespace Handle_ ObjectAccess_::Fetch(const String_& tag, bool quiet) { diff --git a/dal-cpp/tests/indice/test_fixingsnapshot.cpp b/dal-cpp/tests/indice/test_fixingsnapshot.cpp new file mode 100644 index 000000000..9d3cc386f --- /dev/null +++ b/dal-cpp/tests/indice/test_fixingsnapshot.cpp @@ -0,0 +1,101 @@ +// +// Created by dal-implementer on 2026/7/13. +// + +#include + +#include +#include +#include +#include +#include + +using namespace Dal; + +namespace { + const DateTime_ kFixing(Date_(2024, 1, 2), 11, 0); + + MarketFixingSnapshot_::values_t SingleValue(const String_& indexName, double value) { return {{indexName, {{kFixing, value}}}}; } +} // namespace + +TEST(FixingSnapshotTest, TestCanonicalFxNames) { + const CurrencyPair_ pair(Ccy_("USD"), Ccy_("EUR")); + ASSERT_EQ(FxIndexName(Ccy_("USD"), Ccy_("EUR")), "FX[EUR/USD]"); + ASSERT_EQ(FxIndexName(pair), "FX[EUR/USD]"); + ASSERT_EQ(ReverseFxIndexName(pair), "FX[USD/EUR]"); +} + +TEST(FixingSnapshotTest, TestFindDirectFixing) { + const MarketFixingSnapshot_ snapshot(SingleValue("FX[EUR/USD]", 1.10)); + const std::optional value = snapshot.Find("FX[EUR/USD]", kFixing); + ASSERT_TRUE(value.has_value()); + ASSERT_NEAR(*value, 1.10, 1.0e-12); +} + +TEST(FixingSnapshotTest, TestFindUsesReverseFxReciprocal) { + const MarketFixingSnapshot_ snapshot(SingleValue("FX[USD/EUR]", 1.0 / 1.10)); + const std::optional value = snapshot.Find("FX[EUR/USD]", kFixing); + ASSERT_TRUE(value.has_value()); + ASSERT_NEAR(*value, 1.10, 1.0e-12); +} + +TEST(FixingSnapshotTest, TestConsistentTwoWayFxFixingsAreAccepted) { + const MarketFixingSnapshot_ snapshot({ + {"FX[EUR/USD]", {{kFixing, 1.10}}}, + {"FX[USD/EUR]", {{kFixing, 1.0 / 1.10}}}, + }); + ASSERT_NEAR(snapshot.Require("FX[EUR/USD]", kFixing, "consistent test"), 1.10, 1.0e-12); +} + +TEST(FixingSnapshotTest, TestInconsistentTwoWayFxFixingsAreRejected) { + const MarketFixingSnapshot_::values_t values = { + {"FX[EUR/USD]", {{kFixing, 1.10}}}, + {"FX[USD/EUR]", {{kFixing, 0.95}}}, + }; + ASSERT_THROW(static_cast(MarketFixingSnapshot_(values)), Dal::Exception_); +} + +TEST(FixingSnapshotTest, TestInvalidFixingValuesAreRejected) { + ASSERT_THROW(static_cast(MarketFixingSnapshot_(SingleValue("USD-SOFR", 0.0))), Dal::Exception_); + ASSERT_THROW(static_cast(MarketFixingSnapshot_(SingleValue("USD-SOFR", -0.01))), Dal::Exception_); + ASSERT_THROW(static_cast(MarketFixingSnapshot_(SingleValue("USD-SOFR", std::numeric_limits::infinity()))), Dal::Exception_); + ASSERT_THROW(static_cast(MarketFixingSnapshot_(SingleValue("USD-SOFR", std::numeric_limits::quiet_NaN()))), Dal::Exception_); +} + +TEST(FixingSnapshotTest, TestRequireRejectsMissingFixing) { + const MarketFixingSnapshot_ snapshot; + ASSERT_THROW(static_cast(snapshot.Require("USD-SOFR", kFixing, "coupon 3")), Dal::Exception_); +} + +TEST(FixingSnapshotTest, TestSnapshotStoresOnlyRequestedTimestamp) { + const DateTime_ other(Date_(2024, 1, 3), 11, 0); + FixHistory_ history; + history.vals_ = {{kFixing, 1.10}, {other, 1.20}}; + XGLOBAL::StoreFixings("FX[EUR/USD]", history, false); + + const auto snapshot = SnapshotGlobalFixings({{"FX[EUR/USD]", kFixing}}); + ASSERT_NEAR(snapshot->Require("FX[EUR/USD]", kFixing, "requested"), 1.10, 1.0e-12); + ASSERT_FALSE(snapshot->Find("FX[EUR/USD]", other).has_value()); +} + +TEST(FixingSnapshotTest, TestSnapshotResolvesReverseGlobalFxFixing) { + FixHistory_ reverse; + reverse.vals_ = {{kFixing, 1.0 / 1.25}}; + XGLOBAL::StoreFixings("FX[USD/GBP]", reverse, false); + + const auto snapshot = SnapshotGlobalFixings({{"FX[GBP/USD]", kFixing}}); + ASSERT_NEAR(snapshot->Require("FX[GBP/USD]", kFixing, "reverse"), 1.25, 1.0e-12); +} + +TEST(FixingSnapshotTest, TestSnapshotDoesNotObserveLaterGlobalMutation) { + FixHistory_ first; + first.vals_ = {{kFixing, 1.10}}; + XGLOBAL::StoreFixings("FX[EUR/USD]", first, false); + + const auto snapshot = SnapshotGlobalFixings({{"FX[EUR/USD]", kFixing}}); + + FixHistory_ replacement; + replacement.vals_ = {{kFixing, 1.20}}; + XGLOBAL::StoreFixings("FX[EUR/USD]", replacement, false); + ASSERT_NEAR(snapshot->Require("FX[EUR/USD]", kFixing, "test"), 1.10, 1.0e-12); +} From 215d974e428f45e4bbda2c166ba41e53728dd5ce Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Mon, 13 Jul 2026 12:29:12 +0800 Subject: [PATCH 06/38] feat(xccy): precompute reset and cashflow plans --- dal-cpp/dal/curve/xccypricing.cpp | 113 +++++++++++++++++++ dal-cpp/dal/curve/xccypricing.hpp | 40 +++++++ dal-cpp/tests/curve/test_xccypricing.cpp | 135 +++++++++++++++++++++++ 3 files changed, 288 insertions(+) create mode 100644 dal-cpp/dal/curve/xccypricing.cpp create mode 100644 dal-cpp/dal/curve/xccypricing.hpp create mode 100644 dal-cpp/tests/curve/test_xccypricing.cpp diff --git a/dal-cpp/dal/curve/xccypricing.cpp b/dal-cpp/dal/curve/xccypricing.cpp new file mode 100644 index 000000000..e35be131a --- /dev/null +++ b/dal-cpp/dal/curve/xccypricing.cpp @@ -0,0 +1,113 @@ +// +// Created by Codex on 2026/7/13. +// + +#include +#include + +#include +#include +#include +#include + +namespace Dal { + namespace { + bool ValidFixingIdentity(const FixingIdentity_& identity) { + return !identity.indexName_.empty() && identity.fixingHour_ >= 0 && identity.fixingHour_ < 24 && identity.fixingMinute_ >= 0 && + identity.fixingMinute_ < 60; + } + + void ValidateResetConfig(const CrossCurrencySwapConfig_& config) { + if (config.notionalMode_ == XccyNotionalMode_::Value_::FIXED) + return; + REQUIRE(config.fxReset_.fixingLag_ >= 0, "Resettable XCCY plan requires a non-negative FX fixing lag"); + REQUIRE(config.fxReset_.fixingHour_ >= 0 && config.fxReset_.fixingHour_ < 24 && config.fxReset_.fixingMinute_ >= 0 && + config.fxReset_.fixingMinute_ < 60, + "Resettable XCCY plan requires a valid FX fixing time"); + REQUIRE(ValidFixingIdentity(config.domesticRateFixing_) && ValidFixingIdentity(config.foreignRateFixing_), + "Resettable XCCY plan requires explicit domestic and foreign rate fixing identities"); + } + + DateTime_ RateFixingTime(const Date_& date, const FixingIdentity_& identity) { + if (identity.fixingHour_ < 0 || identity.fixingMinute_ < 0) + return DateTime_(date); + return DateTime_(date, identity.fixingHour_, identity.fixingMinute_); + } + + Date_ FxFixingDate(const Date_& effectiveDate, const FxResetConvention_& convention) { + const Date_ lagged = convention.fixingLag_ == 0 + ? effectiveDate + : Date::NBusDays(convention.fixingLag_, convention.fixingHolidays_)->BackFrom(effectiveDate); + return Holidays::Adjust(convention.fixingHolidays_, lagged, convention.fixingConvention_); + } + + void SetRateFixings(Vector_* periods, const FixingIdentity_& identity) { + for (auto& period : *periods) { + period.rateIndexName_ = identity.indexName_; + period.rateFixingTime_ = RateFixingTime(period.schedule_.fixingDate_, identity); + } + } + + bool RequestLess(const FixingRequest_& lhs, const FixingRequest_& rhs) { + return lhs.indexName_ < rhs.indexName_ || (lhs.indexName_ == rhs.indexName_ && lhs.fixingTime_ < rhs.fixingTime_); + } + + bool SameRequest(const FixingRequest_& lhs, const FixingRequest_& rhs) { + return lhs.indexName_ == rhs.indexName_ && lhs.fixingTime_ == rhs.fixingTime_; + } + + void AddHistoricalRateRequests(const Vector_& periods, const DateTime_& valuationTime, Vector_* requests) { + for (const auto& period : periods) { + if (period.schedule_.paymentDate_ < valuationTime.Date() || period.rateIndexName_.empty() || + !(period.rateFixingTime_ < valuationTime)) + continue; + requests->push_back({period.rateIndexName_, period.rateFixingTime_}); + } + } + } // namespace + + XccyCashflowPlan_ BuildXccyCashflowPlan(const Date_& start, const Date_& maturity, const CrossCurrencySwapConfig_& config) { + ValidateResetConfig(config); + XccyCashflowPlan_ result; + result.config_ = config; + result.start_ = start; + result.maturity_ = maturity; + result.domesticPeriods_ = + BuildLegPeriods(start, maturity, config.convention_.domesticLeg_, config.convention_.domesticIndex_.fixingLag_, + config.convention_.domesticIndex_.fixingHolidays_); + result.foreignPeriods_ = + BuildLegPeriods(start, maturity, config.convention_.foreignLeg_, config.convention_.foreignIndex_.fixingLag_, + config.convention_.foreignIndex_.fixingHolidays_); + SetRateFixings(&result.domesticPeriods_, config.domesticRateFixing_); + SetRateFixings(&result.foreignPeriods_, config.foreignRateFixing_); + + if (config.notionalMode_ == XccyNotionalMode_::Value_::FIXED) + return result; + + for (int i = 1; i < static_cast(result.domesticPeriods_.size()); ++i) { + const Date_ effectiveDate = result.domesticPeriods_[i].schedule_.accrualStart_; + const Date_ fixingDate = FxFixingDate(effectiveDate, config.fxReset_); + result.resets_.push_back({effectiveDate, DateTime_(fixingDate, config.fxReset_.fixingHour_, config.fxReset_.fixingMinute_), i}); + } + return result; + } + + Vector_ RequiredHistoricalFixings(const XccyCashflowPlan_& plan, const DateTime_& valuationTime) { + Vector_ result; + AddHistoricalRateRequests(plan.domesticPeriods_, valuationTime, &result); + AddHistoricalRateRequests(plan.foreignPeriods_, valuationTime, &result); + + for (const auto& reset : plan.resets_) { + REQUIRE(reset.domesticPeriodIndex_ >= 0 && reset.domesticPeriodIndex_ < static_cast(plan.domesticPeriods_.size()), + "XCCY reset event references an invalid domestic period"); + const auto& targetPeriod = plan.domesticPeriods_[reset.domesticPeriodIndex_]; + if (targetPeriod.schedule_.paymentDate_ < valuationTime.Date() || !(reset.fxFixingTime_ < valuationTime)) + continue; + result.push_back({FxIndexName(plan.config_.pair_), reset.fxFixingTime_}); + } + + std::sort(result.begin(), result.end(), RequestLess); + result.erase(std::unique(result.begin(), result.end(), SameRequest), result.end()); + return result; + } +} // namespace Dal diff --git a/dal-cpp/dal/curve/xccypricing.hpp b/dal-cpp/dal/curve/xccypricing.hpp new file mode 100644 index 000000000..2219cdc73 --- /dev/null +++ b/dal-cpp/dal/curve/xccypricing.hpp @@ -0,0 +1,40 @@ +// +// Created by Codex on 2026/7/13. +// + +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace Dal { + struct XccyCouponPeriod_ { + SchedulePeriod_ schedule_; + AccrualPeriod_ accrual_; + String_ rateIndexName_; + DateTime_ rateFixingTime_; + }; + + struct XccyResetEvent_ { + Date_ effectiveDate_; + DateTime_ fxFixingTime_; + int domesticPeriodIndex_ = 0; + }; + + struct XccyCashflowPlan_ { + Vector_ domesticPeriods_; + Vector_ foreignPeriods_; + Vector_ resets_; + CrossCurrencySwapConfig_ config_; + Date_ start_; + Date_ maturity_; + }; + + XccyCashflowPlan_ BuildXccyCashflowPlan(const Date_& start, const Date_& maturity, const CrossCurrencySwapConfig_& config); + Vector_ RequiredHistoricalFixings(const XccyCashflowPlan_& plan, const DateTime_& valuationTime); +} // namespace Dal diff --git a/dal-cpp/tests/curve/test_xccypricing.cpp b/dal-cpp/tests/curve/test_xccypricing.cpp new file mode 100644 index 000000000..0f53df542 --- /dev/null +++ b/dal-cpp/tests/curve/test_xccypricing.cpp @@ -0,0 +1,135 @@ +// +// Created by Codex on 2026/7/13. +// + +#include + +#include + +using namespace Dal; + +namespace { + CrossCurrencySwapConfig_ MakeQuarterlyConfig(XccyNotionalMode_ mode) { + CrossCurrencySwapConfig_ config; + config.pair_ = CurrencyPair_(Ccy_("USD"), Ccy_("EUR")); + config.domesticNotional_ = 110.0; + config.foreignNotional_ = 100.0; + config.notionalMode_ = mode; + + config.convention_.domesticLeg_.paymentFrequency_ = PeriodLength_("3M"); + config.convention_.domesticLeg_.dayBasis_ = DayBasis_("ACT_365F"); + config.convention_.domesticLeg_.accrualHolidays_ = Holidays::None(); + config.convention_.domesticLeg_.paymentHolidays_ = Holidays::None(); + config.convention_.domesticLeg_.businessDayConvention_ = BizDayConvention_("Unadjusted"); + config.convention_.domesticLeg_.paymentConvention_ = BizDayConvention_("Unadjusted"); + config.convention_.foreignLeg_ = config.convention_.domesticLeg_; + + config.convention_.domesticIndex_.fixingLag_ = 2; + config.convention_.domesticIndex_.fixingHolidays_ = Holidays::None(); + config.convention_.domesticIndex_.forecastTenor_ = PeriodLength_("3M"); + config.convention_.foreignIndex_ = config.convention_.domesticIndex_; + + config.domesticRateFixing_ = {"USD-SOFR-3M", 11, 0}; + config.foreignRateFixing_ = {"EUR-ESTR-3M", 11, 0}; + config.fxReset_.fixingLag_ = 2; + config.fxReset_.fixingHolidays_ = Holidays::None(); + config.fxReset_.fixingConvention_ = BizDayConvention_("Preceding"); + config.fxReset_.fixingHour_ = 10; + config.fxReset_.fixingMinute_ = 30; + return config; + } + + bool HasRequest(const Vector_& requests, const String_& indexName, const DateTime_& fixingTime) { + for (const auto& request : requests) { + if (request.indexName_ == indexName && request.fixingTime_ == fixingTime) + return true; + } + return false; + } +} // namespace + +TEST(XccyPricingTest, TestQuarterlyMtmPlanResetsFromSecondPeriod) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 1, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET)); + + ASSERT_EQ(plan.domesticPeriods_.size(), 4U); + ASSERT_EQ(plan.foreignPeriods_.size(), 4U); + ASSERT_EQ(plan.resets_.size(), 3U); + ASSERT_EQ(plan.resets_[0].effectiveDate_, plan.domesticPeriods_[1].schedule_.accrualStart_); + ASSERT_EQ(plan.resets_[0].domesticPeriodIndex_, 1); + ASSERT_EQ(plan.resets_[1].domesticPeriodIndex_, 2); + ASSERT_EQ(plan.resets_[2].domesticPeriodIndex_, 3); +} + +TEST(XccyPricingTest, TestResettableAndMtmPlansUseSameResetDates) { + const auto resettable = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 1, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::RESETTABLE)); + const auto mtm = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 1, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET)); + + ASSERT_EQ(resettable.resets_.size(), mtm.resets_.size()); + for (int i = 0; i < static_cast(resettable.resets_.size()); ++i) { + ASSERT_EQ(resettable.resets_[i].effectiveDate_, mtm.resets_[i].effectiveDate_); + ASSERT_EQ(resettable.resets_[i].fxFixingTime_, mtm.resets_[i].fxFixingTime_); + } +} + +TEST(XccyPricingTest, TestFixedPlanHasNoResets) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 1, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED)); + ASSERT_TRUE(plan.resets_.empty()); +} + +TEST(XccyPricingTest, TestPlanPreservesShortFinalStub) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 2, 15), MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET)); + + ASSERT_EQ(plan.domesticPeriods_.size(), 5U); + ASSERT_TRUE(plan.domesticPeriods_.back().schedule_.isStub_); + ASSERT_EQ(plan.domesticPeriods_.back().schedule_.accrualEnd_, Date_(2025, 2, 15)); + ASSERT_EQ(plan.resets_.size(), 4U); +} + +TEST(XccyPricingTest, TestPlanAppliesRateAndFxFixingLagAndTime) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 8), Date_(2024, 7, 8), MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET)); + + ASSERT_EQ(plan.domesticPeriods_[0].rateFixingTime_, DateTime_(Date_(2024, 1, 4), 11, 0)); + ASSERT_EQ(plan.foreignPeriods_[0].rateFixingTime_, DateTime_(Date_(2024, 1, 4), 11, 0)); + ASSERT_EQ(plan.resets_[0].effectiveDate_, Date_(2024, 4, 8)); + ASSERT_EQ(plan.resets_[0].fxFixingTime_, DateTime_(Date_(2024, 4, 4), 10, 30)); +} + +TEST(XccyPricingTest, TestRequiredHistoricalFixingsExcludePaidCoupons) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 8), Date_(2025, 1, 8), MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET)); + const auto requests = RequiredHistoricalFixings(plan, DateTime_(Date_(2024, 4, 9), 12, 0)); + + ASSERT_EQ(requests.size(), 3U); + ASSERT_TRUE(HasRequest(requests, "USD-SOFR-3M", DateTime_(Date_(2024, 4, 4), 11, 0))); + ASSERT_TRUE(HasRequest(requests, "EUR-ESTR-3M", DateTime_(Date_(2024, 4, 4), 11, 0))); + ASSERT_TRUE(HasRequest(requests, "FX[EUR/USD]", DateTime_(Date_(2024, 4, 4), 10, 30))); +} + +TEST(XccyPricingTest, TestRequiredHistoricalFixingsIncludeValuationDatePayments) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 1, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET)); + const auto requests = RequiredHistoricalFixings(plan, DateTime_(Date_(2024, 4, 4), 12, 0)); + + ASSERT_EQ(requests.size(), 5U); + ASSERT_TRUE(HasRequest(requests, "USD-SOFR-3M", DateTime_(Date_(2024, 1, 2), 11, 0))); + ASSERT_TRUE(HasRequest(requests, "EUR-ESTR-3M", DateTime_(Date_(2024, 1, 2), 11, 0))); + ASSERT_TRUE(HasRequest(requests, "USD-SOFR-3M", DateTime_(Date_(2024, 4, 2), 11, 0))); + ASSERT_TRUE(HasRequest(requests, "EUR-ESTR-3M", DateTime_(Date_(2024, 4, 2), 11, 0))); + ASSERT_TRUE(HasRequest(requests, "FX[EUR/USD]", DateTime_(Date_(2024, 4, 2), 10, 30))); + for (int i = 1; i < static_cast(requests.size()); ++i) { + ASSERT_TRUE(requests[i - 1].indexName_ < requests[i].indexName_ || + (requests[i - 1].indexName_ == requests[i].indexName_ && requests[i - 1].fixingTime_ < requests[i].fixingTime_)); + } +} + +TEST(XccyPricingTest, TestResetPlanRejectsNegativeFxFixingLag) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::RESETTABLE); + config.fxReset_.fixingLag_ = -1; + + ASSERT_THROW(static_cast(BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 1, 4), config)), Dal::Exception_); +} + +TEST(XccyPricingTest, TestResetPlanRequiresRateFixingIdentities) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET); + config.domesticRateFixing_ = FixingIdentity_(); + + ASSERT_THROW(static_cast(BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 1, 4), config)), Dal::Exception_); +} From 9dfe47370f2945e647bcd10c1994ffba02d7a0e0 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Mon, 13 Jul 2026 13:27:20 +0800 Subject: [PATCH 07/38] feat(xccy): price reset and in-progress cashflows --- dal-cpp/dal/curve/xccycalibration.hpp | 19 +- dal-cpp/dal/curve/xccyinstrument.cpp | 270 +++++------------------ dal-cpp/dal/curve/xccypricing.cpp | 194 ++++++++++++++++ dal-cpp/dal/curve/xccypricing.hpp | 22 ++ dal-cpp/tests/curve/test_xccymarket.cpp | 152 ++++++------- dal-cpp/tests/curve/test_xccypricing.cpp | 214 ++++++++++++++++++ 6 files changed, 556 insertions(+), 315 deletions(-) diff --git a/dal-cpp/dal/curve/xccycalibration.hpp b/dal-cpp/dal/curve/xccycalibration.hpp index 81228c5a5..38c8c9701 100644 --- a/dal-cpp/dal/curve/xccycalibration.hpp +++ b/dal-cpp/dal/curve/xccycalibration.hpp @@ -4,16 +4,18 @@ #pragma once -#include -#include #include #include #include #include #include +#include +#include namespace Dal { - namespace Tape { template class DiscountCurve_; } + namespace Tape { + template class DiscountCurve_; + } using DiscountCurve_ = Tape::DiscountCurve_; class CrossCurrencyMarket_ { @@ -25,9 +27,7 @@ namespace Dal { Handle_ basisCurve_; public: - CrossCurrencyMarket_(const Handle_& domesticBlock, - const Handle_& foreignBlock, - double fxSpot); + CrossCurrencyMarket_(const Handle_& domesticBlock, const Handle_& foreignBlock, double fxSpot); void SetBasisCurve(const Handle_& basisCurve); [[nodiscard]] Date_ Today() const; [[nodiscard]] const Ccy_& DomesticCcy() const { return domesticCcy_; } @@ -36,11 +36,10 @@ namespace Dal { [[nodiscard]] const CurveBlock_& ForeignBlock() const { return *foreignBlock_; } [[nodiscard]] const DiscountCurve_& DomesticDiscountCurve(const CollateralType_& collateral) const; [[nodiscard]] const DiscountCurve_& ForeignDiscountCurve(const CollateralType_& collateral) const; - [[nodiscard]] const DiscountCurve_& DomesticForwardCurve(const PeriodLength_& tenor, - const CollateralType_& collateral) const; - [[nodiscard]] const DiscountCurve_& ForeignForwardCurve(const PeriodLength_& tenor, - const CollateralType_& collateral) const; + [[nodiscard]] const DiscountCurve_& DomesticForwardCurve(const PeriodLength_& tenor, const CollateralType_& collateral) const; + [[nodiscard]] const DiscountCurve_& ForeignForwardCurve(const PeriodLength_& tenor, const CollateralType_& collateral) const; [[nodiscard]] double FxSpot() const { return fxSpot_; } + [[nodiscard]] const DiscountCurve_* BasisCurve() const { return basisCurve_.get(); } [[nodiscard]] double BasisDiscountFactor(const Date_& from, const Date_& to) const; [[nodiscard]] double FxForward(const Date_& maturity) const; [[nodiscard]] double FxForward(const Date_& from, const Date_& maturity, const CollateralType_& collateral) const; diff --git a/dal-cpp/dal/curve/xccyinstrument.cpp b/dal-cpp/dal/curve/xccyinstrument.cpp index fae3de1b9..6542a7ddb 100644 --- a/dal-cpp/dal/curve/xccyinstrument.cpp +++ b/dal-cpp/dal/curve/xccyinstrument.cpp @@ -6,187 +6,61 @@ #include #include -#include -#include #include #include -#include -#include +#include #include -#include namespace Dal { #include namespace { - struct XccyCouponPeriod_ { - SchedulePeriod_ schedule_; - AccrualPeriod_ accrual_; - }; - - double ForwardRate(const DiscountCurve_& forecast, - const XccyCouponPeriod_& period, - const DayBasis_& basis) { - const double df = forecast(period.schedule_.accrualStart_, period.schedule_.accrualEnd_); - REQUIRE(df > 0.0, "Cross-currency forward-rate calculation requires positive discount factors"); - return (1.0 / df - 1.0) / basis(period.schedule_.accrualStart_, - period.schedule_.accrualEnd_, - period.schedule_.dayCountContext_.get()); - } - - double CouponPv(const Vector_& periods, - const DiscountCurve_& discount, - const DiscountCurve_& forecast, - const Date_& valueDate, - double notional, - const RateIndexConvention_& indexConvention, - double spread) { - double retval = 0.0; - for (const auto& period : periods) { - const double fixing = ForwardRate(forecast, period, indexConvention.dayBasis_) + spread; - retval += notional * fixing * period.accrual_.dcf_ * discount(valueDate, period.schedule_.paymentDate_); - } - return retval; - } - - double ConvertedCouponPv(const Vector_& periods, - const CrossCurrencyMarket_& market, - const DiscountCurve_& discount, - const DiscountCurve_& forecast, - const Date_& valueDate, - double notional, - const RateIndexConvention_& indexConvention, - double spread) { - double retval = 0.0; - for (const auto& period : periods) { - const double fixing = ForwardRate(forecast, period, indexConvention.dayBasis_) + spread; - const double discountFactor = discount(valueDate, period.schedule_.paymentDate_) - / market.BasisDiscountFactor(valueDate, period.schedule_.paymentDate_); - retval += notional * fixing * period.accrual_.dcf_ * discountFactor * market.FxSpot(); - } - return retval; - } - - double ConvertedAnnuity(const Vector_& periods, - const CrossCurrencyMarket_& market, - const DiscountCurve_& discount, - const Date_& valueDate, - double notional) { - double retval = 0.0; - for (const auto& period : periods) { - const double discountFactor = discount(valueDate, period.schedule_.paymentDate_) - / market.BasisDiscountFactor(valueDate, period.schedule_.paymentDate_); - retval += notional * period.accrual_.dcf_ * discountFactor * market.FxSpot(); - } - return retval; - } - - double DomesticAnnuity(const Vector_& periods, - const DiscountCurve_& discount, - const Date_& valueDate) { - double retval = 0.0; - for (const auto& period : periods) { - retval += period.accrual_.dcf_ * discount(valueDate, period.schedule_.paymentDate_); - } - return retval; - } - - class CrossCurrencySwapRate_ : public CrossCurrencySwap_::Rate_ { - Date_ maturity_; - CurrencyPair_ pair_; - double domesticNotional_; - double foreignNotional_; - Vector_ domesticPeriods_; - Vector_ foreignPeriods_; - RateIndexConvention_ domesticIndexConvention_; - RateIndexConvention_ foreignIndexConvention_; - CrossCurrencyConvention_ convention_; + class CrossCurrencySwapKernelRate_ : public CrossCurrencySwap_::Rate_ { + XccyCashflowPlan_ plan_; public: - CrossCurrencySwapRate_(const Date_& maturity, - const CurrencyPair_& pair, - double domesticNotional, - double foreignNotional, - const Vector_& domesticPeriods, - const Vector_& foreignPeriods, - const RateIndexConvention_& domesticIndexConvention, - const RateIndexConvention_& foreignIndexConvention, - const CrossCurrencyConvention_& convention) - : maturity_(maturity), - pair_(pair), - domesticNotional_(domesticNotional), - foreignNotional_(foreignNotional), - domesticPeriods_(domesticPeriods), - foreignPeriods_(foreignPeriods), - domesticIndexConvention_(domesticIndexConvention), - foreignIndexConvention_(foreignIndexConvention), - convention_(convention) {} + explicit CrossCurrencySwapKernelRate_(const XccyCashflowPlan_& plan) : plan_(plan) {} double operator()(const CrossCurrencyMarket_& market) const override { - REQUIRE(pair_.domestic_ == market.DomesticCcy() && pair_.foreign_ == market.ForeignCcy(), - "Cross-currency swap currency pair does not match the pricing market orientation"); - const Date_ valueDate = market.Today(); - REQUIRE(!domesticPeriods_.empty() && !foreignPeriods_.empty(), "Cross-currency swap requires scheduled periods on both legs"); - REQUIRE(valueDate <= domesticPeriods_.front().schedule_.accrualStart_ && - valueDate <= foreignPeriods_.front().schedule_.accrualStart_, - "Cross-currency swap pricing of in-progress swaps (evaluation date after swap start) is not supported"); - const DiscountCurve_& domesticDiscount = market.DomesticDiscountCurve(domesticIndexConvention_.collateral_); - const DiscountCurve_& domesticForecast = market.DomesticForwardCurve(domesticIndexConvention_.forecastTenor_, - domesticIndexConvention_.collateral_); - const DiscountCurve_& foreignForecast = market.ForeignForwardCurve(foreignIndexConvention_.forecastTenor_, - foreignIndexConvention_.collateral_); - const DiscountCurve_& foreignDiscount = market.ForeignDiscountCurve(foreignIndexConvention_.collateral_); - - const double domesticBase = CouponPv(domesticPeriods_, - domesticDiscount, - domesticForecast, - valueDate, - domesticNotional_, - domesticIndexConvention_, - 0.0); - const double domesticSpreadAnnuity = domesticNotional_ * DomesticAnnuity(domesticPeriods_, domesticDiscount, valueDate); - const double foreignBase = ConvertedCouponPv(foreignPeriods_, - market, - foreignDiscount, - foreignForecast, - valueDate, - foreignNotional_, - foreignIndexConvention_, - 0.0); - const double foreignSpreadAnnuity = ConvertedAnnuity(foreignPeriods_, - market, - foreignDiscount, - valueDate, - foreignNotional_); - - double domesticPv = domesticBase; - double foreignPv = foreignBase; - if (convention_.initialNotionalExchange_) { - const Date_ domesticStart = domesticPeriods_.front().schedule_.accrualStart_; - const Date_ foreignStart = foreignPeriods_.front().schedule_.accrualStart_; - domesticPv -= domesticNotional_ * domesticDiscount(valueDate, domesticStart); - foreignPv -= foreignNotional_ * foreignDiscount(valueDate, foreignStart) - / market.BasisDiscountFactor(valueDate, foreignStart) * market.FxSpot(); + const auto& convention = plan_.config_.convention_; + Tape::JointCurveBlock_ domestic; + Tape::JointCurveBlock_ foreign; + domestic.discountCurves.emplace(convention.domesticIndex_.collateral_, + &market.DomesticDiscountCurve(convention.domesticIndex_.collateral_)); + foreign.discountCurves.emplace(convention.foreignIndex_.collateral_, + &market.ForeignDiscountCurve(convention.foreignIndex_.collateral_)); + if (convention.domesticIndex_.useProjectionCurve_) { + domestic.forwardCurves.emplace( + convention.domesticIndex_.forecastTenor_, + &market.DomesticForwardCurve(convention.domesticIndex_.forecastTenor_, convention.domesticIndex_.collateral_)); } - if (convention_.finalNotionalExchange_) { - domesticPv += domesticNotional_ * domesticDiscount(valueDate, maturity_); - foreignPv += foreignNotional_ * foreignDiscount(valueDate, maturity_) - / market.BasisDiscountFactor(valueDate, maturity_) * market.FxSpot(); + if (convention.foreignIndex_.useProjectionCurve_) { + foreign.forwardCurves.emplace( + convention.foreignIndex_.forecastTenor_, + &market.ForeignForwardCurve(convention.foreignIndex_.forecastTenor_, convention.foreignIndex_.collateral_)); } - if (convention_.spreadOnForeignLeg_) { - REQUIRE(foreignSpreadAnnuity > 0.0, "Cross-currency swap requires positive foreign spread annuity"); - return (domesticPv - foreignPv) / foreignSpreadAnnuity; - } - REQUIRE(domesticSpreadAnnuity > 0.0, "Cross-currency swap requires positive domestic spread annuity"); - return (foreignPv - domesticPv) / domesticSpreadAnnuity; + XccyMarketView_ view; + view.valuationTime_ = DateTime_(market.Today()); + view.pair_ = CurrencyPair_(market.DomesticCcy(), market.ForeignCcy()); + view.collateralCurrency_ = market.DomesticCcy(); + view.fxSpot_ = market.FxSpot(); + view.domestic_ = &domestic; + view.foreign_ = &foreign; + view.basis_ = market.BasisCurve(); + static const MarketFixingSnapshot_ emptyFixings; + if (plan_.config_.notionalMode_ == XccyNotionalMode_::Value_::FIXED && plan_.config_.domesticRateFixing_.indexName_.empty() && + plan_.config_.foreignRateFixing_.indexName_.empty()) + return PriceXccyParSpread(plan_, view, emptyFixings); + + const auto requests = RequiredHistoricalFixings(plan_, view.valuationTime_); + const auto fixings = SnapshotGlobalFixings(requests); + return PriceXccyParSpread(plan_, view, *fixings); } }; - CrossCurrencySwapConfig_ FixedConfig(const CurrencyPair_& pair, - double domesticNotional, - double foreignNotional, - const CrossCurrencyConvention_& convention) { + CrossCurrencySwapConfig_ + FixedConfig(const CurrencyPair_& pair, double domesticNotional, double foreignNotional, const CrossCurrencyConvention_& convention) { CrossCurrencySwapConfig_ result; result.pair_ = pair; result.domesticNotional_ = domesticNotional; @@ -196,29 +70,23 @@ namespace Dal { return result; } - void ValidateConfig(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const CrossCurrencySwapConfig_& config) { + void + ValidateConfig(const Date_& tradeDate, const Date_& start, const Date_& maturity, double marketRate, const CrossCurrencySwapConfig_& config) { REQUIRE(tradeDate.IsValid() && start.IsValid() && maturity.IsValid(), "CrossCurrencySwap_ requires valid trade, start, and maturity dates"); REQUIRE(maturity > start, "CrossCurrencySwap_ requires maturity after start"); REQUIRE(std::isfinite(marketRate), "CrossCurrencySwap_ requires a finite market rate"); - REQUIRE(std::isfinite(config.domesticNotional_) && config.domesticNotional_ > 0.0 && - std::isfinite(config.foreignNotional_) && config.foreignNotional_ > 0.0, + REQUIRE(std::isfinite(config.domesticNotional_) && config.domesticNotional_ > 0.0 && std::isfinite(config.foreignNotional_) && + config.foreignNotional_ > 0.0, "CrossCurrencySwap_ requires positive finite notionals"); - REQUIRE(!(config.pair_.domestic_ == config.pair_.foreign_), - "CrossCurrencySwap_ requires distinct domestic and foreign currencies"); - REQUIRE(config.notionalMode_ == XccyNotionalMode_::Value_::FIXED || - config.notionalMode_ == XccyNotionalMode_::Value_::RESETTABLE || + REQUIRE(!(config.pair_.domestic_ == config.pair_.foreign_), "CrossCurrencySwap_ requires distinct domestic and foreign currencies"); + REQUIRE(config.notionalMode_ == XccyNotionalMode_::Value_::FIXED || config.notionalMode_ == XccyNotionalMode_::Value_::RESETTABLE || config.notionalMode_ == XccyNotionalMode_::Value_::MARK_TO_MARKET, "CrossCurrencySwap_ requires a valid notional mode"); if (config.notionalMode_ != XccyNotionalMode_::Value_::FIXED) { - REQUIRE(config.fxReset_.fixingLag_ >= 0, - "Resettable cross-currency notionals require an explicit non-negative FX fixing lag"); - REQUIRE(config.fxReset_.fixingHour_ >= 0 && config.fxReset_.fixingHour_ < 24 && - config.fxReset_.fixingMinute_ >= 0 && config.fxReset_.fixingMinute_ < 60, + REQUIRE(config.fxReset_.fixingLag_ >= 0, "Resettable cross-currency notionals require an explicit non-negative FX fixing lag"); + REQUIRE(config.fxReset_.fixingHour_ >= 0 && config.fxReset_.fixingHour_ < 24 && config.fxReset_.fixingMinute_ >= 0 && + config.fxReset_.fixingMinute_ < 60, "Resettable cross-currency notionals require an explicit valid FX fixing time"); } } @@ -238,9 +106,7 @@ namespace Dal { return foreign_ < rhs.foreign_; } - bool CurrencyPair_::operator==(const CurrencyPair_& rhs) const { - return domestic_ == rhs.domestic_ && foreign_ == rhs.foreign_; - } + bool CurrencyPair_::operator==(const CurrencyPair_& rhs) const { return domestic_ == rhs.domestic_ && foreign_ == rhs.foreign_; } CrossCurrencySwap_::CrossCurrencySwap_(const Date_& tradeDate, const Date_& start, @@ -250,22 +116,11 @@ namespace Dal { double domesticNotional, double foreignNotional, const CrossCurrencyConvention_& convention) - : CrossCurrencySwap_(tradeDate, - start, - maturity, - marketRate, - FixedConfig(pair, domesticNotional, foreignNotional, convention)) {} + : CrossCurrencySwap_(tradeDate, start, maturity, marketRate, FixedConfig(pair, domesticNotional, foreignNotional, convention)) {} - CrossCurrencySwap_::CrossCurrencySwap_(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const CrossCurrencySwapConfig_& config) - : tradeDate_(tradeDate), - start_(start), - maturity_(maturity), - marketRate_(marketRate), - config_(config) { + CrossCurrencySwap_::CrossCurrencySwap_( + const Date_& tradeDate, const Date_& start, const Date_& maturity, double marketRate, const CrossCurrencySwapConfig_& config) + : tradeDate_(tradeDate), start_(start), maturity_(maturity), marketRate_(marketRate), config_(config) { ValidateConfig(tradeDate_, start_, maturity_, marketRate_, config_); } @@ -274,26 +129,7 @@ namespace Dal { pair CrossCurrencySwap_::TimeSpan() const { return {start_, maturity_}; } Handle_ CrossCurrencySwap_::Precompute() const { - REQUIRE(config_.notionalMode_ == XccyNotionalMode_::Value_::FIXED, - "Resettable and mark-to-market cross-currency notionals are not implemented"); - const auto domesticPeriods = BuildLegPeriods(start_, - maturity_, - config_.convention_.domesticLeg_, - config_.convention_.domesticIndex_.fixingLag_, - config_.convention_.domesticIndex_.fixingHolidays_); - const auto foreignPeriods = BuildLegPeriods(start_, - maturity_, - config_.convention_.foreignLeg_, - config_.convention_.foreignIndex_.fixingLag_, - config_.convention_.foreignIndex_.fixingHolidays_); - return Handle_(new CrossCurrencySwapRate_(maturity_, - config_.pair_, - config_.domesticNotional_, - config_.foreignNotional_, - domesticPeriods, - foreignPeriods, - config_.convention_.domesticIndex_, - config_.convention_.foreignIndex_, - config_.convention_)); + const auto plan = BuildXccyCashflowPlan(start_, maturity_, config_); + return Handle_(new CrossCurrencySwapKernelRate_(plan)); } } // namespace Dal diff --git a/dal-cpp/dal/curve/xccypricing.cpp b/dal-cpp/dal/curve/xccypricing.cpp index e35be131a..0a58cc292 100644 --- a/dal-cpp/dal/curve/xccypricing.cpp +++ b/dal-cpp/dal/curve/xccypricing.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include namespace Dal { @@ -64,6 +65,52 @@ namespace Dal { requests->push_back({period.rateIndexName_, period.rateFixingTime_}); } } + template T_ DiscountFromValuation(const Tape::DiscountCurve_& curve, const DateTime_& valuationTime, const Date_& date) { + if (date == valuationTime.Date()) + return T_(1.0); + return curve(valuationTime.Date(), date); + } + + template + T_ ResolveObservedValue(const String_& indexName, + const DateTime_& fixingTime, + const DateTime_& valuationTime, + const MarketFixingSnapshot_& fixings, + const String_& context, + F_&& activeValue) { + if (fixingTime < valuationTime) + return T_(fixings.Require(indexName, fixingTime, context)); + if (fixingTime == valuationTime) { + const std::optional supplied = fixings.Find(indexName, fixingTime); + if (supplied) + return T_(*supplied); + } + return activeValue(); + } + + template T_ ActiveFxForward(const XccyCashflowPlan_& plan, const XccyMarketView_& market, const DateTime_& fixingTime) { + const auto& domesticDiscount = market.domestic_->Discount(plan.config_.convention_.domesticIndex_.collateral_); + const auto& foreignDiscount = market.foreign_->Discount(plan.config_.convention_.foreignIndex_.collateral_); + REQUIRE(domesticDiscount.ccy_ == plan.config_.pair_.domestic_ && foreignDiscount.ccy_ == plan.config_.pair_.foreign_, + "XCCY FX forward curves do not match the configured currency pair"); + const T_ domesticDf = DiscountFromValuation(domesticDiscount, market.valuationTime_, fixingTime.Date()); + const T_ foreignDf = DiscountFromValuation(foreignDiscount, market.valuationTime_, fixingTime.Date()); + const T_ basisDf = market.basis_ ? DiscountFromValuation(*market.basis_, market.valuationTime_, fixingTime.Date()) : T_(1.0); + REQUIRE(Dal::AAD::Value(domesticDf) > 0.0 && Dal::AAD::Value(foreignDf) > 0.0 && Dal::AAD::Value(basisDf) > 0.0, + "XCCY FX forward requires positive discount factors"); + return market.fxSpot_ * foreignDf / (domesticDf * basisDf); + } + + template void ValidateMarketView(const XccyCashflowPlan_& plan, const XccyMarketView_& market) { + REQUIRE(market.valuationTime_.IsValid(), "XCCY pricing requires a valid valuation time"); + REQUIRE(market.pair_ == plan.config_.pair_, "XCCY pricing market pair does not match the cashflow plan"); + REQUIRE(market.collateralCurrency_ == plan.config_.pair_.domestic_, "XCCY pricing supports domestic-currency collateral only"); + REQUIRE(market.domestic_ && market.foreign_, "XCCY pricing requires domestic and foreign curve blocks"); + REQUIRE(Dal::AAD::Value(market.fxSpot_) > 0.0, "XCCY pricing requires a positive FX spot"); + REQUIRE(!market.basis_ || market.basis_->ccy_ == plan.config_.pair_.domestic_, + "XCCY basis curve currency must match the domestic currency"); + } + } // namespace XccyCashflowPlan_ BuildXccyCashflowPlan(const Date_& start, const Date_& maturity, const CrossCurrencySwapConfig_& config) { @@ -110,4 +157,151 @@ namespace Dal { result.erase(std::unique(result.begin(), result.end(), SameRequest), result.end()); return result; } + template + XccyResolvedNotionals_ + ResolveXccyNotionals(const XccyCashflowPlan_& plan, const XccyMarketView_& market, const MarketFixingSnapshot_& fixings) { + ValidateMarketView(plan, market); + XccyResolvedNotionals_ result; + result.domesticNotionals_ = Vector_(plan.domesticPeriods_.size(), T_(plan.config_.domesticNotional_)); + if (plan.config_.notionalMode_ == XccyNotionalMode_::Value_::FIXED) + return result; + + const String_ fxIndexName = FxIndexName(plan.config_.pair_); + for (int i = 0; i < static_cast(plan.resets_.size()); ++i) { + const auto& reset = plan.resets_[i]; + REQUIRE(reset.domesticPeriodIndex_ == i + 1 && reset.domesticPeriodIndex_ < static_cast(result.domesticNotionals_.size()), + "XCCY reset events must map consecutively from the second domestic period"); + const T_ fx = ResolveObservedValue(fxIndexName, reset.fxFixingTime_, market.valuationTime_, fixings, "XCCY domestic notional reset", + [&]() { return ActiveFxForward(plan, market, reset.fxFixingTime_); }); + const T_ newNotional = T_(plan.config_.foreignNotional_) * fx; + const T_ previousNotional = result.domesticNotionals_[reset.domesticPeriodIndex_ - 1]; + result.domesticNotionals_[reset.domesticPeriodIndex_] = newNotional; + if (plan.config_.notionalMode_ == XccyNotionalMode_::Value_::MARK_TO_MARKET) + result.mtmDeltas_.push_back(newNotional - previousNotional); + } + return result; + } + + template XccyResolvedNotionals_ + ResolveXccyNotionals(const XccyCashflowPlan_&, const XccyMarketView_&, const MarketFixingSnapshot_&); + template XccyResolvedNotionals_ + ResolveXccyNotionals(const XccyCashflowPlan_&, const XccyMarketView_&, const MarketFixingSnapshot_&); + +} // namespace Dal + +namespace Dal { + namespace { + template + const Tape::DiscountCurve_& ForecastCurve(const Tape::JointCurveBlock_& block, const RateIndexConvention_& convention) { + return convention.useProjectionCurve_ ? block.Forward(convention.forecastTenor_, convention.collateral_) + : block.Discount(convention.collateral_); + } + + template T_ ActiveForwardRate(const XccyCouponPeriod_& period, const Tape::DiscountCurve_& forecast) { + const T_ df = forecast(period.schedule_.accrualStart_, period.schedule_.accrualEnd_); + REQUIRE(Dal::AAD::Value(df) > 0.0 && period.accrual_.dcf_ > 0.0, + "XCCY floating coupon requires positive forecast discount factor and accrual fraction"); + return (T_(1.0) / df - T_(1.0)) / static_cast(period.accrual_.dcf_); + } + + template + T_ CouponRate(const XccyCouponPeriod_& period, + const Tape::DiscountCurve_& forecast, + const XccyMarketView_& market, + const MarketFixingSnapshot_& fixings, + const String_& context) { + return ResolveObservedValue(period.rateIndexName_, period.rateFixingTime_, market.valuationTime_, fixings, context, + [&]() { return ActiveForwardRate(period, forecast); }); + } + + template + T_ ForeignConversionFactor(const Tape::DiscountCurve_& foreignDiscount, const XccyMarketView_& market, const Date_& paymentDate) { + const T_ foreignDf = DiscountFromValuation(foreignDiscount, market.valuationTime_, paymentDate); + const T_ basisDf = market.basis_ ? DiscountFromValuation(*market.basis_, market.valuationTime_, paymentDate) : T_(1.0); + REQUIRE(Dal::AAD::Value(foreignDf) > 0.0 && Dal::AAD::Value(basisDf) > 0.0, + "XCCY foreign cashflow conversion requires positive discount factors"); + return market.fxSpot_ * foreignDf / basisDf; + } + } // namespace + + template + T_ PriceXccyParSpread(const XccyCashflowPlan_& plan, const XccyMarketView_& market, const MarketFixingSnapshot_& fixings) { + ValidateMarketView(plan, market); + REQUIRE(!plan.domesticPeriods_.empty() && !plan.foreignPeriods_.empty(), "XCCY pricing requires coupon periods on both legs"); + const bool fixedNotional = plan.config_.notionalMode_ == XccyNotionalMode_::Value_::FIXED; + XccyResolvedNotionals_ resolved; + if (!fixedNotional) + resolved = ResolveXccyNotionals(plan, market, fixings); + const auto domesticNotional = [&](int periodIndex) { + return fixedNotional ? T_(plan.config_.domesticNotional_) : resolved.domesticNotionals_[periodIndex]; + }; + const auto& convention = plan.config_.convention_; + const auto& domesticDiscount = market.domestic_->Discount(convention.domesticIndex_.collateral_); + const auto& foreignDiscount = market.foreign_->Discount(convention.foreignIndex_.collateral_); + const auto& domesticForecast = ForecastCurve(*market.domestic_, convention.domesticIndex_); + const auto& foreignForecast = ForecastCurve(*market.foreign_, convention.foreignIndex_); + REQUIRE(domesticDiscount.ccy_ == plan.config_.pair_.domestic_ && foreignDiscount.ccy_ == plan.config_.pair_.foreign_, + "XCCY discount curves do not match the configured currency pair"); + + const Date_ valuationDate = market.valuationTime_.Date(); + T_ domesticPv(0.0); + T_ foreignPv(0.0); + T_ domesticSpreadAnnuity(0.0); + T_ foreignSpreadAnnuity(0.0); + + for (int i = 0; i < static_cast(plan.domesticPeriods_.size()); ++i) { + const auto& period = plan.domesticPeriods_[i]; + if (period.schedule_.paymentDate_ < valuationDate) + continue; + const T_ rate = CouponRate(period, domesticForecast, market, fixings, "XCCY domestic floating coupon"); + const T_ df = DiscountFromValuation(domesticDiscount, market.valuationTime_, period.schedule_.paymentDate_); + const T_ annuity = domesticNotional(i) * static_cast(period.accrual_.dcf_) * df; + domesticPv += rate * annuity; + domesticSpreadAnnuity += annuity; + } + + for (const auto& period : plan.foreignPeriods_) { + if (period.schedule_.paymentDate_ < valuationDate) + continue; + const T_ rate = CouponRate(period, foreignForecast, market, fixings, "XCCY foreign floating coupon"); + const T_ annuity = T_(plan.config_.foreignNotional_) * static_cast(period.accrual_.dcf_) * + ForeignConversionFactor(foreignDiscount, market, period.schedule_.paymentDate_); + foreignPv += rate * annuity; + foreignSpreadAnnuity += annuity; + } + + if (convention.initialNotionalExchange_) { + const Date_ domesticStart = plan.domesticPeriods_.front().schedule_.accrualStart_; + if (domesticStart >= valuationDate) + domesticPv -= domesticNotional(0) * DiscountFromValuation(domesticDiscount, market.valuationTime_, domesticStart); + const Date_ foreignStart = plan.foreignPeriods_.front().schedule_.accrualStart_; + if (foreignStart >= valuationDate) + foreignPv -= T_(plan.config_.foreignNotional_) * ForeignConversionFactor(foreignDiscount, market, foreignStart); + } + + if (plan.config_.notionalMode_ == XccyNotionalMode_::Value_::MARK_TO_MARKET) { + REQUIRE(resolved.mtmDeltas_.size() == plan.resets_.size(), "XCCY MTM delta count does not match reset events"); + for (int i = 0; i < static_cast(plan.resets_.size()); ++i) { + if (plan.resets_[i].effectiveDate_ < valuationDate) + continue; + domesticPv -= resolved.mtmDeltas_[i] * DiscountFromValuation(domesticDiscount, market.valuationTime_, plan.resets_[i].effectiveDate_); + } + } + + if (convention.finalNotionalExchange_ && plan.maturity_ >= valuationDate) { + domesticPv += domesticNotional(static_cast(plan.domesticPeriods_.size()) - 1) * + DiscountFromValuation(domesticDiscount, market.valuationTime_, plan.maturity_); + foreignPv += T_(plan.config_.foreignNotional_) * ForeignConversionFactor(foreignDiscount, market, plan.maturity_); + } + + if (convention.spreadOnForeignLeg_) { + REQUIRE(Dal::AAD::Value(foreignSpreadAnnuity) > 0.0, "XCCY pricing requires a positive remaining foreign spread annuity"); + return (domesticPv - foreignPv) / foreignSpreadAnnuity; + } + REQUIRE(Dal::AAD::Value(domesticSpreadAnnuity) > 0.0, "XCCY pricing requires a positive remaining domestic spread annuity"); + return (foreignPv - domesticPv) / domesticSpreadAnnuity; + } + + template double PriceXccyParSpread(const XccyCashflowPlan_&, const XccyMarketView_&, const MarketFixingSnapshot_&); + template Dal::AAD::Number_ PriceXccyParSpread(const XccyCashflowPlan_&, const XccyMarketView_&, const MarketFixingSnapshot_&); } // namespace Dal diff --git a/dal-cpp/dal/curve/xccypricing.hpp b/dal-cpp/dal/curve/xccypricing.hpp index 2219cdc73..8077e4a84 100644 --- a/dal-cpp/dal/curve/xccypricing.hpp +++ b/dal-cpp/dal/curve/xccypricing.hpp @@ -6,6 +6,7 @@ #include +#include #include #include #include @@ -37,4 +38,25 @@ namespace Dal { XccyCashflowPlan_ BuildXccyCashflowPlan(const Date_& start, const Date_& maturity, const CrossCurrencySwapConfig_& config); Vector_ RequiredHistoricalFixings(const XccyCashflowPlan_& plan, const DateTime_& valuationTime); + template struct XccyMarketView_ { + DateTime_ valuationTime_; + CurrencyPair_ pair_; + Ccy_ collateralCurrency_; + T_ fxSpot_; + const Tape::JointCurveBlock_* domestic_ = nullptr; + const Tape::JointCurveBlock_* foreign_ = nullptr; + const Tape::DiscountCurve_* basis_ = nullptr; + }; + + template struct XccyResolvedNotionals_ { + Vector_ domesticNotionals_; + Vector_ mtmDeltas_; + }; + + template + XccyResolvedNotionals_ + ResolveXccyNotionals(const XccyCashflowPlan_& plan, const XccyMarketView_& market, const MarketFixingSnapshot_& fixings); + + template T_ PriceXccyParSpread(const XccyCashflowPlan_& plan, const XccyMarketView_& market, const MarketFixingSnapshot_& fixings); + } // namespace Dal diff --git a/dal-cpp/tests/curve/test_xccymarket.cpp b/dal-cpp/tests/curve/test_xccymarket.cpp index 1dda0a01c..76ea95300 100644 --- a/dal-cpp/tests/curve/test_xccymarket.cpp +++ b/dal-cpp/tests/curve/test_xccymarket.cpp @@ -4,34 +4,29 @@ #include -#include #include -#include #include -#include #include +#include #include -#include #include +#include #include #include +#include +#include +#include using namespace Dal; namespace { - Handle_ MakeFlatCurve(const String_& name, - const String_& ccy, - const Date_& today, - double rate) { + Handle_ MakeFlatCurve(const String_& name, const String_& ccy, const Date_& today, double rate) { const Vector_ knots = {Date::AddMonths(today, 12), Date::AddMonths(today, 24)}; const Vector_<> vals(knots.size(), rate); return Handle_(NewDiscountPWLF(name, ccy, PiecewiseLinear_(knots, vals, vals))); } - Handle_ MakeBlock(const String_& name, - const String_& ccy, - const Date_& today, - double rate) { + Handle_ MakeBlock(const String_& name, const String_& ccy, const Date_& today, double rate) { return Handle_(new CurveBlock_(MakeFlatCurve(name, ccy, today, rate))); } @@ -84,13 +79,7 @@ namespace { } CrossCurrencySwap_ MakeSwap(const Date_& today, double quotedSpread = 0.0) { - return CrossCurrencySwap_(today, - today, - Date::AddMonths(today, 12), - quotedSpread, - CurrencyPair_(Ccy_("USD"), Ccy_("EUR")), - 110.0, - 100.0, + return CrossCurrencySwap_(today, today, Date::AddMonths(today, 12), quotedSpread, CurrencyPair_(Ccy_("USD"), Ccy_("EUR")), 110.0, 100.0, MakeConvention()); } } // namespace @@ -101,9 +90,8 @@ TEST(XccyMarketTest, TestFxForwardParityUsesDiscountCurves) { const Date_ maturity = Date::AddMonths(today, 12); const auto market = MakeMarket(today); - const double expected = 1.10 - * market.ForeignDiscountCurve(CollateralType_(CollateralType_::Value_::OIS))(today, maturity) - / market.DomesticDiscountCurve(CollateralType_(CollateralType_::Value_::OIS))(today, maturity); + const double expected = 1.10 * market.ForeignDiscountCurve(CollateralType_(CollateralType_::Value_::OIS))(today, maturity) / + market.DomesticDiscountCurve(CollateralType_(CollateralType_::Value_::OIS))(today, maturity); ASSERT_NEAR(market.FxForward(maturity), expected, 1e-10); ASSERT_NEAR(market.FxForward(today, maturity, CollateralType_(CollateralType_::Value_::OIS)), expected, 1e-10); } @@ -235,38 +223,38 @@ TEST(XccyMarketTest, TestSwapPricingRejectsMismatchedPair) { CrossCurrencyConvention_ convention = MakeConvention(); - const CrossCurrencySwap_ mismatchedSwap(today, - today, - Date::AddMonths(today, 12), - 0.0, - CurrencyPair_(Ccy_("USD"), Ccy_("GBP")), - 110.0, - 100.0, + const CrossCurrencySwap_ mismatchedSwap(today, today, Date::AddMonths(today, 12), 0.0, CurrencyPair_(Ccy_("USD"), Ccy_("GBP")), 110.0, 100.0, convention); const auto rate = mismatchedSwap.Precompute(); ASSERT_THROW(static_cast((*rate)(market)), Dal::Exception_); } -TEST(XccyMarketTest, TestSwapPricingRejectsInProgressSwap) { +TEST(XccyMarketTest, TestSwapPricingInProgressUsesHistoricalFixings) { const Date_ tradeDate(2024, 1, 15); const Date_ start = tradeDate; const Date_ maturity = Date::AddMonths(start, 12); - - // Evaluate after the swap has started accruing. const Date_ evaluationDate = Date::AddMonths(start, 3); const auto evalDate = XGLOBAL::SetEvaluationDateInScope(evaluationDate); const auto market = MakeMarket(evaluationDate); - const CrossCurrencySwap_ swap(tradeDate, - start, - maturity, - 0.0, - CurrencyPair_(Ccy_("USD"), Ccy_("EUR")), - 110.0, - 100.0, - MakeConvention()); - const auto rate = swap.Precompute(); - ASSERT_THROW(static_cast((*rate)(market)), Dal::Exception_); + CrossCurrencySwapConfig_ config; + config.pair_ = CurrencyPair_(Ccy_("USD"), Ccy_("EUR")); + config.domesticNotional_ = 110.0; + config.foreignNotional_ = 100.0; + config.convention_ = MakeConvention(); + config.domesticRateFixing_ = {"USD-XCCY-IN-PROGRESS", 11, 0}; + config.foreignRateFixing_ = {"EUR-XCCY-IN-PROGRESS", 11, 0}; + + const DateTime_ fixingTime(start, 11, 0); + FixHistory_ domesticHistory; + domesticHistory.vals_ = {{fixingTime, 0.04}}; + XGLOBAL::StoreFixings(config.domesticRateFixing_.indexName_, domesticHistory, false); + FixHistory_ foreignHistory; + foreignHistory.vals_ = {{fixingTime, 0.03}}; + XGLOBAL::StoreFixings(config.foreignRateFixing_.indexName_, foreignHistory, false); + + const CrossCurrencySwap_ swap(tradeDate, start, maturity, 0.0, config); + ASSERT_TRUE(std::isfinite((*swap.Precompute())(market))); } TEST(XccyMarketTest, TestParSpreadAnchoredToEvaluationDateNotTradeDate) { @@ -280,14 +268,7 @@ TEST(XccyMarketTest, TestParSpreadAnchoredToEvaluationDateNotTradeDate) { CrossCurrencyConvention_ convention = MakeConvention(); auto makeSwap = [&](const Date_& tradeDate) { - return CrossCurrencySwap_(tradeDate, - start, - maturity, - 0.0, - CurrencyPair_(Ccy_("USD"), Ccy_("EUR")), - 110.0, - 100.0, - convention); + return CrossCurrencySwap_(tradeDate, start, maturity, 0.0, CurrencyPair_(Ccy_("USD"), Ccy_("EUR")), 110.0, 100.0, convention); }; // Pricing must value all cashflows as of the market evaluation date, so the @@ -312,14 +293,7 @@ TEST(XccyMarketTest, TestForwardStartingParFloatersHaveZeroParSpread) { // the accrual start. const Date_ start = Date::AddMonths(today, 6); const Date_ maturity = Date::AddMonths(today, 18); - const CrossCurrencySwap_ forwardStarting(today, - start, - maturity, - 0.0, - CurrencyPair_(Ccy_("USD"), Ccy_("EUR")), - 110.0, - 100.0, - MakeConvention()); + const CrossCurrencySwap_ forwardStarting(today, start, maturity, 0.0, CurrencyPair_(Ccy_("USD"), Ccy_("EUR")), 110.0, 100.0, MakeConvention()); ASSERT_GT(start, today); ASSERT_NEAR((*forwardStarting.Precompute())(market), 0.0, 1e-10); @@ -348,12 +322,7 @@ TEST(XccyMarketTest, TestCrossCurrencyCalibrationRepricesInputQuote) { TEST(XccyMarketTest, TestResetConfigRequiresExplicitFixingIdentity) { CrossCurrencySwapConfig_ config; config.notionalMode_ = XccyNotionalMode_::Value_::RESETTABLE; - ASSERT_THROW(static_cast(CrossCurrencySwap_(Date_(2024, 1, 2), - Date_(2024, 1, 4), - Date_(2025, 1, 4), - 0.0, - config)), - Dal::Exception_); + ASSERT_THROW(static_cast(CrossCurrencySwap_(Date_(2024, 1, 2), Date_(2024, 1, 4), Date_(2025, 1, 4), 0.0, config)), Dal::Exception_); } TEST(XccyMarketTest, TestCalibrationWithSingleKnotAndZeroMarketRate) { @@ -385,14 +354,7 @@ TEST(XccyMarketTest, TestCalibrationWithSingleKnotAndZeroMarketRate) { Vector_> instruments; const Date_ maturity = Date::AddMonths(today, 12); - Handle_ swap(new CrossCurrencySwap_(today, - today, - maturity, - 0.0, - pair, - 110.0, - 100.0, - convention)); + Handle_ swap(new CrossCurrencySwap_(today, today, maturity, 0.0, pair, 110.0, 100.0, convention)); instruments.push_back(swap); CrossCurrencyCalibrationSpec_ spec = MakeCalibrationSpec(today, pair); @@ -437,14 +399,7 @@ TEST(XccyMarketTest, TestCalibrationConvergesForLargerSpread) { const Date_ maturity = Date::AddMonths(today, 12); Vector_> instruments; - instruments.push_back(Handle_(new CrossCurrencySwap_(today, - today, - maturity, - 0.0050, - pair, - 110.0, - 100.0, - convention))); + instruments.push_back(Handle_(new CrossCurrencySwap_(today, today, maturity, 0.0050, pair, 110.0, 100.0, convention))); CrossCurrencyCalibrationSpec_ spec = MakeCalibrationSpec(today, pair); spec.instruments_ = instruments; @@ -497,9 +452,7 @@ TEST(XccyMarketTest, TestCalibrationWithMultipleInstrumentsTermStructure) { trueMarket.SetBasisCurve(Handle_(NewDiscountPWC("true_basis", "USD", PiecewiseConstant_(trueKnots, trueRates)))); // Create instruments and read their par spreads from the true market - auto makeSwap = [&](const Date_& maturity) { - return CrossCurrencySwap_(today, today, maturity, 0.0, pair, 110.0, 100.0, convention); - }; + auto makeSwap = [&](const Date_& maturity) { return CrossCurrencySwap_(today, today, maturity, 0.0, pair, 110.0, 100.0, convention); }; const auto swap1Y = makeSwap(maturity1Y); const auto swap2Y = makeSwap(maturity2Y); @@ -511,11 +464,9 @@ TEST(XccyMarketTest, TestCalibrationWithMultipleInstrumentsTermStructure) { // Calibrate using the observed quotes CrossCurrencyCalibrationSpec_ spec = MakeCalibrationSpec(today, pair); - spec.instruments_ = { - Handle_(new CrossCurrencySwap_(today, today, maturity1Y, quote1Y, pair, 110.0, 100.0, convention)), - Handle_(new CrossCurrencySwap_(today, today, maturity2Y, quote2Y, pair, 110.0, 100.0, convention)), - Handle_(new CrossCurrencySwap_(today, today, maturity3Y, quote3Y, pair, 110.0, 100.0, convention)) - }; + spec.instruments_ = {Handle_(new CrossCurrencySwap_(today, today, maturity1Y, quote1Y, pair, 110.0, 100.0, convention)), + Handle_(new CrossCurrencySwap_(today, today, maturity2Y, quote2Y, pair, 110.0, 100.0, convention)), + Handle_(new CrossCurrencySwap_(today, today, maturity3Y, quote3Y, pair, 110.0, 100.0, convention))}; spec.knotDates_ = trueKnots; spec.smoothingWeight_ = 1.0; spec.tolerance_ = 1e-10; @@ -609,3 +560,28 @@ TEST(XccyMarketTest, TestCalibrationRejectsInvalidSpec) { ASSERT_THROW(static_cast(CalibrateCrossCurrencyMarket(spec)), Dal::Exception_); } } + +TEST(XccyMarketTest, TestMtmSwapPricingUsesResetKernel) { + const Date_ today(2024, 1, 15); + const auto evalDate = XGLOBAL::SetEvaluationDateInScope(today); + const auto market = MakeMarket(today); + + CrossCurrencySwapConfig_ config; + config.pair_ = CurrencyPair_(Ccy_("USD"), Ccy_("EUR")); + config.domesticNotional_ = 110.0; + config.foreignNotional_ = 100.0; + config.convention_ = MakeConvention(); + config.convention_.domesticLeg_.paymentFrequency_ = PeriodLength_("3M"); + config.convention_.foreignLeg_.paymentFrequency_ = PeriodLength_("3M"); + config.convention_.domesticIndex_.forecastTenor_ = PeriodLength_("3M"); + config.convention_.foreignIndex_.forecastTenor_ = PeriodLength_("3M"); + config.notionalMode_ = XccyNotionalMode_::Value_::MARK_TO_MARKET; + config.fxReset_.fixingLag_ = 0; + config.fxReset_.fixingHour_ = 11; + config.fxReset_.fixingMinute_ = 0; + config.domesticRateFixing_ = {"USD-XCCY-MTM", 11, 0}; + config.foreignRateFixing_ = {"EUR-XCCY-MTM", 11, 0}; + + const CrossCurrencySwap_ swap(today, today, Date::AddMonths(today, 12), 0.0, config); + ASSERT_TRUE(std::isfinite((*swap.Precompute())(market))); +} diff --git a/dal-cpp/tests/curve/test_xccypricing.cpp b/dal-cpp/tests/curve/test_xccypricing.cpp index 0f53df542..39eea31fa 100644 --- a/dal-cpp/tests/curve/test_xccypricing.cpp +++ b/dal-cpp/tests/curve/test_xccypricing.cpp @@ -4,7 +4,10 @@ #include +#include +#include #include +#include using namespace Dal; @@ -46,6 +49,49 @@ namespace { } return false; } + class ConstantDiscountCurve_ : public DiscountCurve_ { + double value_; + + public: + ConstantDiscountCurve_(const String_& name, const String_& ccy, double value) : DiscountCurve_(name, ccy), value_(value) {} + + double operator()(const Date_&, const Date_&) const override { return value_; } + void Poll(Vector_* all) const override { all->push_back(this); } + void Poll(std::map>*) const override {} + [[nodiscard]] ConstantDiscountCurve_* Clone(const String_& newName, const YCComponent_::substitutions_t&) const override { + return new ConstantDiscountCurve_(newName, ccy_.String(), value_); + } + void Write(Archive::Store_&) const override {} + }; + + struct PricingMarket_ { + ConstantDiscountCurve_ domestic_{"domestic", "USD", 1.0}; + ConstantDiscountCurve_ foreign_{"foreign", "EUR", 1.0}; + ConstantDiscountCurve_ basis_{"basis", "USD", 1.0}; + Tape::JointCurveBlock_ domesticBlock_; + Tape::JointCurveBlock_ foreignBlock_; + + PricingMarket_() { + const CollateralType_ ois(CollateralType_::Value_::OIS); + domesticBlock_.discountCurves[ois] = &domestic_; + domesticBlock_.forwardCurves[PeriodLength_("3M")] = &domestic_; + foreignBlock_.discountCurves[ois] = &foreign_; + foreignBlock_.forwardCurves[PeriodLength_("3M")] = &foreign_; + } + + XccyMarketView_ View(const DateTime_& valuationTime) const { + XccyMarketView_ result; + result.valuationTime_ = valuationTime; + result.pair_ = CurrencyPair_(Ccy_("USD"), Ccy_("EUR")); + result.collateralCurrency_ = Ccy_("USD"); + result.fxSpot_ = 1.10; + result.domestic_ = &domesticBlock_; + result.foreign_ = &foreignBlock_; + result.basis_ = &basis_; + return result; + } + }; + } // namespace TEST(XccyPricingTest, TestQuarterlyMtmPlanResetsFromSecondPeriod) { @@ -133,3 +179,171 @@ TEST(XccyPricingTest, TestResetPlanRequiresRateFixingIdentities) { ASSERT_THROW(static_cast(BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 1, 4), config)), Dal::Exception_); } +TEST(XccyPricingTest, TestPastFxResetRequiresHistoricalFixing) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 7, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET)); + const PricingMarket_ curves; + const auto market = curves.View(DateTime_(Date_(2024, 4, 3), 12, 0)); + const MarketFixingSnapshot_ fixings; + + ASSERT_THROW(static_cast(ResolveXccyNotionals(plan, market, fixings)), Dal::Exception_); +} + +TEST(XccyPricingTest, TestTwoPeriodMtmPrincipalCashflowsMatchHandCalculation) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET); + config.convention_.initialNotionalExchange_ = true; + config.convention_.finalNotionalExchange_ = true; + config.convention_.spreadOnForeignLeg_ = true; + config.domesticRateFixing_.fixingHour_ = 13; + config.foreignRateFixing_.fixingHour_ = 13; + config.fxReset_.fixingLag_ = 70; + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 8), Date_(2024, 7, 8), config); + + MarketFixingSnapshot_::values_t values; + values[FxIndexName(config.pair_)][plan.resets_[0].fxFixingTime_] = 1.20; + const MarketFixingSnapshot_ fixings(values); + const PricingMarket_ curves; + const auto market = curves.View(DateTime_(Date_(2024, 1, 4), 12, 0)); + + const auto resolved = ResolveXccyNotionals(plan, market, fixings); + ASSERT_NEAR(resolved.domesticNotionals_[0], 110.0, 1.0e-12); + ASSERT_NEAR(resolved.domesticNotionals_[1], 120.0, 1.0e-12); + ASSERT_EQ(resolved.mtmDeltas_.size(), 1U); + ASSERT_NEAR(resolved.mtmDeltas_[0], 10.0, 1.0e-12); + + const double explicitDomesticPrincipal = -110.0 - 10.0 + 120.0; + const double explicitForeignPrincipal = (-100.0 + 100.0) * 1.10; + const double explicitForeignAnnuity = 100.0 * (plan.foreignPeriods_[0].accrual_.dcf_ + plan.foreignPeriods_[1].accrual_.dcf_) * 1.10; + const double expectedParSpread = (explicitDomesticPrincipal - explicitForeignPrincipal) / explicitForeignAnnuity; + ASSERT_NEAR(PriceXccyParSpread(plan, market, fixings), expectedParSpread, 1.0e-12); +} + +namespace { + struct CustomPricingMarket_ { + ConstantDiscountCurve_ domestic_; + ConstantDiscountCurve_ foreign_; + ConstantDiscountCurve_ basis_; + Tape::JointCurveBlock_ domesticBlock_; + Tape::JointCurveBlock_ foreignBlock_; + double spot_; + + CustomPricingMarket_(double domesticDf, double foreignDf, double basisDf, double spot = 1.10) + : domestic_("domestic_custom", "USD", domesticDf), foreign_("foreign_custom", "EUR", foreignDf), basis_("basis_custom", "USD", basisDf), + spot_(spot) { + const CollateralType_ ois(CollateralType_::Value_::OIS); + domesticBlock_.discountCurves[ois] = &domestic_; + domesticBlock_.forwardCurves[PeriodLength_("3M")] = &domestic_; + foreignBlock_.discountCurves[ois] = &foreign_; + foreignBlock_.forwardCurves[PeriodLength_("3M")] = &foreign_; + } + + XccyMarketView_ View(const DateTime_& valuationTime) const { + XccyMarketView_ result; + result.valuationTime_ = valuationTime; + result.pair_ = CurrencyPair_(Ccy_("USD"), Ccy_("EUR")); + result.collateralCurrency_ = Ccy_("USD"); + result.fxSpot_ = spot_; + result.domestic_ = &domesticBlock_; + result.foreign_ = &foreignBlock_; + result.basis_ = &basis_; + return result; + } + }; +} // namespace + +TEST(XccyPricingTest, TestFxResetAtValuationUsesSuppliedFixing) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 7, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET)); + MarketFixingSnapshot_::values_t values; + values[FxIndexName(plan.config_.pair_)][plan.resets_[0].fxFixingTime_] = 1.25; + const MarketFixingSnapshot_ fixings(values); + const PricingMarket_ curves; + const auto market = curves.View(plan.resets_[0].fxFixingTime_); + + const auto resolved = ResolveXccyNotionals(plan, market, fixings); + ASSERT_NEAR(resolved.domesticNotionals_[1], 125.0, 1.0e-12); +} + +TEST(XccyPricingTest, TestFutureFxResetUsesActiveForward) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 7, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET)); + const DateTime_ valuationTime(Date_(2024, 4, 1), 12, 0); + const MarketFixingSnapshot_ fixings; + const CustomPricingMarket_ first(0.99, 0.98, 0.97); + const CustomPricingMarket_ second(0.99, 0.98, 0.90); + + const double firstNotional = ResolveXccyNotionals(plan, first.View(valuationTime), fixings).domesticNotionals_[1]; + const double secondNotional = ResolveXccyNotionals(plan, second.View(valuationTime), fixings).domesticNotionals_[1]; + ASSERT_NEAR(firstNotional, 100.0 * 1.10 * 0.98 / (0.99 * 0.97), 1.0e-12); + ASSERT_NE(firstNotional, secondNotional); +} + +TEST(XccyPricingTest, TestPastRateFixingRequiredForUnpaidCoupon) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 4, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED)); + const PricingMarket_ curves; + const auto market = curves.View(DateTime_(Date_(2024, 1, 3), 12, 0)); + const MarketFixingSnapshot_ fixings; + + ASSERT_THROW(static_cast(PriceXccyParSpread(plan, market, fixings)), Dal::Exception_); +} + +TEST(XccyPricingTest, TestRateFixingAtValuationUsesSuppliedValue) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED); + config.convention_.domesticIndex_.fixingLag_ = 0; + config.convention_.foreignIndex_.fixingLag_ = 0; + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 4, 4), config); + const DateTime_ valuationTime(Date_(2024, 1, 4), 11, 0); + MarketFixingSnapshot_::values_t values; + values[config.domesticRateFixing_.indexName_][valuationTime] = 0.05; + values[config.foreignRateFixing_.indexName_][valuationTime] = 0.02; + const MarketFixingSnapshot_ fixings(values); + const PricingMarket_ curves; + + ASSERT_NEAR(PriceXccyParSpread(plan, curves.View(valuationTime), fixings), 0.03, 1.0e-12); +} + +TEST(XccyPricingTest, TestFutureRateFixingUsesActiveCurve) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED); + config.convention_.domesticIndex_.fixingLag_ = 0; + config.convention_.foreignIndex_.fixingLag_ = 0; + config.domesticRateFixing_.fixingHour_ = 13; + config.foreignRateFixing_.fixingHour_ = 13; + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 4, 4), config); + const DateTime_ valuationTime(Date_(2024, 1, 4), 12, 0); + const MarketFixingSnapshot_ fixings; + const CustomPricingMarket_ first(0.99, 1.0, 1.0); + const CustomPricingMarket_ second(0.98, 1.0, 1.0); + + const double firstSpread = PriceXccyParSpread(plan, first.View(valuationTime), fixings); + const double secondSpread = PriceXccyParSpread(plan, second.View(valuationTime), fixings); + ASSERT_NE(firstSpread, secondSpread); +} + +TEST(XccyPricingTest, TestPaidCouponIsFilteredBeforeFixingLookup) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 7, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED)); + const DateTime_ valuationTime(Date_(2024, 4, 5), 12, 0); + MarketFixingSnapshot_::values_t values; + values[plan.config_.domesticRateFixing_.indexName_][plan.domesticPeriods_[1].rateFixingTime_] = 0.04; + values[plan.config_.foreignRateFixing_.indexName_][plan.foreignPeriods_[1].rateFixingTime_] = 0.03; + const MarketFixingSnapshot_ fixings(values); + const PricingMarket_ curves; + + ASSERT_NEAR(PriceXccyParSpread(plan, curves.View(valuationTime), fixings), 0.01, 1.0e-12); +} + +TEST(XccyPricingTest, TestValuationDatePaymentUsesUnitDiscountFactor) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 4, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED)); + const DateTime_ valuationTime(Date_(2024, 4, 4), 12, 0); + MarketFixingSnapshot_::values_t values; + values[plan.config_.domesticRateFixing_.indexName_][plan.domesticPeriods_[0].rateFixingTime_] = 0.05; + values[plan.config_.foreignRateFixing_.indexName_][plan.foreignPeriods_[0].rateFixingTime_] = 0.02; + const MarketFixingSnapshot_ fixings(values); + const CustomPricingMarket_ curves(0.50, 0.80, 0.90); + + ASSERT_NEAR(PriceXccyParSpread(plan, curves.View(valuationTime), fixings), 0.03, 1.0e-12); +} + +TEST(XccyPricingTest, TestMaturedTradeThrowsWithoutLookingUpPaidFixings) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 4, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED)); + const PricingMarket_ curves; + const MarketFixingSnapshot_ fixings; + + ASSERT_THROW(static_cast(PriceXccyParSpread(plan, curves.View(DateTime_(Date_(2024, 4, 5), 12, 0)), fixings)), Dal::Exception_); +} From 967dd20466e941c062f4221b7e7fe48fe9f28e51 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 01:35:21 +0800 Subject: [PATCH 08/38] feat(xccy): calibrate basis with reset-aware AAD pricing Add explicit valuation, collateral, and immutable fixing context to XCCY basis calibration. Route future and in-progress MTM instruments through the typed pricing kernel, provide analytic and bumped Jacobian options, and cover central-difference diagnostics. --- dal-cpp/dal/curve/curvejacobian.hpp | 3 +- dal-cpp/dal/curve/xccybasiscalibration.cpp | 271 ++++++++++++++++++ dal-cpp/dal/curve/xccycalibration.cpp | 203 ++----------- dal-cpp/dal/curve/xccycalibration.hpp | 30 +- dal-cpp/dal/curve/xccyinstrument.cpp | 7 +- dal-cpp/dal/curve/xccyinstrument.hpp | 1 + .../tests/curve/test_xccybasisjacobian.cpp | 204 +++++++++++++ dal-cpp/tests/curve/test_xccymarket.cpp | 78 +++++ 8 files changed, 606 insertions(+), 191 deletions(-) create mode 100644 dal-cpp/dal/curve/xccybasiscalibration.cpp create mode 100644 dal-cpp/tests/curve/test_xccybasisjacobian.cpp diff --git a/dal-cpp/dal/curve/curvejacobian.hpp b/dal-cpp/dal/curve/curvejacobian.hpp index d1c096c82..12ecb8709 100644 --- a/dal-cpp/dal/curve/curvejacobian.hpp +++ b/dal-cpp/dal/curve/curvejacobian.hpp @@ -4,7 +4,7 @@ #pragma once -#include +#include #include #include #include @@ -12,6 +12,7 @@ #include #include #include +#include namespace Dal { struct XCurveJacobian_ : Underdetermined::Jacobian_ { diff --git a/dal-cpp/dal/curve/xccybasiscalibration.cpp b/dal-cpp/dal/curve/xccybasiscalibration.cpp new file mode 100644 index 000000000..aa44d27d4 --- /dev/null +++ b/dal-cpp/dal/curve/xccybasiscalibration.cpp @@ -0,0 +1,271 @@ +// +// Created by Codex on 2026/7/13. +// + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Dal { + namespace { + DateTime_ ResolveValuationTime(const CrossCurrencyCalibrationSpec_& spec) { + if (spec.valuationTime_.IsValid()) { + if (spec.today_.IsValid()) + REQUIRE(spec.today_ == spec.valuationTime_.Date(), "Cross-currency calibration today must match the explicit valuation date"); + return spec.valuationTime_; + } + REQUIRE(spec.today_.IsValid(), "Cross-currency calibration requires today or an explicit valuation time"); + return DateTime_(spec.today_); + } + + Ccy_ ResolveCollateralCurrency(const CrossCurrencyCalibrationSpec_& spec) { + return spec.collateralCurrency_.Switch() == Ccy_::Value_::_NOT_SET ? spec.basisPair_.domestic_ : spec.collateralCurrency_; + } + + Handle_ ResolveFixings(const CrossCurrencyCalibrationSpec_& spec) { + return spec.fixings_ ? spec.fixings_ : Handle_(new MarketFixingSnapshot_()); + } + + CurveDefinition_ BasisDefinition(const CrossCurrencyCalibrationSpec_& spec, const DateTime_& valuationTime) { + return MakeCurveDefinition(String_("xccy_basis_") + spec.basisPair_.domestic_.String(), spec.basisPair_.domestic_.String(), + CurveParameterization_(CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD), + LogDfScheme_(LogDfScheme_::Value_::LOG_LINEAR), spec.knotDates_, valuationTime.Date(), DayBasis_("ACT_365F")); + } + + void ValidateSpec(const CrossCurrencyCalibrationSpec_& spec, const DateTime_& valuationTime, const Ccy_& collateralCurrency) { + REQUIRE(spec.domesticCurveBlock_, "Cross-currency calibration requires a domestic curve block"); + REQUIRE(spec.foreignCurveBlock_, "Cross-currency calibration requires a foreign curve block"); + REQUIRE(std::isfinite(spec.fxSpot_) && spec.fxSpot_ > 0.0, "Cross-currency calibration requires a positive finite FX spot"); + REQUIRE(spec.basisPair_.domestic_ == spec.domesticCurveBlock_->ccy_, + "Cross-currency calibration basis pair domestic currency must match the domestic curve block"); + REQUIRE(spec.basisPair_.foreign_ == spec.foreignCurveBlock_->ccy_, + "Cross-currency calibration basis pair foreign currency must match the foreign curve block"); + REQUIRE(collateralCurrency == spec.basisPair_.domestic_, "Cross-currency calibration supports domestic-currency collateral only"); + REQUIRE(!spec.instruments_.empty(), "Cross-currency calibration requires at least one instrument"); + REQUIRE(!spec.knotDates_.empty(), "Cross-currency calibration requires at least one basis knot date"); + REQUIRE(spec.smoothingWeight_ > 0.0, "Cross-currency calibration smoothing weight must be positive"); + REQUIRE(spec.tolerance_ > 0.0, "Cross-currency calibration tolerance must be positive"); + REQUIRE(spec.fitTolerance_ > 0.0, "Cross-currency calibration fit tolerance must be positive"); + REQUIRE(spec.maxEvaluations_ > 0, "Cross-currency calibration max evaluations must be positive"); + REQUIRE(spec.maxRestarts_ > 0, "Cross-currency calibration max restarts must be positive"); + REQUIRE(std::isfinite(spec.initialGuess_), "Cross-currency calibration initial guess must be finite"); + REQUIRE(spec.knotDates_.front() > valuationTime.Date(), "Cross-currency calibration basis knot dates must be after the valuation date"); + for (int i = 1; i < static_cast(spec.knotDates_.size()); ++i) + REQUIRE(spec.knotDates_[i] > spec.knotDates_[i - 1], "Cross-currency calibration basis knot dates must be strictly increasing"); + } + + template class PassiveDiscountCurve_ : public Tape::DiscountCurve_ { + const Dal::DiscountCurve_* source_; + + public: + explicit PassiveDiscountCurve_(const Dal::DiscountCurve_& source) + : Tape::DiscountCurve_(source.Name(), source.ccy_.String()), source_(&source) {} + + T_ operator()(const Date_& from, const Date_& to) const override { return T_((*source_)(from, to)); } + void Poll(Vector_* all) const override { all->push_back(this); } + void Poll(std::map>*) const override {} + [[nodiscard]] PassiveDiscountCurve_* Clone(const String_&, const YCComponent_::substitutions_t&) const override { + return new PassiveDiscountCurve_(*source_); + } + void Write(Archive::Store_&) const override {} + }; + + template struct TypedBlockStorage_ { + Tape::JointCurveBlock_ block_; + std::vector>> owned_; + }; + + template const Tape::DiscountCurve_* AddPassiveCurve(const DiscountCurve_& source, TypedBlockStorage_* storage) { + if constexpr (std::is_same_v) { + return &source; + } else { + storage->owned_.emplace_back(new PassiveDiscountCurve_(source)); + return storage->owned_.back().get(); + } + } + + template TypedBlockStorage_ BuildTypedBlock(const CurveBlock_& source, const RateIndexConvention_& convention) { + TypedBlockStorage_ result; + result.block_.discountCurves[convention.collateral_] = AddPassiveCurve(source.Discount(convention.collateral_), &result); + if (convention.useProjectionCurve_) + result.block_.forwardCurves[convention.forecastTenor_] = + AddPassiveCurve(source.Forward(convention.forecastTenor_, convention.collateral_), &result); + return result; + } + + class XccyBasisCalibrationFunc_ : public Underdetermined::Function_ { + DateTime_ valuationTime_; + CurrencyPair_ pair_; + Ccy_ collateralCurrency_; + Handle_ domesticBlock_; + Handle_ foreignBlock_; + double fxSpot_; + Handle_ fixings_; + Vector_ plans_; + Vector_<> marketRates_; + CurveDefinition_ basisDefinition_; + CurveJacobianMode_ jacobianMode_; + + template Vector_ Residuals(const Vector_& parameters) const { + const auto basis = BuildDiscountCurveT(basisDefinition_, parameters); + Vector_ result(plans_.size()); + for (int i = 0; i < static_cast(plans_.size()); ++i) { + const auto& plan = plans_[i]; + const auto& convention = plan.config_.convention_; + auto domestic = BuildTypedBlock(*domesticBlock_, convention.domesticIndex_); + auto foreign = BuildTypedBlock(*foreignBlock_, convention.foreignIndex_); + + XccyMarketView_ market; + market.valuationTime_ = valuationTime_; + market.pair_ = pair_; + market.collateralCurrency_ = collateralCurrency_; + market.fxSpot_ = T_(fxSpot_); + market.domestic_ = &domestic.block_; + market.foreign_ = &foreign.block_; + market.basis_ = basis.get(); + result[i] = PriceXccyParSpread(plan, market, *fixings_) - T_(marketRates_[i]); + } + return result; + } + + public: + XccyBasisCalibrationFunc_(const CrossCurrencyCalibrationSpec_& spec, + const DateTime_& valuationTime, + const Ccy_& collateralCurrency, + const Handle_& fixings, + const CurveDefinition_& basisDefinition, + CurveJacobianMode_ jacobianMode) + : valuationTime_(valuationTime), pair_(spec.basisPair_), collateralCurrency_(collateralCurrency), + domesticBlock_(spec.domesticCurveBlock_), foreignBlock_(spec.foreignCurveBlock_), fxSpot_(spec.fxSpot_), fixings_(fixings), + basisDefinition_(basisDefinition), jacobianMode_(jacobianMode) { + plans_.reserve(spec.instruments_.size()); + marketRates_.reserve(spec.instruments_.size()); + for (const auto& instrument : spec.instruments_) { + const auto span = instrument->TimeSpan(); + plans_.push_back(BuildXccyCashflowPlan(span.first, span.second, instrument->Config())); + marketRates_.push_back(instrument->MarketRate()); + } + } + + [[nodiscard]] Vector_<> F(const Vector_<>& x) const override { return Residuals(x); } + + [[nodiscard]] Underdetermined::Jacobian_* Gradient(const Vector_<>& x, const Vector_<>&) const override { + if (jacobianMode_ != CurveJacobianMode_::Value_::ANALYTIC) + return nullptr; + auto* tape = Dal::AAD::Tape(); + TapeGuard_ guard(tape); + Vector_ parameters = RegisterCurveParameters(x); + Dal::AAD::NewRecording(*tape); + Vector_ residuals = Residuals(parameters); + return new XCurveJacobian_(HarvestCurveJacobian(*tape, parameters, residuals)); + } + }; + + CrossCurrencyCalibrationDiagnostics_ BuildDiagnostics(const CrossCurrencyCalibrationSpec_& spec, + const XccyBasisCalibrationFunc_& func, + const Vector_<>& parameters, + bool usedApproximateFit, + const Matrix_<>* effJacobianInverse) { + CrossCurrencyCalibrationDiagnostics_ result; + result.usedApproximateFit_ = usedApproximateFit; + result.residuals_ = func.F(parameters); + for (int i = 0; i < static_cast(spec.instruments_.size()); ++i) { + result.instrumentNames_.push_back(spec.instruments_[i]->Name()); + result.marketRates_.push_back(spec.instruments_[i]->MarketRate()); + result.modelRates_.push_back(result.marketRates_.back() + result.residuals_[i]); + } + const ResidualStats_ stats = ResidualStats(result.residuals_); + result.maxAbsResidual_ = stats.maxAbsResidual_; + result.rmsResidual_ = stats.rmsResidual_; + if (effJacobianInverse) + result.effJacobianInverse_ = *effJacobianInverse; + return result; + } + + CrossCurrencyFxForwardCurve_ BuildFxForwardCurve(const CurrencyPair_& pair, + const Vector_& dates, + const CrossCurrencyMarket_& market, + const CollateralType_& collateral) { + CrossCurrencyFxForwardCurve_ result; + result.pair_ = pair; + result.dates_ = dates; + for (const auto& date : dates) + result.forwards_.push_back(market.FxForward(market.Today(), date, collateral)); + return result; + } + } // namespace + + CrossCurrencyCalibrationResult_ CalibrateCrossCurrencyMarket(const CrossCurrencyCalibrationSpec_& spec, + const CrossCurrencyCalibrationOptions_& options) { + const DateTime_ valuationTime = ResolveValuationTime(spec); + const Ccy_ collateralCurrency = ResolveCollateralCurrency(spec); + const Handle_ fixings = ResolveFixings(spec); + ValidateSpec(spec, valuationTime, collateralCurrency); + REQUIRE(options.jacobianMode_ == CurveJacobianMode_::Value_::ANALYTIC || options.jacobianMode_ == CurveJacobianMode_::Value_::BUMPED, + "Cross-currency calibration requires ANALYTIC or BUMPED Jacobian mode"); + + const CurveDefinition_ basisDefinition = BasisDefinition(spec, valuationTime); + const int parameterCount = BuildCurveParameterLayout(basisDefinition).parameterCount_; + REQUIRE(parameterCount == static_cast(spec.knotDates_.size()), + "Cross-currency basis calibration requires one piecewise-constant parameter per knot"); + + Vector_<> guess(parameterCount, spec.initialGuess_); + Vector_<> tolerance(spec.instruments_.size(), spec.tolerance_); + Vector_ knotDateTimes; + knotDateTimes.reserve(spec.knotDates_.size()); + for (const auto& date : spec.knotDates_) + knotDateTimes.push_back(DateTime_(date)); + std::unique_ptr weights(Underdetermined::WeightsPWC(knotDateTimes, spec.smoothingWeight_)); + + constexpr const char* KEY_MAX_EVALUATIONS = "MAXEVALUATIONS"; + constexpr const char* KEY_MAX_RESTARTS = "MAXRESTARTS"; + Dictionary_ controlsDictionary; + controlsDictionary.Insert(KEY_MAX_EVALUATIONS, Cell_(static_cast(spec.maxEvaluations_))); + controlsDictionary.Insert(KEY_MAX_RESTARTS, Cell_(static_cast(spec.maxRestarts_))); + UnderdeterminedControls_ controls(controlsDictionary); + + XccyBasisCalibrationFunc_ func(spec, valuationTime, collateralCurrency, fixings, basisDefinition, options.jacobianMode_); + const bool approximate = spec.solveMode_ == CurveSolveMode_::Value_::APPROXIMATE; + const bool wantForwardJacobian = + options.computeForwardJacobian_ && options.jacobianMode_ == CurveJacobianMode_::Value_::ANALYTIC && !approximate; + Vector_<> parameters; + Matrix_<> effJacobianInverse; + Matrix_<> forwardJacobian; + if (!approximate) { + std::unique_ptr decomposition(weights->DecomposeSymmetric()); + parameters = Underdetermined::Find(func, guess, tolerance, *decomposition, controls, + options.computeEffJacobianInverse_ ? &effJacobianInverse : nullptr, + wantForwardJacobian ? &forwardJacobian : nullptr); + } else { + parameters = Underdetermined::Approximate(func, guess, tolerance, spec.fitTolerance_, *weights, controls); + } + + Handle_ basisCurve(BuildDiscountCurveUniqueT(basisDefinition, parameters).release()); + CrossCurrencyMarket_ market(spec.domesticCurveBlock_, spec.foreignCurveBlock_, spec.fxSpot_, valuationTime, collateralCurrency, fixings); + market.SetBasisCurve(basisCurve); + + std::map> basisCurves; + basisCurves[spec.basisPair_] = basisCurve; + CrossCurrencyFxForwardCurve_ fxForwardCurve = BuildFxForwardCurve(spec.basisPair_, spec.knotDates_, market, spec.fxForwardCollateral_); + CrossCurrencyCalibrationDiagnostics_ diagnostics = + BuildDiagnostics(spec, func, parameters, approximate, !approximate && options.computeEffJacobianInverse_ ? &effJacobianInverse : nullptr); + diagnostics.jacobian_ = std::move(forwardJacobian); + return CrossCurrencyCalibrationResult_(market, basisCurves, fxForwardCurve, diagnostics); + } +} // namespace Dal diff --git a/dal-cpp/dal/curve/xccycalibration.cpp b/dal-cpp/dal/curve/xccycalibration.cpp index d0ef9e058..ceb083e5b 100644 --- a/dal-cpp/dal/curve/xccycalibration.cpp +++ b/dal-cpp/dal/curve/xccycalibration.cpp @@ -2,141 +2,36 @@ // Created by GitHub Copilot on 2026/6/6. // -#include -#include #include #include -#include -#include -#include + +#include #include -#include -#include -#include -#include #include -#include -#include namespace Dal { - namespace { - CrossCurrencyCalibrationDiagnostics_ BuildDiagnostics(const Vector_>& instruments, - const CrossCurrencyMarket_& market, - bool usedApproximateFit = false, - const Matrix_<>* effJacobianInverse = nullptr) { - CrossCurrencyCalibrationDiagnostics_ retval; - retval.usedApproximateFit_ = usedApproximateFit; - for (const auto& instrument : instruments) { - const double modelRate = (*instrument->Precompute())(market); - const double marketRate = instrument->MarketRate(); - retval.instrumentNames_.push_back(instrument->Name()); - retval.marketRates_.push_back(marketRate); - retval.modelRates_.push_back(modelRate); - retval.residuals_.push_back(modelRate - marketRate); - } - const ResidualStats_ stats = ResidualStats(retval.residuals_); - retval.maxAbsResidual_ = stats.maxAbsResidual_; - retval.rmsResidual_ = stats.rmsResidual_; - if (effJacobianInverse) - retval.effJacobianInverse_ = *effJacobianInverse; - return retval; - } - - Handle_ BuildBasisCurve(const String_& domesticCcy, - const Vector_& knotDates, - const Vector_<>& rates) { - REQUIRE(rates.size() == knotDates.size(), "Basis curve rates and knot dates must have the same size"); - return Handle_( - NewDiscountPWC(String_("xccy_basis_") + domesticCcy, - domesticCcy, - PiecewiseConstant_(knotDates, rates))); - } - - void ValidateCalibrationSpec(const CrossCurrencyCalibrationSpec_& spec) { - REQUIRE(spec.domesticCurveBlock_, "Cross-currency calibration requires a domestic curve block"); - REQUIRE(spec.foreignCurveBlock_, "Cross-currency calibration requires a foreign curve block"); - REQUIRE(spec.fxSpot_ > 0.0, "Cross-currency calibration requires a positive FX spot"); - REQUIRE(spec.basisPair_.domestic_ == spec.domesticCurveBlock_->ccy_, - "Cross-currency calibration basis pair domestic currency must match the domestic curve block"); - REQUIRE(spec.basisPair_.foreign_ == spec.foreignCurveBlock_->ccy_, - "Cross-currency calibration basis pair foreign currency must match the foreign curve block"); - } - - CrossCurrencyFxForwardCurve_ BuildFxForwardCurve(const CurrencyPair_& pair, - const Vector_& dates, - const CrossCurrencyMarket_& market, - const CollateralType_& collateral) { - CrossCurrencyFxForwardCurve_ retval; - retval.pair_ = pair; - retval.dates_ = dates; - for (const auto& date : dates) { - retval.forwards_.push_back(market.FxForward(market.Today(), date, collateral)); - } - return retval; - } - - Handle_ BuildBasisCurveInternal(const String_& domesticCcy, - const Vector_& knotDates, - const Vector_<>& rates) { - return BuildBasisCurve(domesticCcy, knotDates, rates); - } - } // namespace - - class XccyCalibrationFunc_ : public Underdetermined::Function_ { - Handle_ domesticBlock_; - Handle_ foreignBlock_; - double fxSpot_; - String_ domesticCcy_; - Vector_> instruments_; - Vector_> rates_; - Vector_<> marketRates_; - Vector_ knotDates_; - - public: - XccyCalibrationFunc_(const Handle_& domesticBlock, - const Handle_& foreignBlock, - double fxSpot, - const Vector_>& instruments, - const Vector_& knotDates) - : domesticBlock_(domesticBlock), - foreignBlock_(foreignBlock), - fxSpot_(fxSpot), - domesticCcy_(domesticBlock->ccy_.String()), - instruments_(instruments), - knotDates_(knotDates) { - rates_.reserve(instruments_.size()); - marketRates_.reserve(instruments_.size()); - for (const auto& inst : instruments_) { - rates_.push_back(inst->Precompute()); - marketRates_.push_back(inst->MarketRate()); - } - } - - [[nodiscard]] Vector_<> F(const Vector_<>& x) const override { - CrossCurrencyMarket_ market(domesticBlock_, foreignBlock_, fxSpot_); - market.SetBasisCurve(BuildBasisCurveInternal(domesticCcy_, knotDates_, x)); - - Vector_<> result(instruments_.size()); - for (int i = 0; i < static_cast(instruments_.size()); ++i) - result[i] = (*rates_[i])(market) - marketRates_[i]; - return result; - } - }; + CrossCurrencyMarket_::CrossCurrencyMarket_(const Handle_& domesticBlock, const Handle_& foreignBlock, double fxSpot) + : CrossCurrencyMarket_( + domesticBlock, foreignBlock, fxSpot, DateTime_(Global::Dates_::EvaluationDate()), domesticBlock ? domesticBlock->ccy_ : Ccy_()) {} CrossCurrencyMarket_::CrossCurrencyMarket_(const Handle_& domesticBlock, const Handle_& foreignBlock, - double fxSpot) - : domesticBlock_(domesticBlock), foreignBlock_(foreignBlock), fxSpot_(fxSpot) { + double fxSpot, + const DateTime_& valuationTime, + const Ccy_& collateralCurrency, + const Handle_& fixings) + : domesticBlock_(domesticBlock), foreignBlock_(foreignBlock), fxSpot_(fxSpot), valuationTime_(valuationTime), + collateralCurrency_(collateralCurrency), fixings_(fixings) { REQUIRE(domesticBlock_, "CrossCurrencyMarket_ requires a non-empty domestic curve block"); REQUIRE(foreignBlock_, "CrossCurrencyMarket_ requires a non-empty foreign curve block"); - REQUIRE(fxSpot_ > 0.0, "CrossCurrencyMarket_ requires a positive FX spot"); + REQUIRE(std::isfinite(fxSpot_) && fxSpot_ > 0.0, "CrossCurrencyMarket_ requires a positive finite FX spot"); + REQUIRE(valuationTime_.IsValid(), "CrossCurrencyMarket_ requires a valid valuation time"); domesticCcy_ = domesticBlock_->ccy_; foreignCcy_ = foreignBlock_->ccy_; REQUIRE(!(domesticCcy_ == foreignCcy_), "CrossCurrencyMarket_ requires distinct domestic and foreign currencies"); + REQUIRE(collateralCurrency_ == domesticCcy_, "CrossCurrencyMarket_ supports domestic-currency collateral only"); } - Date_ CrossCurrencyMarket_::Today() const { return Global::Dates_::EvaluationDate(); } - const DiscountCurve_& CrossCurrencyMarket_::DomesticDiscountCurve(const CollateralType_& collateral) const { return domesticBlock_->Discount(collateral); } @@ -145,13 +40,11 @@ namespace Dal { return foreignBlock_->Discount(collateral); } - const DiscountCurve_& CrossCurrencyMarket_::DomesticForwardCurve(const PeriodLength_& tenor, - const CollateralType_& collateral) const { + const DiscountCurve_& CrossCurrencyMarket_::DomesticForwardCurve(const PeriodLength_& tenor, const CollateralType_& collateral) const { return domesticBlock_->Forward(tenor, collateral); } - const DiscountCurve_& CrossCurrencyMarket_::ForeignForwardCurve(const PeriodLength_& tenor, - const CollateralType_& collateral) const { + const DiscountCurve_& CrossCurrencyMarket_::ForeignForwardCurve(const PeriodLength_& tenor, const CollateralType_& collateral) const { return foreignBlock_->Forward(tenor, collateral); } @@ -177,71 +70,11 @@ namespace Dal { void CrossCurrencyMarket_::SetBasisCurve(const Handle_& basisCurve) { REQUIRE(basisCurve, "CrossCurrencyMarket_ requires a non-empty basis curve handle"); - REQUIRE(basisCurve->ccy_ == domesticCcy_, - "Cross-currency basis curve currency must match the market's domestic currency"); + REQUIRE(basisCurve->ccy_ == domesticCcy_, "Cross-currency basis curve currency must match the market's domestic currency"); basisCurve_ = basisCurve; } CrossCurrencyCalibrationResult_ CalibrateCrossCurrencyMarket(const CrossCurrencyCalibrationSpec_& spec) { - REQUIRE(spec.today_.IsValid(), "Cross-currency calibration requires a valid today date"); - auto evalDateScope = XGLOBAL::SetEvaluationDateInScope(spec.today_); - REQUIRE(!spec.instruments_.empty(), "Cross-currency calibration requires at least one instrument"); - REQUIRE(!spec.knotDates_.empty(), "Cross-currency calibration requires at least one basis knot date"); - REQUIRE(spec.smoothingWeight_ > 0.0, "Cross-currency calibration smoothing weight must be positive"); - REQUIRE(spec.tolerance_ > 0.0, "Cross-currency calibration tolerance must be positive"); - REQUIRE(spec.fitTolerance_ > 0.0, "Cross-currency calibration fit tolerance must be positive"); - REQUIRE(spec.maxEvaluations_ > 0, "Cross-currency calibration max evaluations must be positive"); - REQUIRE(spec.maxRestarts_ > 0, "Cross-currency calibration max restarts must be positive"); - REQUIRE(std::isfinite(spec.initialGuess_), "Cross-currency calibration initial guess must be finite"); - ValidateCalibrationSpec(spec); - - REQUIRE(spec.knotDates_.front() > spec.today_, "Cross-currency calibration basis knot dates must be after the evaluation date"); - for (int i = 1; i < static_cast(spec.knotDates_.size()); ++i) - REQUIRE(spec.knotDates_[i] > spec.knotDates_[i - 1], "Cross-currency calibration basis knot dates must be strictly increasing"); - - const int nKnots = static_cast(spec.knotDates_.size()); - const int nInstruments = static_cast(spec.instruments_.size()); - - Vector_<> guess(nKnots, spec.initialGuess_); - Vector_<> tol(nInstruments, spec.tolerance_); - - Vector_ knotDateTimes; - knotDateTimes.reserve(nKnots); - for (const auto& d : spec.knotDates_) - knotDateTimes.push_back(DateTime_(d)); - std::unique_ptr weights(Underdetermined::WeightsPWC(knotDateTimes, spec.smoothingWeight_)); - - constexpr const char* KEY_MAX_EVALUATIONS = "MAXEVALUATIONS"; - constexpr const char* KEY_MAX_RESTARTS = "MAXRESTARTS"; - Dictionary_ ctrlDict; - ctrlDict.Insert(KEY_MAX_EVALUATIONS, Cell_(static_cast(spec.maxEvaluations_))); - ctrlDict.Insert(KEY_MAX_RESTARTS, Cell_(static_cast(spec.maxRestarts_))); - UnderdeterminedControls_ controls(ctrlDict); - - XccyCalibrationFunc_ func(spec.domesticCurveBlock_, spec.foreignCurveBlock_, spec.fxSpot_, spec.instruments_, spec.knotDates_); - - Vector_<> result; - Matrix_<> effJacobianInverse; - const bool useApproximate = spec.solveMode_ == CurveSolveMode_::Value_::APPROXIMATE; - if (!useApproximate) { - std::unique_ptr wDecomp(weights->DecomposeSymmetric()); - result = Underdetermined::Find(func, guess, tol, *wDecomp, controls, &effJacobianInverse); - } else { - result = Underdetermined::Approximate(func, guess, tol, spec.fitTolerance_, *weights, controls); - } - - auto basisCurve = BuildBasisCurve(spec.domesticCurveBlock_->ccy_.String(), spec.knotDates_, result); - - CrossCurrencyMarket_ market(spec.domesticCurveBlock_, spec.foreignCurveBlock_, spec.fxSpot_); - market.SetBasisCurve(basisCurve); - - std::map> basisCurves; - basisCurves[spec.basisPair_] = basisCurve; - - CrossCurrencyFxForwardCurve_ fxForwardCurve = BuildFxForwardCurve(spec.basisPair_, spec.knotDates_, market, spec.fxForwardCollateral_); - CrossCurrencyCalibrationDiagnostics_ diagnostics = BuildDiagnostics( - spec.instruments_, market, useApproximate, useApproximate ? nullptr : &effJacobianInverse); - - return CrossCurrencyCalibrationResult_(market, basisCurves, fxForwardCurve, diagnostics); + return CalibrateCrossCurrencyMarket(spec, CrossCurrencyCalibrationOptions_()); } } // namespace Dal diff --git a/dal-cpp/dal/curve/xccycalibration.hpp b/dal-cpp/dal/curve/xccycalibration.hpp index 38c8c9701..e8db2461b 100644 --- a/dal-cpp/dal/curve/xccycalibration.hpp +++ b/dal-cpp/dal/curve/xccycalibration.hpp @@ -7,9 +7,11 @@ #include #include #include +#include #include #include #include +#include #include namespace Dal { @@ -25,11 +27,23 @@ namespace Dal { Handle_ foreignBlock_; double fxSpot_ = 0.0; Handle_ basisCurve_; + DateTime_ valuationTime_; + Ccy_ collateralCurrency_; + Handle_ fixings_; public: CrossCurrencyMarket_(const Handle_& domesticBlock, const Handle_& foreignBlock, double fxSpot); + CrossCurrencyMarket_(const Handle_& domesticBlock, + const Handle_& foreignBlock, + double fxSpot, + const DateTime_& valuationTime, + const Ccy_& collateralCurrency, + const Handle_& fixings = Handle_()); void SetBasisCurve(const Handle_& basisCurve); - [[nodiscard]] Date_ Today() const; + [[nodiscard]] Date_ Today() const { return valuationTime_.Date(); } + [[nodiscard]] const DateTime_& ValuationTime() const { return valuationTime_; } + [[nodiscard]] const Ccy_& CollateralCurrency() const { return collateralCurrency_; } + [[nodiscard]] const Handle_& Fixings() const { return fixings_; } [[nodiscard]] const Ccy_& DomesticCcy() const { return domesticCcy_; } [[nodiscard]] const Ccy_& ForeignCcy() const { return foreignCcy_; } [[nodiscard]] const CurveBlock_& DomesticBlock() const { return *domesticBlock_; } @@ -51,8 +65,7 @@ namespace Dal { Vector_<> modelRates_; Vector_<> residuals_; Matrix_<> effJacobianInverse_; - // Forward-Jacobian mirror of CurveCalibrationDiagnostics_::jacobian_. Left empty on the - // cross-currency path; populated only by single-curve CalibrateYieldCurve for now. + // Unscaled at-solution forward Jacobian; empty for bumped or approximate solves. Matrix_<> jacobian_; double maxAbsResidual_ = 0.0; double rmsResidual_ = 0.0; @@ -67,6 +80,9 @@ namespace Dal { struct CrossCurrencyCalibrationSpec_ { Date_ today_; + DateTime_ valuationTime_; + Ccy_ collateralCurrency_; + Handle_ fixings_; CurrencyPair_ basisPair_; Handle_ domesticCurveBlock_; Handle_ foreignCurveBlock_; @@ -83,6 +99,12 @@ namespace Dal { CurveSolveMode_ solveMode_ = CurveSolveMode_::Value_::EXACT; }; + struct CrossCurrencyCalibrationOptions_ { + CurveJacobianMode_ jacobianMode_ = CurveJacobianMode_::Value_::ANALYTIC; + bool computeEffJacobianInverse_ = true; + bool computeForwardJacobian_ = true; + }; + struct CrossCurrencyCalibrationResult_ { CrossCurrencyMarket_ market_; std::map> basisCurves_; @@ -97,4 +119,6 @@ namespace Dal { }; CrossCurrencyCalibrationResult_ CalibrateCrossCurrencyMarket(const CrossCurrencyCalibrationSpec_& spec); + CrossCurrencyCalibrationResult_ CalibrateCrossCurrencyMarket(const CrossCurrencyCalibrationSpec_& spec, + const CrossCurrencyCalibrationOptions_& options); } // namespace Dal diff --git a/dal-cpp/dal/curve/xccyinstrument.cpp b/dal-cpp/dal/curve/xccyinstrument.cpp index 6542a7ddb..c99bcf1ff 100644 --- a/dal-cpp/dal/curve/xccyinstrument.cpp +++ b/dal-cpp/dal/curve/xccyinstrument.cpp @@ -41,13 +41,16 @@ namespace Dal { } XccyMarketView_ view; - view.valuationTime_ = DateTime_(market.Today()); + view.valuationTime_ = market.ValuationTime(); view.pair_ = CurrencyPair_(market.DomesticCcy(), market.ForeignCcy()); - view.collateralCurrency_ = market.DomesticCcy(); + view.collateralCurrency_ = market.CollateralCurrency(); view.fxSpot_ = market.FxSpot(); view.domestic_ = &domestic; view.foreign_ = &foreign; view.basis_ = market.BasisCurve(); + if (market.Fixings()) + return PriceXccyParSpread(plan_, view, *market.Fixings()); + static const MarketFixingSnapshot_ emptyFixings; if (plan_.config_.notionalMode_ == XccyNotionalMode_::Value_::FIXED && plan_.config_.domesticRateFixing_.indexName_.empty() && plan_.config_.foreignRateFixing_.indexName_.empty()) diff --git a/dal-cpp/dal/curve/xccyinstrument.hpp b/dal-cpp/dal/curve/xccyinstrument.hpp index c8f128d66..da8d7b2de 100644 --- a/dal-cpp/dal/curve/xccyinstrument.hpp +++ b/dal-cpp/dal/curve/xccyinstrument.hpp @@ -82,6 +82,7 @@ namespace Dal { [[nodiscard]] String_ Name() const; [[nodiscard]] pair TimeSpan() const; [[nodiscard]] double MarketRate() const { return marketRate_; } + [[nodiscard]] const CrossCurrencySwapConfig_& Config() const { return config_; } [[nodiscard]] Handle_ Precompute() const; }; } // namespace Dal diff --git a/dal-cpp/tests/curve/test_xccybasisjacobian.cpp b/dal-cpp/tests/curve/test_xccybasisjacobian.cpp new file mode 100644 index 000000000..2bd1714e3 --- /dev/null +++ b/dal-cpp/tests/curve/test_xccybasisjacobian.cpp @@ -0,0 +1,204 @@ +// +// Created by Codex on 2026/7/13. +// + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Dal; + +namespace { + Handle_ MakeFlatCurve(const String_& name, const String_& ccy, const Date_& today, double rate) { + const Vector_ knots = { + Date::AddMonths(today, 6), + Date::AddMonths(today, 12), + Date::AddMonths(today, 24), + Date::AddMonths(today, 36), + }; + const Vector_<> values(knots.size(), rate); + return Handle_(NewDiscountPWLF(name, ccy, PiecewiseLinear_(knots, values, values))); + } + + Handle_ MakeBlock(const String_& name, const String_& ccy, const Date_& today, double rate) { + return Handle_(new CurveBlock_(MakeFlatCurve(name, ccy, today, rate))); + } + + CrossCurrencySwapConfig_ MtmConfig() { + RateIndexConvention_ index; + index.useProjectionCurve_ = true; + index.forecastTenor_ = PeriodLength_("3M"); + index.dayBasis_ = DayBasis_("ACT_365F"); + index.collateral_ = CollateralType_(CollateralType_::Value_::OIS); + + RateLegConvention_ leg; + leg.paymentFrequency_ = PeriodLength_("3M"); + leg.dayBasis_ = DayBasis_("ACT_365F"); + + CrossCurrencySwapConfig_ result; + result.pair_ = CurrencyPair_(Ccy_("USD"), Ccy_("EUR")); + result.domesticNotional_ = 110.0; + result.foreignNotional_ = 100.0; + result.convention_.initialNotionalExchange_ = true; + result.convention_.finalNotionalExchange_ = true; + result.convention_.spreadOnForeignLeg_ = true; + result.convention_.domesticIndex_ = index; + result.convention_.foreignIndex_ = index; + result.convention_.domesticLeg_ = leg; + result.convention_.foreignLeg_ = leg; + result.notionalMode_ = XccyNotionalMode_::Value_::MARK_TO_MARKET; + result.fxReset_.fixingLag_ = 0; + result.fxReset_.fixingHour_ = 11; + result.fxReset_.fixingMinute_ = 0; + result.domesticRateFixing_ = {"USD-XCCY-JAC", 11, 0}; + result.foreignRateFixing_ = {"EUR-XCCY-JAC", 11, 0}; + return result; + } + + Handle_ QuoteSwap(const Date_& tradeDate, + const Date_& start, + const Date_& maturity, + const CrossCurrencySwapConfig_& config, + const CrossCurrencyMarket_& market) { + const CrossCurrencySwap_ prototype(tradeDate, start, maturity, 0.0, config); + const double quote = (*prototype.Precompute())(market); + return Handle_(new CrossCurrencySwap_(tradeDate, start, maturity, quote, config)); + } + + struct Fixture_ { + CrossCurrencyCalibrationSpec_ spec_; + Vector_<> trueParameters_; + }; + + Fixture_ MakeFixture() { + Fixture_ result; + const Date_ valuationDate(2025, 1, 16); + const DateTime_ valuationTime(valuationDate, 9, 0); + const Ccy_ collateral("USD"); + const auto domestic = MakeBlock("usd_jac", "USD", valuationDate, 0.02); + const auto foreign = MakeBlock("eur_jac", "EUR", valuationDate, 0.01); + const auto config = MtmConfig(); + + const Date_ startedDate(2024, 10, 15); + const DateTime_ historicalFixing(Date::AddMonths(startedDate, 3), 11, 0); + MarketFixingSnapshot_::values_t values; + values[config.domesticRateFixing_.indexName_][historicalFixing] = 0.04; + values[config.foreignRateFixing_.indexName_][historicalFixing] = 0.03; + values[FxIndexName(config.pair_)][historicalFixing] = 1.20; + const Handle_ fixings(new MarketFixingSnapshot_(values)); + + const Date_ futureStart = Date::AddMonths(valuationDate, 1); + const Vector_ maturities = { + Date::AddMonths(startedDate, 12), + Date::AddMonths(futureStart, 12), + Date::AddMonths(futureStart, 18), + }; + result.trueParameters_ = {0.0010, 0.0020, 0.0030}; + CrossCurrencyMarket_ quoteMarket(domestic, foreign, 1.10, valuationTime, collateral, fixings); + quoteMarket.SetBasisCurve( + Handle_(NewDiscountPWC("known_jac_basis", "USD", PiecewiseConstant_(maturities, result.trueParameters_)))); + + result.spec_.today_ = valuationDate; + result.spec_.valuationTime_ = valuationTime; + result.spec_.collateralCurrency_ = collateral; + result.spec_.fixings_ = fixings; + result.spec_.basisPair_ = config.pair_; + result.spec_.domesticCurveBlock_ = domestic; + result.spec_.foreignCurveBlock_ = foreign; + result.spec_.fxSpot_ = 1.10; + result.spec_.knotDates_ = maturities; + result.spec_.initialGuess_ = 0.0; + result.spec_.tolerance_ = 1.0e-10; + result.spec_.instruments_ = { + QuoteSwap(startedDate, startedDate, maturities[0], config, quoteMarket), + QuoteSwap(valuationDate, futureStart, maturities[1], config, quoteMarket), + QuoteSwap(valuationDate, futureStart, maturities[2], config, quoteMarket), + }; + return result; + } + + Vector_<> Residuals(const CrossCurrencyCalibrationSpec_& spec, const Vector_<>& parameters) { + CrossCurrencyMarket_ market(spec.domesticCurveBlock_, spec.foreignCurveBlock_, spec.fxSpot_, spec.valuationTime_, spec.collateralCurrency_, + spec.fixings_); + market.SetBasisCurve(Handle_( + NewDiscountPWC("bumped_jac_basis", spec.basisPair_.domestic_.String(), PiecewiseConstant_(spec.knotDates_, parameters)))); + Vector_<> result; + for (const auto& instrument : spec.instruments_) + result.push_back((*instrument->Precompute())(market)-instrument->MarketRate()); + return result; + } + + Matrix_<> CentralJacobian(const CrossCurrencyCalibrationSpec_& spec, const Vector_<>& parameters) { + constexpr double h = 1.0e-6; + Matrix_<> result(static_cast(spec.instruments_.size()), static_cast(parameters.size())); + for (int column = 0; column < static_cast(parameters.size()); ++column) { + auto up = parameters; + auto down = parameters; + up[column] += h; + down[column] -= h; + const Vector_<> upResiduals = Residuals(spec, up); + const Vector_<> downResiduals = Residuals(spec, down); + for (int row = 0; row < static_cast(spec.instruments_.size()); ++row) + result(row, column) = (upResiduals[row] - downResiduals[row]) / (2.0 * h); + } + return result; + } +} // namespace + +TEST(XccyBasisJacobianTest, TestAnalyticMatchesCentralDifferenceIncludingHistoricalResetRow) { + const auto fixture = MakeFixture(); + CrossCurrencyCalibrationOptions_ options; + options.jacobianMode_ = CurveJacobianMode_::Value_::ANALYTIC; + const auto calibrated = CalibrateCrossCurrencyMarket(fixture.spec_, options); + + const auto basis = calibrated.basisCurves_.at(fixture.spec_.basisPair_); + const auto* typed = dynamic_cast*>(basis.get()); + ASSERT_NE(typed, nullptr); + const Vector_<> parameters = typed->FRight(); + ASSERT_LE(calibrated.diagnostics_.maxAbsResidual_, 1.0e-8); + + const Matrix_<> central = CentralJacobian(fixture.spec_, parameters); + const Matrix_<>& analytic = calibrated.diagnostics_.jacobian_; + ASSERT_EQ(analytic.Rows(), central.Rows()); + ASSERT_EQ(analytic.Cols(), central.Cols()); + for (int row = 0; row < analytic.Rows(); ++row) + for (int column = 0; column < analytic.Cols(); ++column) + ASSERT_NEAR(analytic(row, column), central(row, column), 1.0e-7) << "row=" << row << " column=" << column; +} + +TEST(XccyBasisJacobianTest, TestBumpedModeKeepsOnlyEffectiveInverse) { + const auto fixture = MakeFixture(); + CrossCurrencyCalibrationOptions_ options; + options.jacobianMode_ = CurveJacobianMode_::Value_::BUMPED; + const auto calibrated = CalibrateCrossCurrencyMarket(fixture.spec_, options); + ASSERT_TRUE(calibrated.diagnostics_.jacobian_.Empty()); + ASSERT_FALSE(calibrated.diagnostics_.effJacobianInverse_.Empty()); + ASSERT_LE(calibrated.diagnostics_.maxAbsResidual_, 1.0e-8); +} + +TEST(XccyBasisJacobianTest, TestDiagnosticFlagsAndApproximateModeLeaveMatricesEmpty) { + auto fixture = MakeFixture(); + CrossCurrencyCalibrationOptions_ options; + options.computeEffJacobianInverse_ = false; + options.computeForwardJacobian_ = false; + const auto withoutMatrices = CalibrateCrossCurrencyMarket(fixture.spec_, options); + ASSERT_TRUE(withoutMatrices.diagnostics_.jacobian_.Empty()); + ASSERT_TRUE(withoutMatrices.diagnostics_.effJacobianInverse_.Empty()); + + fixture.spec_.solveMode_ = CurveSolveMode_::Value_::APPROXIMATE; + const auto approximate = CalibrateCrossCurrencyMarket(fixture.spec_, CrossCurrencyCalibrationOptions_()); + ASSERT_TRUE(approximate.diagnostics_.usedApproximateFit_); + ASSERT_TRUE(approximate.diagnostics_.jacobian_.Empty()); + ASSERT_TRUE(approximate.diagnostics_.effJacobianInverse_.Empty()); +} diff --git a/dal-cpp/tests/curve/test_xccymarket.cpp b/dal-cpp/tests/curve/test_xccymarket.cpp index 76ea95300..fbf1f1192 100644 --- a/dal-cpp/tests/curve/test_xccymarket.cpp +++ b/dal-cpp/tests/curve/test_xccymarket.cpp @@ -585,3 +585,81 @@ TEST(XccyMarketTest, TestMtmSwapPricingUsesResetKernel) { const CrossCurrencySwap_ swap(today, today, Date::AddMonths(today, 12), 0.0, config); ASSERT_TRUE(std::isfinite((*swap.Precompute())(market))); } +TEST(XccyMarketTest, TestResetAwareCalibrationUsesExplicitValuationAndFixingSnapshot) { + const Date_ valuationDate(2025, 1, 16); + const DateTime_ valuationTime(valuationDate, 9, 0); + const Ccy_ collateral("USD"); + const auto domesticBlock = MakeBlock("usd_explicit", "USD", valuationDate, 0.02); + const auto foreignBlock = MakeBlock("eur_explicit", "EUR", valuationDate, 0.01); + + CrossCurrencySwapConfig_ config; + config.pair_ = CurrencyPair_(Ccy_("USD"), Ccy_("EUR")); + config.domesticNotional_ = 110.0; + config.foreignNotional_ = 100.0; + config.convention_ = MakeConvention(); + config.convention_.domesticLeg_.paymentFrequency_ = PeriodLength_("3M"); + config.convention_.foreignLeg_.paymentFrequency_ = PeriodLength_("3M"); + config.convention_.domesticIndex_.forecastTenor_ = PeriodLength_("3M"); + config.convention_.foreignIndex_.forecastTenor_ = PeriodLength_("3M"); + config.notionalMode_ = XccyNotionalMode_::Value_::MARK_TO_MARKET; + config.fxReset_.fixingLag_ = 0; + config.fxReset_.fixingHour_ = 11; + config.fxReset_.fixingMinute_ = 0; + config.domesticRateFixing_ = {"USD-XCCY-CAL", 11, 0}; + config.foreignRateFixing_ = {"EUR-XCCY-CAL", 11, 0}; + + const Date_ startedDate(2024, 10, 15); + const DateTime_ historicalFixing(Date::AddMonths(startedDate, 3), 11, 0); + MarketFixingSnapshot_::values_t values; + values[config.domesticRateFixing_.indexName_][historicalFixing] = 0.04; + values[config.foreignRateFixing_.indexName_][historicalFixing] = 0.03; + values[FxIndexName(config.pair_)][historicalFixing] = 1.20; + const Handle_ fixings(new MarketFixingSnapshot_(values)); + + CrossCurrencyMarket_ quoteMarket(domesticBlock, foreignBlock, 1.10, valuationTime, collateral, fixings); + quoteMarket.SetBasisCurve(MakeFlatCurve("known_explicit_basis", "USD", valuationDate, 0.002)); + + const Date_ startedMaturity = Date::AddMonths(startedDate, 12); + const Date_ futureStart = Date::AddMonths(valuationDate, 1); + const Date_ futureMaturity = Date::AddMonths(futureStart, 12); + const CrossCurrencySwap_ startedPrototype(startedDate, startedDate, startedMaturity, 0.0, config); + const CrossCurrencySwap_ futurePrototype(valuationDate, futureStart, futureMaturity, 0.0, config); + const double startedQuote = (*startedPrototype.Precompute())(quoteMarket); + const double futureQuote = (*futurePrototype.Precompute())(quoteMarket); + + CrossCurrencyCalibrationSpec_ spec; + spec.today_ = valuationDate; + spec.valuationTime_ = valuationTime; + spec.collateralCurrency_ = collateral; + spec.fixings_ = fixings; + spec.basisPair_ = config.pair_; + spec.domesticCurveBlock_ = domesticBlock; + spec.foreignCurveBlock_ = foreignBlock; + spec.fxSpot_ = 1.10; + spec.instruments_ = { + Handle_(new CrossCurrencySwap_(startedDate, startedDate, startedMaturity, startedQuote, config)), + Handle_(new CrossCurrencySwap_(valuationDate, futureStart, futureMaturity, futureQuote, config)), + }; + spec.knotDates_ = {startedMaturity, futureMaturity}; + spec.initialGuess_ = 0.0; + spec.tolerance_ = 1.0e-10; + + CrossCurrencyCalibrationOptions_ options; + options.jacobianMode_ = CurveJacobianMode_::Value_::ANALYTIC; + const auto result = CalibrateCrossCurrencyMarket(spec, options); + ASSERT_LE(result.diagnostics_.maxAbsResidual_, 1.0e-8); + ASSERT_FALSE(result.diagnostics_.jacobian_.Empty()); +} + +TEST(XccyMarketTest, TestExplicitContextRejectsNonDomesticCollateral) { + const Date_ today(2025, 1, 16); + const DateTime_ valuationTime(today, 9, 0); + const auto domestic = MakeBlock("usd_collateral", "USD", today, 0.02); + const auto foreign = MakeBlock("eur_collateral", "EUR", today, 0.01); + ASSERT_THROW(static_cast(CrossCurrencyMarket_(domestic, foreign, 1.10, valuationTime, Ccy_("EUR"))), Dal::Exception_); + + auto spec = MakeCalibrationSpec(today, CurrencyPair_(Ccy_("USD"), Ccy_("EUR"))); + spec.valuationTime_ = valuationTime; + spec.collateralCurrency_ = Ccy_("EUR"); + ASSERT_THROW(static_cast(CalibrateCrossCurrencyMarket(spec)), Dal::Exception_); +} From 3887765c91e7cf611219419859ab09fd5714a578 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 02:15:37 +0800 Subject: [PATCH 09/38] fix(xccy): close calibration handoff gaps --- dal-cpp/dal/curve/xccybasiscalibration.cpp | 119 +++++++++---- dal-cpp/dal/curve/xccyinstrument.cpp | 16 +- dal-cpp/dal/curve/xccypricing.cpp | 160 ++++++++++++------ .../tests/curve/test_xccybasisjacobian.cpp | 101 ++++++++++- dal-cpp/tests/curve/test_xccypricing.cpp | 80 +++++++++ dal-public/src/xccycalibration.cpp | 27 ++- dal-public/tests/test_xccy_calibration.cpp | 89 +++++----- 7 files changed, 436 insertions(+), 156 deletions(-) diff --git a/dal-cpp/dal/curve/xccybasiscalibration.cpp b/dal-cpp/dal/curve/xccybasiscalibration.cpp index aa44d27d4..efc6f1532 100644 --- a/dal-cpp/dal/curve/xccybasiscalibration.cpp +++ b/dal-cpp/dal/curve/xccybasiscalibration.cpp @@ -38,8 +38,28 @@ namespace Dal { return spec.collateralCurrency_.Switch() == Ccy_::Value_::_NOT_SET ? spec.basisPair_.domestic_ : spec.collateralCurrency_; } - Handle_ ResolveFixings(const CrossCurrencyCalibrationSpec_& spec) { - return spec.fixings_ ? spec.fixings_ : Handle_(new MarketFixingSnapshot_()); + Vector_ BuildInstrumentPlans(const CrossCurrencyCalibrationSpec_& spec) { + Vector_ result; + result.reserve(spec.instruments_.size()); + for (const auto& instrument : spec.instruments_) { + const auto span = instrument->TimeSpan(); + result.push_back(BuildXccyCashflowPlan(span.first, span.second, instrument->Config())); + } + return result; + } + + Handle_ + ResolveFixings(const CrossCurrencyCalibrationSpec_& spec, const DateTime_& valuationTime, const Vector_& plans) { + if (spec.fixings_) + return spec.fixings_; + + Vector_ requests; + for (const auto& plan : plans) { + const auto required = RequiredHistoricalFixings(plan, valuationTime); + for (const auto& request : required) + requests.push_back(request); + } + return SnapshotGlobalFixings(requests); } CurveDefinition_ BasisDefinition(const CrossCurrencyCalibrationSpec_& spec, const DateTime_& valuationTime) { @@ -149,18 +169,15 @@ namespace Dal { const DateTime_& valuationTime, const Ccy_& collateralCurrency, const Handle_& fixings, + const Vector_& plans, const CurveDefinition_& basisDefinition, CurveJacobianMode_ jacobianMode) : valuationTime_(valuationTime), pair_(spec.basisPair_), collateralCurrency_(collateralCurrency), domesticBlock_(spec.domesticCurveBlock_), foreignBlock_(spec.foreignCurveBlock_), fxSpot_(spec.fxSpot_), fixings_(fixings), - basisDefinition_(basisDefinition), jacobianMode_(jacobianMode) { - plans_.reserve(spec.instruments_.size()); + plans_(plans), basisDefinition_(basisDefinition), jacobianMode_(jacobianMode) { marketRates_.reserve(spec.instruments_.size()); - for (const auto& instrument : spec.instruments_) { - const auto span = instrument->TimeSpan(); - plans_.push_back(BuildXccyCashflowPlan(span.first, span.second, instrument->Config())); + for (const auto& instrument : spec.instruments_) marketRates_.push_back(instrument->MarketRate()); - } } [[nodiscard]] Vector_<> F(const Vector_<>& x) const override { return Residuals(x); } @@ -177,6 +194,38 @@ namespace Dal { } }; + struct XccyBasisSolveResult_ { + Vector_<> parameters_; + Matrix_<> effJacobianInverse_; + Matrix_<> forwardJacobian_; + bool approximate_ = false; + bool hasEffJacobianInverse_ = false; + }; + + XccyBasisSolveResult_ SolveXccyBasis(const CrossCurrencyCalibrationSpec_& spec, + const CrossCurrencyCalibrationOptions_& options, + const XccyBasisCalibrationFunc_& func, + const Vector_<>& guess, + const Vector_<>& tolerance, + const Sparse::TriDiagonal_& weights, + const UnderdeterminedControls_& controls) { + XccyBasisSolveResult_ result; + result.approximate_ = spec.solveMode_ == CurveSolveMode_::Value_::APPROXIMATE; + const bool wantForwardJacobian = + options.computeForwardJacobian_ && options.jacobianMode_ == CurveJacobianMode_::Value_::ANALYTIC && !result.approximate_; + if (result.approximate_) { + result.parameters_ = Underdetermined::Approximate(func, guess, tolerance, spec.fitTolerance_, weights, controls); + return result; + } + + result.hasEffJacobianInverse_ = options.computeEffJacobianInverse_; + std::unique_ptr decomposition(weights.DecomposeSymmetric()); + result.parameters_ = Underdetermined::Find(func, guess, tolerance, *decomposition, controls, + result.hasEffJacobianInverse_ ? &result.effJacobianInverse_ : nullptr, + wantForwardJacobian ? &result.forwardJacobian_ : nullptr); + return result; + } + CrossCurrencyCalibrationDiagnostics_ BuildDiagnostics(const CrossCurrencyCalibrationSpec_& spec, const XccyBasisCalibrationFunc_& func, const Vector_<>& parameters, @@ -209,16 +258,38 @@ namespace Dal { result.forwards_.push_back(market.FxForward(market.Today(), date, collateral)); return result; } + + CrossCurrencyCalibrationResult_ AssembleCalibrationResult(const CrossCurrencyCalibrationSpec_& spec, + const DateTime_& valuationTime, + const Ccy_& collateralCurrency, + const Handle_& fixings, + const CurveDefinition_& basisDefinition, + const XccyBasisCalibrationFunc_& func, + XccyBasisSolveResult_* solve) { + Handle_ basisCurve(BuildDiscountCurveUniqueT(basisDefinition, solve->parameters_).release()); + CrossCurrencyMarket_ market(spec.domesticCurveBlock_, spec.foreignCurveBlock_, spec.fxSpot_, valuationTime, collateralCurrency, fixings); + market.SetBasisCurve(basisCurve); + + std::map> basisCurves; + basisCurves[spec.basisPair_] = basisCurve; + CrossCurrencyFxForwardCurve_ fxForwardCurve = BuildFxForwardCurve(spec.basisPair_, spec.knotDates_, market, spec.fxForwardCollateral_); + const Matrix_<>* effJacobianInverse = solve->hasEffJacobianInverse_ ? &solve->effJacobianInverse_ : nullptr; + CrossCurrencyCalibrationDiagnostics_ diagnostics = + BuildDiagnostics(spec, func, solve->parameters_, solve->approximate_, effJacobianInverse); + diagnostics.jacobian_ = std::move(solve->forwardJacobian_); + return CrossCurrencyCalibrationResult_(market, basisCurves, fxForwardCurve, diagnostics); + } } // namespace CrossCurrencyCalibrationResult_ CalibrateCrossCurrencyMarket(const CrossCurrencyCalibrationSpec_& spec, const CrossCurrencyCalibrationOptions_& options) { const DateTime_ valuationTime = ResolveValuationTime(spec); const Ccy_ collateralCurrency = ResolveCollateralCurrency(spec); - const Handle_ fixings = ResolveFixings(spec); ValidateSpec(spec, valuationTime, collateralCurrency); REQUIRE(options.jacobianMode_ == CurveJacobianMode_::Value_::ANALYTIC || options.jacobianMode_ == CurveJacobianMode_::Value_::BUMPED, "Cross-currency calibration requires ANALYTIC or BUMPED Jacobian mode"); + const Vector_ plans = BuildInstrumentPlans(spec); + const Handle_ fixings = ResolveFixings(spec, valuationTime, plans); const CurveDefinition_ basisDefinition = BasisDefinition(spec, valuationTime); const int parameterCount = BuildCurveParameterLayout(basisDefinition).parameterCount_; @@ -240,32 +311,8 @@ namespace Dal { controlsDictionary.Insert(KEY_MAX_RESTARTS, Cell_(static_cast(spec.maxRestarts_))); UnderdeterminedControls_ controls(controlsDictionary); - XccyBasisCalibrationFunc_ func(spec, valuationTime, collateralCurrency, fixings, basisDefinition, options.jacobianMode_); - const bool approximate = spec.solveMode_ == CurveSolveMode_::Value_::APPROXIMATE; - const bool wantForwardJacobian = - options.computeForwardJacobian_ && options.jacobianMode_ == CurveJacobianMode_::Value_::ANALYTIC && !approximate; - Vector_<> parameters; - Matrix_<> effJacobianInverse; - Matrix_<> forwardJacobian; - if (!approximate) { - std::unique_ptr decomposition(weights->DecomposeSymmetric()); - parameters = Underdetermined::Find(func, guess, tolerance, *decomposition, controls, - options.computeEffJacobianInverse_ ? &effJacobianInverse : nullptr, - wantForwardJacobian ? &forwardJacobian : nullptr); - } else { - parameters = Underdetermined::Approximate(func, guess, tolerance, spec.fitTolerance_, *weights, controls); - } - - Handle_ basisCurve(BuildDiscountCurveUniqueT(basisDefinition, parameters).release()); - CrossCurrencyMarket_ market(spec.domesticCurveBlock_, spec.foreignCurveBlock_, spec.fxSpot_, valuationTime, collateralCurrency, fixings); - market.SetBasisCurve(basisCurve); - - std::map> basisCurves; - basisCurves[spec.basisPair_] = basisCurve; - CrossCurrencyFxForwardCurve_ fxForwardCurve = BuildFxForwardCurve(spec.basisPair_, spec.knotDates_, market, spec.fxForwardCollateral_); - CrossCurrencyCalibrationDiagnostics_ diagnostics = - BuildDiagnostics(spec, func, parameters, approximate, !approximate && options.computeEffJacobianInverse_ ? &effJacobianInverse : nullptr); - diagnostics.jacobian_ = std::move(forwardJacobian); - return CrossCurrencyCalibrationResult_(market, basisCurves, fxForwardCurve, diagnostics); + XccyBasisCalibrationFunc_ func(spec, valuationTime, collateralCurrency, fixings, plans, basisDefinition, options.jacobianMode_); + XccyBasisSolveResult_ solve = SolveXccyBasis(spec, options, func, guess, tolerance, *weights, controls); + return AssembleCalibrationResult(spec, valuationTime, collateralCurrency, fixings, basisDefinition, func, &solve); } } // namespace Dal diff --git a/dal-cpp/dal/curve/xccyinstrument.cpp b/dal-cpp/dal/curve/xccyinstrument.cpp index c99bcf1ff..8b174816e 100644 --- a/dal-cpp/dal/curve/xccyinstrument.cpp +++ b/dal-cpp/dal/curve/xccyinstrument.cpp @@ -73,6 +73,15 @@ namespace Dal { return result; } + void ValidateResetConfig(const CrossCurrencySwapConfig_& config) { + if (config.notionalMode_ == XccyNotionalMode_::Value_::FIXED) + return; + REQUIRE(config.fxReset_.fixingLag_ >= 0, "Resettable cross-currency notionals require an explicit non-negative FX fixing lag"); + REQUIRE(config.fxReset_.fixingHour_ >= 0 && config.fxReset_.fixingHour_ < 24 && config.fxReset_.fixingMinute_ >= 0 && + config.fxReset_.fixingMinute_ < 60, + "Resettable cross-currency notionals require an explicit valid FX fixing time"); + } + void ValidateConfig(const Date_& tradeDate, const Date_& start, const Date_& maturity, double marketRate, const CrossCurrencySwapConfig_& config) { REQUIRE(tradeDate.IsValid() && start.IsValid() && maturity.IsValid(), @@ -86,12 +95,7 @@ namespace Dal { REQUIRE(config.notionalMode_ == XccyNotionalMode_::Value_::FIXED || config.notionalMode_ == XccyNotionalMode_::Value_::RESETTABLE || config.notionalMode_ == XccyNotionalMode_::Value_::MARK_TO_MARKET, "CrossCurrencySwap_ requires a valid notional mode"); - if (config.notionalMode_ != XccyNotionalMode_::Value_::FIXED) { - REQUIRE(config.fxReset_.fixingLag_ >= 0, "Resettable cross-currency notionals require an explicit non-negative FX fixing lag"); - REQUIRE(config.fxReset_.fixingHour_ >= 0 && config.fxReset_.fixingHour_ < 24 && config.fxReset_.fixingMinute_ >= 0 && - config.fxReset_.fixingMinute_ < 60, - "Resettable cross-currency notionals require an explicit valid FX fixing time"); - } + ValidateResetConfig(config); } } // namespace diff --git a/dal-cpp/dal/curve/xccypricing.cpp b/dal-cpp/dal/curve/xccypricing.cpp index 0a58cc292..8ce98230b 100644 --- a/dal-cpp/dal/curve/xccypricing.cpp +++ b/dal-cpp/dal/curve/xccypricing.cpp @@ -222,6 +222,103 @@ namespace Dal { "XCCY foreign cashflow conversion requires positive discount factors"); return market.fxSpot_ * foreignDf / basisDf; } + + template struct XccyCouponValues_ { + T_ pv_; + T_ spreadAnnuity_; + }; + + template + T_ DomesticNotional(const XccyCashflowPlan_& plan, const XccyResolvedNotionals_& resolved, bool fixedNotional, int periodIndex) { + if (fixedNotional) + return T_(plan.config_.domesticNotional_); + return resolved.domesticNotionals_[periodIndex]; + } + + template + XccyCouponValues_ AccumulateDomesticCoupons(const XccyCashflowPlan_& plan, + const XccyMarketView_& market, + const MarketFixingSnapshot_& fixings, + const Tape::DiscountCurve_& forecast, + const Tape::DiscountCurve_& discount, + const XccyResolvedNotionals_& resolved, + bool fixedNotional) { + XccyCouponValues_ result{T_(0.0), T_(0.0)}; + for (int i = 0; i < static_cast(plan.domesticPeriods_.size()); ++i) { + const auto& period = plan.domesticPeriods_[i]; + if (period.schedule_.paymentDate_ < market.valuationTime_.Date()) + continue; + const T_ rate = CouponRate(period, forecast, market, fixings, "XCCY domestic floating coupon"); + const T_ df = DiscountFromValuation(discount, market.valuationTime_, period.schedule_.paymentDate_); + const T_ annuity = DomesticNotional(plan, resolved, fixedNotional, i) * static_cast(period.accrual_.dcf_) * df; + result.pv_ += rate * annuity; + result.spreadAnnuity_ += annuity; + } + return result; + } + + template + XccyCouponValues_ AccumulateForeignCoupons(const XccyCashflowPlan_& plan, + const XccyMarketView_& market, + const MarketFixingSnapshot_& fixings, + const Tape::DiscountCurve_& forecast, + const Tape::DiscountCurve_& discount) { + XccyCouponValues_ result{T_(0.0), T_(0.0)}; + for (const auto& period : plan.foreignPeriods_) { + if (period.schedule_.paymentDate_ < market.valuationTime_.Date()) + continue; + const T_ rate = CouponRate(period, forecast, market, fixings, "XCCY foreign floating coupon"); + const T_ annuity = T_(plan.config_.foreignNotional_) * static_cast(period.accrual_.dcf_) * + ForeignConversionFactor(discount, market, period.schedule_.paymentDate_); + result.pv_ += rate * annuity; + result.spreadAnnuity_ += annuity; + } + return result; + } + + template struct XccyNotionalExchangeAdjustments_ { + T_ domesticPv_; + T_ foreignPv_; + }; + + template + XccyNotionalExchangeAdjustments_ InitialNotionalExchangeAdjustments(const XccyCashflowPlan_& plan, + const XccyMarketView_& market, + const Tape::DiscountCurve_& domesticDiscount, + const Tape::DiscountCurve_& foreignDiscount, + const XccyResolvedNotionals_& resolved, + bool fixedNotional) { + XccyNotionalExchangeAdjustments_ result{T_(0.0), T_(0.0)}; + if (!plan.config_.convention_.initialNotionalExchange_) + return result; + + const Date_ domesticStart = plan.domesticPeriods_.front().schedule_.accrualStart_; + if (domesticStart >= market.valuationTime_.Date()) + result.domesticPv_ -= DomesticNotional(plan, resolved, fixedNotional, 0) * + DiscountFromValuation(domesticDiscount, market.valuationTime_, domesticStart); + const Date_ foreignStart = plan.foreignPeriods_.front().schedule_.accrualStart_; + if (foreignStart >= market.valuationTime_.Date()) + result.foreignPv_ -= T_(plan.config_.foreignNotional_) * ForeignConversionFactor(foreignDiscount, market, foreignStart); + return result; + } + + template + void ApplyMtmNotionalExchangeAdjustments(const XccyCashflowPlan_& plan, + const XccyMarketView_& market, + const Tape::DiscountCurve_& domesticDiscount, + const XccyResolvedNotionals_& resolved, + T_* domesticPv) { + if (plan.config_.notionalMode_ != XccyNotionalMode_::Value_::MARK_TO_MARKET) + return; + + REQUIRE(resolved.mtmDeltas_.size() == plan.resets_.size(), "XCCY MTM delta count does not match reset events"); + for (int i = 0; i < static_cast(plan.resets_.size()); ++i) { + if (plan.resets_[i].effectiveDate_ < market.valuationTime_.Date()) + continue; + *domesticPv -= + resolved.mtmDeltas_[i] * DiscountFromValuation(domesticDiscount, market.valuationTime_, plan.resets_[i].effectiveDate_); + } + } } // namespace template @@ -232,9 +329,6 @@ namespace Dal { XccyResolvedNotionals_ resolved; if (!fixedNotional) resolved = ResolveXccyNotionals(plan, market, fixings); - const auto domesticNotional = [&](int periodIndex) { - return fixedNotional ? T_(plan.config_.domesticNotional_) : resolved.domesticNotionals_[periodIndex]; - }; const auto& convention = plan.config_.convention_; const auto& domesticDiscount = market.domestic_->Discount(convention.domesticIndex_.collateral_); const auto& foreignDiscount = market.foreign_->Discount(convention.foreignIndex_.collateral_); @@ -244,62 +338,28 @@ namespace Dal { "XCCY discount curves do not match the configured currency pair"); const Date_ valuationDate = market.valuationTime_.Date(); - T_ domesticPv(0.0); - T_ foreignPv(0.0); - T_ domesticSpreadAnnuity(0.0); - T_ foreignSpreadAnnuity(0.0); - - for (int i = 0; i < static_cast(plan.domesticPeriods_.size()); ++i) { - const auto& period = plan.domesticPeriods_[i]; - if (period.schedule_.paymentDate_ < valuationDate) - continue; - const T_ rate = CouponRate(period, domesticForecast, market, fixings, "XCCY domestic floating coupon"); - const T_ df = DiscountFromValuation(domesticDiscount, market.valuationTime_, period.schedule_.paymentDate_); - const T_ annuity = domesticNotional(i) * static_cast(period.accrual_.dcf_) * df; - domesticPv += rate * annuity; - domesticSpreadAnnuity += annuity; - } + const auto domesticCoupons = AccumulateDomesticCoupons(plan, market, fixings, domesticForecast, domesticDiscount, resolved, fixedNotional); + const auto foreignCoupons = AccumulateForeignCoupons(plan, market, fixings, foreignForecast, foreignDiscount); + T_ domesticPv = domesticCoupons.pv_; + T_ foreignPv = foreignCoupons.pv_; - for (const auto& period : plan.foreignPeriods_) { - if (period.schedule_.paymentDate_ < valuationDate) - continue; - const T_ rate = CouponRate(period, foreignForecast, market, fixings, "XCCY foreign floating coupon"); - const T_ annuity = T_(plan.config_.foreignNotional_) * static_cast(period.accrual_.dcf_) * - ForeignConversionFactor(foreignDiscount, market, period.schedule_.paymentDate_); - foreignPv += rate * annuity; - foreignSpreadAnnuity += annuity; - } - - if (convention.initialNotionalExchange_) { - const Date_ domesticStart = plan.domesticPeriods_.front().schedule_.accrualStart_; - if (domesticStart >= valuationDate) - domesticPv -= domesticNotional(0) * DiscountFromValuation(domesticDiscount, market.valuationTime_, domesticStart); - const Date_ foreignStart = plan.foreignPeriods_.front().schedule_.accrualStart_; - if (foreignStart >= valuationDate) - foreignPv -= T_(plan.config_.foreignNotional_) * ForeignConversionFactor(foreignDiscount, market, foreignStart); - } - - if (plan.config_.notionalMode_ == XccyNotionalMode_::Value_::MARK_TO_MARKET) { - REQUIRE(resolved.mtmDeltas_.size() == plan.resets_.size(), "XCCY MTM delta count does not match reset events"); - for (int i = 0; i < static_cast(plan.resets_.size()); ++i) { - if (plan.resets_[i].effectiveDate_ < valuationDate) - continue; - domesticPv -= resolved.mtmDeltas_[i] * DiscountFromValuation(domesticDiscount, market.valuationTime_, plan.resets_[i].effectiveDate_); - } - } + const auto initialAdjustments = InitialNotionalExchangeAdjustments(plan, market, domesticDiscount, foreignDiscount, resolved, fixedNotional); + domesticPv += initialAdjustments.domesticPv_; + foreignPv += initialAdjustments.foreignPv_; + ApplyMtmNotionalExchangeAdjustments(plan, market, domesticDiscount, resolved, &domesticPv); if (convention.finalNotionalExchange_ && plan.maturity_ >= valuationDate) { - domesticPv += domesticNotional(static_cast(plan.domesticPeriods_.size()) - 1) * + domesticPv += DomesticNotional(plan, resolved, fixedNotional, static_cast(plan.domesticPeriods_.size()) - 1) * DiscountFromValuation(domesticDiscount, market.valuationTime_, plan.maturity_); foreignPv += T_(plan.config_.foreignNotional_) * ForeignConversionFactor(foreignDiscount, market, plan.maturity_); } if (convention.spreadOnForeignLeg_) { - REQUIRE(Dal::AAD::Value(foreignSpreadAnnuity) > 0.0, "XCCY pricing requires a positive remaining foreign spread annuity"); - return (domesticPv - foreignPv) / foreignSpreadAnnuity; + REQUIRE(Dal::AAD::Value(foreignCoupons.spreadAnnuity_) > 0.0, "XCCY pricing requires a positive remaining foreign spread annuity"); + return (domesticPv - foreignPv) / foreignCoupons.spreadAnnuity_; } - REQUIRE(Dal::AAD::Value(domesticSpreadAnnuity) > 0.0, "XCCY pricing requires a positive remaining domestic spread annuity"); - return (foreignPv - domesticPv) / domesticSpreadAnnuity; + REQUIRE(Dal::AAD::Value(domesticCoupons.spreadAnnuity_) > 0.0, "XCCY pricing requires a positive remaining domestic spread annuity"); + return (foreignPv - domesticPv) / domesticCoupons.spreadAnnuity_; } template double PriceXccyParSpread(const XccyCashflowPlan_&, const XccyMarketView_&, const MarketFixingSnapshot_&); diff --git a/dal-cpp/tests/curve/test_xccybasisjacobian.cpp b/dal-cpp/tests/curve/test_xccybasisjacobian.cpp index 2bd1714e3..3803dc2ae 100644 --- a/dal-cpp/tests/curve/test_xccybasisjacobian.cpp +++ b/dal-cpp/tests/curve/test_xccybasisjacobian.cpp @@ -14,6 +14,8 @@ #include #include #include +#include +#include #include #include @@ -78,9 +80,49 @@ namespace { struct Fixture_ { CrossCurrencyCalibrationSpec_ spec_; + MarketFixingSnapshot_::values_t fixingValues_; Vector_<> trueParameters_; }; + String_ EscapeRegex(const String_& value) { + static const String_ specials("\\.^$|()[]{}*+?"); + String_ result; + for (const char ch : value) { + if (specials.find(ch) != String_::npos) + result.push_back('\\'); + result.push_back(ch); + } + return result; + } + + class ScopedGlobalFixingHistoryRestore_ { + Vector_> saved_; + + public: + explicit ScopedGlobalFixingHistoryRestore_(const MarketFixingSnapshot_::values_t& values) { + for (const auto& indexHistory : values) + saved_.push_back({indexHistory.first, Global::Fixings_().History(indexHistory.first)}); + } + + ~ScopedGlobalFixingHistoryRestore_() { + for (const auto& saved : saved_) { + if (saved.second.vals_.empty()) + (void)ObjectAccess_::Erase(EscapeRegex(String_("##GLOBAL##FixingsFor:") + saved.first + String_("~"))); + else + XGLOBAL::StoreFixings(saved.first, saved.second, false); + } + } + }; + + void StoreGlobalFixings(const MarketFixingSnapshot_::values_t& values) { + for (const auto& indexHistory : values) { + FixHistory_ history; + for (const auto& fixing : indexHistory.second) + history.vals_.push_back(fixing); + XGLOBAL::StoreFixings(indexHistory.first, history, false); + } + } + Fixture_ MakeFixture() { Fixture_ result; const Date_ valuationDate(2025, 1, 16); @@ -92,11 +134,10 @@ namespace { const Date_ startedDate(2024, 10, 15); const DateTime_ historicalFixing(Date::AddMonths(startedDate, 3), 11, 0); - MarketFixingSnapshot_::values_t values; - values[config.domesticRateFixing_.indexName_][historicalFixing] = 0.04; - values[config.foreignRateFixing_.indexName_][historicalFixing] = 0.03; - values[FxIndexName(config.pair_)][historicalFixing] = 1.20; - const Handle_ fixings(new MarketFixingSnapshot_(values)); + result.fixingValues_[config.domesticRateFixing_.indexName_][historicalFixing] = 0.04; + result.fixingValues_[config.foreignRateFixing_.indexName_][historicalFixing] = 0.03; + result.fixingValues_[FxIndexName(config.pair_)][historicalFixing] = 1.20; + const Handle_ fixings(new MarketFixingSnapshot_(result.fixingValues_)); const Date_ futureStart = Date::AddMonths(valuationDate, 1); const Vector_ maturities = { @@ -177,6 +218,56 @@ TEST(XccyBasisJacobianTest, TestAnalyticMatchesCentralDifferenceIncludingHistori ASSERT_NEAR(analytic(row, column), central(row, column), 1.0e-7) << "row=" << row << " column=" << column; } +TEST(XccyBasisJacobianTest, TestOmittedSnapshotCapturesRequiredGlobalFixingsOnce) { + const auto fixture = MakeFixture(); + const ScopedGlobalFixingHistoryRestore_ restore(fixture.fixingValues_); + StoreGlobalFixings(fixture.fixingValues_); + + const auto explicitResult = CalibrateCrossCurrencyMarket(fixture.spec_); + auto omittedSpec = fixture.spec_; + omittedSpec.fixings_ = Handle_(); + const auto capturedResult = CalibrateCrossCurrencyMarket(omittedSpec); + + ASSERT_TRUE(capturedResult.market_.Fixings()); + for (const auto& indexHistory : fixture.fixingValues_) { + for (const auto& fixing : indexHistory.second) + ASSERT_NEAR(capturedResult.market_.Fixings()->Require(indexHistory.first, fixing.first, "captured calibration fixing"), fixing.second, + 1.0e-12); + + FixHistory_ replacement; + for (const auto& fixing : indexHistory.second) + replacement.vals_.push_back({fixing.first, fixing.second + 0.10}); + XGLOBAL::StoreFixings(indexHistory.first, replacement, false); + } + for (const auto& indexHistory : fixture.fixingValues_) + for (const auto& fixing : indexHistory.second) + ASSERT_NEAR(capturedResult.market_.Fixings()->Require(indexHistory.first, fixing.first, "immutable calibration fixing"), fixing.second, + 1.0e-12); + + const auto* explicitBasis = dynamic_cast*>(explicitResult.basisCurves_.at(fixture.spec_.basisPair_).get()); + const auto* capturedBasis = dynamic_cast*>(capturedResult.basisCurves_.at(fixture.spec_.basisPair_).get()); + ASSERT_NE(explicitBasis, nullptr); + ASSERT_NE(capturedBasis, nullptr); + ASSERT_EQ(explicitBasis->FRight().size(), capturedBasis->FRight().size()); + for (int i = 0; i < static_cast(explicitBasis->FRight().size()); ++i) + ASSERT_NEAR(explicitBasis->FRight()[i], capturedBasis->FRight()[i], 1.0e-12); + ASSERT_EQ(explicitResult.diagnostics_.modelRates_.size(), capturedResult.diagnostics_.modelRates_.size()); + for (int i = 0; i < static_cast(explicitResult.diagnostics_.modelRates_.size()); ++i) + ASSERT_NEAR(explicitResult.diagnostics_.modelRates_[i], capturedResult.diagnostics_.modelRates_[i], 1.0e-12); + ASSERT_EQ(explicitResult.fxForwardCurve_.forwards_.size(), capturedResult.fxForwardCurve_.forwards_.size()); + for (int i = 0; i < static_cast(explicitResult.fxForwardCurve_.forwards_.size()); ++i) + ASSERT_NEAR(explicitResult.fxForwardCurve_.forwards_[i], capturedResult.fxForwardCurve_.forwards_[i], 1.0e-12); +} + +TEST(XccyBasisJacobianTest, TestExplicitSnapshotRemainsAuthoritativeWhenFixingsAreMissing) { + auto fixture = MakeFixture(); + const ScopedGlobalFixingHistoryRestore_ restore(fixture.fixingValues_); + StoreGlobalFixings(fixture.fixingValues_); + + fixture.spec_.fixings_ = Handle_(new MarketFixingSnapshot_()); + ASSERT_THROW(static_cast(CalibrateCrossCurrencyMarket(fixture.spec_)), Dal::Exception_); +} + TEST(XccyBasisJacobianTest, TestBumpedModeKeepsOnlyEffectiveInverse) { const auto fixture = MakeFixture(); CrossCurrencyCalibrationOptions_ options; diff --git a/dal-cpp/tests/curve/test_xccypricing.cpp b/dal-cpp/tests/curve/test_xccypricing.cpp index 39eea31fa..1c3c06704 100644 --- a/dal-cpp/tests/curve/test_xccypricing.cpp +++ b/dal-cpp/tests/curve/test_xccypricing.cpp @@ -64,6 +64,44 @@ namespace { void Write(Archive::Store_&) const override {} }; + class MappedDiscountCurve_ : public DiscountCurve_ { + Date_ valuationDate_; + std::map valuationDfs_; + std::map, double> forwardDfs_; + + MappedDiscountCurve_(const String_& name, + const String_& ccy, + const Date_& valuationDate, + const std::map& valuationDfs, + const std::map, double>& forwardDfs) + : DiscountCurve_(name, ccy), valuationDate_(valuationDate), valuationDfs_(valuationDfs), forwardDfs_(forwardDfs) {} + + public: + MappedDiscountCurve_(const String_& name, const String_& ccy, const Date_& valuationDate) + : DiscountCurve_(name, ccy), valuationDate_(valuationDate) {} + + void SetValuationDf(const Date_& to, double value) { valuationDfs_[to] = value; } + void SetForwardDf(const Date_& from, const Date_& to, double value) { forwardDfs_[{from, to}] = value; } + + double operator()(const Date_& from, const Date_& to) const override { + const auto forward = forwardDfs_.find({from, to}); + if (forward != forwardDfs_.end()) + return forward->second; + if (from == valuationDate_) { + const auto valuation = valuationDfs_.find(to); + if (valuation != valuationDfs_.end()) + return valuation->second; + } + return 1.0; + } + void Poll(Vector_* all) const override { all->push_back(this); } + void Poll(std::map>*) const override {} + [[nodiscard]] MappedDiscountCurve_* Clone(const String_& newName, const YCComponent_::substitutions_t&) const override { + return new MappedDiscountCurve_(newName, ccy_.String(), valuationDate_, valuationDfs_, forwardDfs_); + } + void Write(Archive::Store_&) const override {} + }; + struct PricingMarket_ { ConstantDiscountCurve_ domestic_{"domestic", "USD", 1.0}; ConstantDiscountCurve_ foreign_{"foreign", "EUR", 1.0}; @@ -217,6 +255,48 @@ TEST(XccyPricingTest, TestTwoPeriodMtmPrincipalCashflowsMatchHandCalculation) { ASSERT_NEAR(PriceXccyParSpread(plan, market, fixings), expectedParSpread, 1.0e-12); } +TEST(XccyPricingTest, TestMultipleFutureMtmExchangesUpdateDomesticPvSequentially) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET); + config.domesticNotional_ = 8.0; + config.foreignNotional_ = 1.0; + config.convention_.domesticLeg_.dayBasis_ = DayBasis_("30_360"); + config.convention_.foreignLeg_.dayBasis_ = DayBasis_("30_360"); + config.convention_.spreadOnForeignLeg_ = true; + config.convention_.initialNotionalExchange_ = false; + config.convention_.finalNotionalExchange_ = false; + config.convention_.domesticIndex_.fixingLag_ = 0; + config.convention_.foreignIndex_.fixingLag_ = 0; + + const Date_ valuationDate(2024, 1, 3); + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 10, 4), config); + ASSERT_EQ(plan.resets_.size(), 2U); + + MappedDiscountCurve_ domestic("domestic_mapped", "USD", valuationDate); + MappedDiscountCurve_ foreign("foreign_mapped", "EUR", valuationDate); + ConstantDiscountCurve_ basis("basis_mapped", "USD", 1.0); + domestic.SetForwardDf(plan.domesticPeriods_[0].schedule_.accrualStart_, plan.domesticPeriods_[0].schedule_.accrualEnd_, 0.5); + domestic.SetValuationDf(plan.resets_[0].effectiveDate_, 0x1p50); + foreign.SetValuationDf(plan.resets_[0].fxFixingTime_.Date(), 16.0); + foreign.SetValuationDf(plan.resets_[1].fxFixingTime_.Date(), 17.0); + + const CollateralType_ ois(CollateralType_::Value_::OIS); + Tape::JointCurveBlock_ domesticBlock; + Tape::JointCurveBlock_ foreignBlock; + domesticBlock.discountCurves[ois] = &domestic; + foreignBlock.discountCurves[ois] = &foreign; + XccyMarketView_ market; + market.valuationTime_ = DateTime_(valuationDate, 12, 0); + market.pair_ = config.pair_; + market.collateralCurrency_ = config.pair_.domestic_; + market.fxSpot_ = 1.0; + market.domestic_ = &domesticBlock; + market.foreign_ = &foreignBlock; + market.basis_ = &basis; + + const MarketFixingSnapshot_ fixings; + ASSERT_NEAR(PriceXccyParSpread(plan, market, fixings), -4.0 / 3.0, 1.0e-12); +} + namespace { struct CustomPricingMarket_ { ConstantDiscountCurve_ domestic_; diff --git a/dal-public/src/xccycalibration.cpp b/dal-public/src/xccycalibration.cpp index 492657ec8..5f187a956 100644 --- a/dal-public/src/xccycalibration.cpp +++ b/dal-public/src/xccycalibration.cpp @@ -9,16 +9,25 @@ namespace Dal { CrossCurrencyCalibrationSpec_ CrossCurrencyCalibrationSpecBuilder_::Build() const { - return CrossCurrencyCalibrationSpec_{ - today_, basisPair_, domesticCurveBlock_, foreignCurveBlock_, - fxSpot_, fxForwardCollateral_, instruments_, knotDates_, - smoothingWeight_, tolerance_, fitTolerance_, initialGuess_, - maxEvaluations_, maxRestarts_, solveMode_ - }; + CrossCurrencyCalibrationSpec_ result; + result.today_ = today_; + result.basisPair_ = basisPair_; + result.domesticCurveBlock_ = domesticCurveBlock_; + result.foreignCurveBlock_ = foreignCurveBlock_; + result.fxSpot_ = fxSpot_; + result.fxForwardCollateral_ = fxForwardCollateral_; + result.instruments_ = instruments_; + result.knotDates_ = knotDates_; + result.smoothingWeight_ = smoothingWeight_; + result.tolerance_ = tolerance_; + result.fitTolerance_ = fitTolerance_; + result.initialGuess_ = initialGuess_; + result.maxEvaluations_ = maxEvaluations_; + result.maxRestarts_ = maxRestarts_; + result.solveMode_ = solveMode_; + return result; } - CrossCurrencyCalibrationResult_ CalibrateXccyMarket(const CrossCurrencyCalibrationSpec_& spec) { - return CalibrateCrossCurrencyMarket(spec); - } + CrossCurrencyCalibrationResult_ CalibrateXccyMarket(const CrossCurrencyCalibrationSpec_& spec) { return CalibrateCrossCurrencyMarket(spec); } } // namespace Dal diff --git a/dal-public/tests/test_xccy_calibration.cpp b/dal-public/tests/test_xccy_calibration.cpp index 582d3f8a1..d32f262e8 100644 --- a/dal-public/tests/test_xccy_calibration.cpp +++ b/dal-public/tests/test_xccy_calibration.cpp @@ -29,30 +29,20 @@ using Dal::Vector_; namespace { -Date_ Today() { return Date_(2025, 6, 20); } -Date_ Spot() { return Today().AddDays(2); } + Date_ Today() { return Date_(2025, 6, 20); } + Date_ Spot() { return Today().AddDays(2); } -Dal::RateLegConvention_ Fixed6M365F() { - return RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_365F")); -} + Dal::RateLegConvention_ Fixed6M365F() { return RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_365F")); } -Dal::RateLegConvention_ Fixed6M360() { - return RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_360")); -} + Dal::RateLegConvention_ Fixed6M360() { return RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_360")); } -Dal::RateIndexConvention_ Libor3M() { - return RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360"), - CollateralType_OIS()); -} + Dal::RateIndexConvention_ Libor3M() { return RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360"), CollateralType_OIS()); } -Dal::RateIndexConvention_ Euribor6M() { - return RateIndexConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_360"), - CollateralType_OIS()); -} + Dal::RateIndexConvention_ Euribor6M() { return RateIndexConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_360"), CollateralType_OIS()); } -Dal::RateLegConvention_ FloatLeg(const char* tenor, const char* basis) { - return RateLegConvention_New(PeriodLength_New(tenor), DayBasis_New(basis)); -} + Dal::RateLegConvention_ FloatLeg(const char* tenor, const char* basis) { + return RateLegConvention_New(PeriodLength_New(tenor), DayBasis_New(basis)); + } } // namespace @@ -74,30 +64,30 @@ TEST(XccyCalibrationTest, TestBuilderDefaults) { namespace { -struct BaselineCurves_ { - Dal::Handle_ domesticBlock_; - Dal::Handle_ foreignBlock_; -}; - -BaselineCurves_ MakeBaselineCurves() { - Vector_ knotDates; - knotDates.push_back(Spot()); - knotDates.push_back(Spot().AddDays(3650)); // 10 years - Vector_<> oisRates; - oisRates.push_back(0.04); - oisRates.push_back(0.04); - - auto usdOis = DiscountPWLFNew(String_("usd_ois"), String_("USD"), knotDates, oisRates); - auto eurOis = DiscountPWLFNew(String_("eur_ois"), String_("EUR"), knotDates, oisRates); - - auto usdBlock = CurveBlockNew(usdOis, DayBasis_New("ACT_365F")); - auto eurBlock = CurveBlockNew(eurOis, DayBasis_New("ACT_360")); - - BaselineCurves_ result; - result.domesticBlock_ = usdBlock; - result.foreignBlock_ = eurBlock; - return result; -} + struct BaselineCurves_ { + Dal::Handle_ domesticBlock_; + Dal::Handle_ foreignBlock_; + }; + + BaselineCurves_ MakeBaselineCurves() { + Vector_ knotDates; + knotDates.push_back(Spot()); + knotDates.push_back(Spot().AddDays(3650)); // 10 years + Vector_<> oisRates; + oisRates.push_back(0.04); + oisRates.push_back(0.04); + + auto usdOis = DiscountPWLFNew(String_("usd_ois"), String_("USD"), knotDates, oisRates); + auto eurOis = DiscountPWLFNew(String_("eur_ois"), String_("EUR"), knotDates, oisRates); + + auto usdBlock = CurveBlockNew(usdOis, DayBasis_New("ACT_365F")); + auto eurBlock = CurveBlockNew(eurOis, DayBasis_New("ACT_360")); + + BaselineCurves_ result; + result.domesticBlock_ = usdBlock; + result.foreignBlock_ = eurBlock; + return result; + } } // namespace @@ -128,9 +118,7 @@ TEST(XccyCalibrationTest, TestCalibrateXccyMarket) { Date_ maturity = Spot().AddDays(y * 365); knotDates.push_back(maturity); builder.instruments_.push_back( - CrossCurrencySwapNew(Today(), Spot(), maturity, 0.01, currencies, - 100.0, 100.0 / 1.10, - usdLeg, usdIndex, eurLeg, eurIndex)); + CrossCurrencySwapNew(Today(), Spot(), maturity, 0.01, currencies, 100.0, 100.0 / 1.10, usdLeg, usdIndex, eurLeg, eurIndex)); } builder.knotDates_ = knotDates; @@ -138,15 +126,13 @@ TEST(XccyCalibrationTest, TestCalibrateXccyMarket) { auto result = CalibrateXccyMarket(spec); ASSERT_GT(result.diagnostics_.marketRates_.size(), static_cast(0)); - ASSERT_EQ(result.diagnostics_.marketRates_.size(), - result.diagnostics_.modelRates_.size()); + ASSERT_EQ(result.diagnostics_.marketRates_.size(), result.diagnostics_.modelRates_.size()); ASSERT_LT(result.diagnostics_.maxAbsResidual_, 1.0e-4); ASSERT_LT(result.diagnostics_.rmsResidual_, 1.0e-4); // Verify FX forward curve is populated ASSERT_GT(result.fxForwardCurve_.dates_.size(), static_cast(0)); - ASSERT_EQ(result.fxForwardCurve_.dates_.size(), - result.fxForwardCurve_.forwards_.size()); + ASSERT_EQ(result.fxForwardCurve_.dates_.size(), result.fxForwardCurve_.forwards_.size()); } // Round-trip every builder field through Build() to guard against brace-init @@ -175,6 +161,9 @@ TEST(XccyCalibrationTest, TestBuildRoundTripsEveryField) { auto spec = b.Build(); ASSERT_EQ(spec.today_, b.today_); + ASSERT_FALSE(spec.valuationTime_.IsValid()); + ASSERT_EQ(spec.collateralCurrency_.Switch(), Dal::Ccy_::Value_::_NOT_SET); + ASSERT_FALSE(spec.fixings_); ASSERT_TRUE(spec.basisPair_ == b.basisPair_); ASSERT_EQ(spec.domesticCurveBlock_.get(), b.domesticCurveBlock_.get()); ASSERT_EQ(spec.foreignCurveBlock_.get(), b.foreignCurveBlock_.get()); From 76c879b89e3c77be72853a8a40d121ca0027a272 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 02:51:34 +0800 Subject: [PATCH 10/38] feat(xccy): jointly calibrate domestic foreign and basis curves --- dal-cpp/dal/curve/jointcalibration.cpp | 314 ++---------- .../dal/curve/jointcalibration_internal.hpp | 344 +++++++++++++ dal-cpp/dal/curve/xccyjointcalibration.cpp | 457 ++++++++++++++++++ dal-cpp/dal/curve/xccyjointcalibration.hpp | 89 ++++ .../curve/test_joint_analytic_jacobian.cpp | 11 + .../tests/curve/test_joint_calibration.cpp | 16 + .../tests/curve/test_xccyjointcalibration.cpp | 420 ++++++++++++++++ .../tests/curve/test_xccyjointjacobian.cpp | 284 +++++++++++ 8 files changed, 1661 insertions(+), 274 deletions(-) create mode 100644 dal-cpp/dal/curve/jointcalibration_internal.hpp create mode 100644 dal-cpp/dal/curve/xccyjointcalibration.cpp create mode 100644 dal-cpp/dal/curve/xccyjointcalibration.hpp create mode 100644 dal-cpp/tests/curve/test_xccyjointcalibration.cpp create mode 100644 dal-cpp/tests/curve/test_xccyjointjacobian.cpp diff --git a/dal-cpp/dal/curve/jointcalibration.cpp b/dal-cpp/dal/curve/jointcalibration.cpp index 105e73699..ea1e6ba01 100644 --- a/dal-cpp/dal/curve/jointcalibration.cpp +++ b/dal-cpp/dal/curve/jointcalibration.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -29,7 +30,6 @@ #include #include #include -#include #include #include @@ -39,141 +39,32 @@ namespace Dal { constexpr const char* KEY_MAX_EVALUATIONS = "MAXEVALUATIONS"; constexpr const char* KEY_MAX_RESTARTS = "MAXRESTARTS"; - // See docs/methodology/yield_curve_jacobian.md §Joint Multi-Curve Analytic Jacobian. - String_ ForwardDeclarationOffendingInstrument(const JointCurveDeclaration_& decl) { - for (const auto& inst : decl.instruments_) { - const RateIndexConvention_* conv = FloatConventionOf(*inst); - if (!conv || !conv->useProjectionCurve_) - return inst->Name(); - } - return String_(); - } - - struct CurveSlot_ { - int curveIndex; - int paramOffset; - int nParams; - int residualOffset; - int nInstruments; - Vector_> instruments; - Vector_> rates; - Vector_<> marketRates; - double smoothingWeight; - CurveDefinition_ definition; - CurveParameterLayout_ layout; - }; + using JointCalibrationInternal::CurveSlot_; template Vector_ SliceParameters(const Vector_& parameters, const CurveSlot_& slot) { - Vector_ result(slot.nParams); - for (int i = 0; i < slot.nParams; ++i) - result[i] = parameters[slot.paramOffset + i]; - return result; + return JointCalibrationInternal::SliceParameters(parameters, slot); } - std::pair, std::set> DedupCollateralsAndTenors(const JointMultiCurveCalibrationSpec_& spec) { - REQUIRE(!spec.curves_.empty(), "Joint multi-curve calibration requires at least one curve declaration"); - REQUIRE(spec.tolerance_ > 0.0 && spec.fitTolerance_ > 0.0, "Joint multi-curve calibration tolerances must be positive"); - REQUIRE(spec.maxEvaluations_ > 0 && spec.maxRestarts_ > 0, "Joint multi-curve calibration iteration caps must be positive"); - std::set collaterals; - std::set tenors; - bool hasDiscount = false; - for (int di = 0; di < static_cast(spec.curves_.size()); ++di) { - const JointCurveDeclaration_& decl = spec.curves_[di]; - if (decl.calibrateDiscountCurve_) { - hasDiscount = true; - REQUIRE(collaterals.insert(decl.targetCollateral_).second, - String_("Duplicate discount collateral ") + decl.targetCollateral_.String()); - REQUIRE(!decl.baseLayeredOverDiscount_, - String_("Declaration ") + String::FromInt(di) + " base-layered on a discount declaration"); - } else { - REQUIRE(tenors.insert(decl.targetTenor_).second, String_("Duplicate forward tenor ") + decl.targetTenor_.String()); - } - } - REQUIRE(hasDiscount, "Joint multi-curve calibration requires at least one discount-curve declaration"); - return {std::move(collaterals), std::move(tenors)}; + JointCalibrationInternal::CurveCollectionSpec_ InternalSpec(const JointMultiCurveCalibrationSpec_& spec) { + JointCalibrationInternal::CurveCollectionSpec_ result; + result.today_ = spec.today_; + result.ccy_ = spec.ccy_; + result.liborBasis_ = spec.liborBasis_; + result.curves_ = &spec.curves_; + result.context_ = "Joint multi-curve calibration"; + result.declarationLabel_ = "Declaration"; + return result; } std::vector ValidateAndBuildSlots(const JointMultiCurveCalibrationSpec_& spec) { - const auto [producedCollaterals, producedTenors] = DedupCollateralsAndTenors(spec); - - std::vector slots; - slots.reserve(spec.curves_.size()); - int paramOffset = 0; - int residualOffset = 0; - for (int i = 0; i < static_cast(spec.curves_.size()); ++i) { - const JointCurveDeclaration_& decl = spec.curves_[i]; - REQUIRE(!decl.instruments_.empty(), String_("Declaration ") + String::FromInt(i) + " requires at least one instrument"); - REQUIRE(!decl.knotDates_.empty(), String_("Declaration ") + String::FromInt(i) + " requires at least one knot date"); - for (int k = 1; k < static_cast(decl.knotDates_.size()); ++k) - REQUIRE(decl.knotDates_[k] > decl.knotDates_[k - 1], - String_("Declaration ") + String::FromInt(i) + " knot dates must be strictly increasing"); - REQUIRE(decl.knotDates_.front() > spec.today_, String_("Declaration ") + String::FromInt(i) + " knot dates must be after today"); - REQUIRE(decl.smoothingWeight_ > 0.0, String_("Declaration ") + String::FromInt(i) + " smoothing weight must be positive"); - - if (!decl.calibrateDiscountCurve_) { - REQUIRE(decl.targetTenor_ != PeriodLength_(), String_("Forward declaration ") + String::FromInt(i) + " requires a target tenor"); - REQUIRE(producedCollaterals.count(decl.targetCollateral_) > 0, - String_("Declaration ") + String::FromInt(i) + " target collateral " + decl.targetCollateral_.String() + - " is not produced by any discount-curve declaration in this spec"); - const String_ offender = ForwardDeclarationOffendingInstrument(decl); - REQUIRE(offender.empty(), String_("Declaration ") + String::FromInt(i) + " forward instrument " + offender + - " has useProjectionCurve_ = false and leaves the forward curve unconstrained"); - } - - const CurveDefinition_ definition = MakeCurveDefinition(decl.curveName_, spec.ccy_, decl.parameterization_, decl.logDfScheme_, - decl.knotDates_, spec.today_, spec.liborBasis_); - const CurveParameterLayout_ layout = BuildCurveParameterLayout(definition); - const int nParams = layout.parameterCount_; - const auto ordered = OrderInstruments(decl.instruments_); - CurveSlot_ slot; - slot.curveIndex = i; - slot.paramOffset = paramOffset; - slot.nParams = nParams; - slot.residualOffset = residualOffset; - slot.nInstruments = static_cast(ordered.size()); - slot.instruments = ordered; - slot.smoothingWeight = decl.smoothingWeight_; - slot.definition = definition; - slot.layout = layout; - slot.rates.reserve(ordered.size()); - slot.marketRates.reserve(ordered.size()); - for (const auto& inst : ordered) { - slot.rates.push_back(inst->Precompute(Handle_())); - slot.marketRates.push_back(inst->MarketRate()); - } - slots.push_back(std::move(slot)); - paramOffset += nParams; - residualOffset += slot.nInstruments; - } - return slots; - } - - std::unique_ptr BuildJointSmoothing(const std::vector& slots) { - int totalParams = 0; - for (const auto& slot : slots) - totalParams += slot.nParams; - auto weights = std::make_unique(totalParams); - for (const auto& slot : slots) { - Vector_ expandedKnots; - expandedKnots.reserve(slot.layout.parameterCount_); - const int firstFreeNode = slot.layout.pinnedAnchor_ ? 1 : 0; - for (int node = firstFreeNode; node < slot.layout.storageNodeCount_; ++node) - for (int parameter = 0; parameter < slot.layout.paramsPerDeclaredKnot_; ++parameter) - expandedKnots.push_back(DateTime_(slot.definition.nodeDates_[node])); - REQUIRE(static_cast(expandedKnots.size()) == slot.layout.parameterCount_, - "Joint curve smoothing dates must match the curve parameter layout"); - Underdetermined::SelfCouplePWC(weights.get(), expandedKnots, slot.smoothingWeight, slot.paramOffset); - } - return weights; + REQUIRE(spec.tolerance_ > 0.0 && spec.fitTolerance_ > 0.0, "Joint multi-curve calibration tolerances must be positive"); + REQUIRE(spec.maxEvaluations_ > 0 && spec.maxRestarts_ > 0, "Joint multi-curve calibration iteration caps must be positive"); + return JointCalibrationInternal::ValidateAndBuildSlots(InternalSpec(spec)); } Vector_<> BuildGuessSlice(const JointMultiCurveCalibrationSpec_& spec, const JointCurveDeclaration_& decl, int nParams) { - if (!decl.initialGuessPerNode_.empty()) { - REQUIRE(static_cast(decl.initialGuessPerNode_.size()) == nParams, - String_("Joint curve declaration ") + decl.curveName_ + " initialGuessPerNode_ length must equal its parameter count"); - return decl.initialGuessPerNode_; - } - return Vector_<>(nParams, spec.initialGuess_); + return JointCalibrationInternal::BuildGuessSlice(decl, nParams, spec.initialGuess_, + String_("Joint curve declaration ") + decl.curveName_); } [[nodiscard]] bool IsSupportedInstrumentType(const YCInstrument_& inst) { @@ -217,10 +108,10 @@ namespace Dal { } for (int d = 0; d < static_cast(slots->size()); ++d) { const CurveSlot_& slot = (*slots)[d]; - const JointCurveDeclaration_& decl = spec->curves_[slot.curveIndex]; + const JointCurveDeclaration_& decl = spec->curves_[slot.curveIndex_]; const bool onDiscountDecl = decl.calibrateDiscountCurve_; - for (int i = 0; i < slot.nInstruments; ++i) { - if (!InstrumentEligibleForAnalyticJacobian(*slot.instruments[i], onDiscountDecl)) + for (int i = 0; i < slot.nInstruments_; ++i) { + if (!InstrumentEligibleForAnalyticJacobian(*slot.instruments_[i], onDiscountDecl)) return false; } } @@ -230,8 +121,6 @@ namespace Dal { class JointResidualFunction_ : public Underdetermined::Function_ { const JointMultiCurveCalibrationSpec_* spec_; const std::vector* slots_; - String_ ccy_; - DayBasis_ dayCount_; CurveJacobianMode_ jacobianMode_; mutable int evaluationCount_ = 0; mutable AnalyticEligibility_ cachedEligibility_ = AnalyticEligibility_::Value_::UNKNOWN; @@ -252,46 +141,22 @@ namespace Dal { public: JointResidualFunction_(const JointMultiCurveCalibrationSpec_& spec, const std::vector& slots, CurveJacobianMode_ jacobianMode) - : spec_(&spec), slots_(&slots), ccy_(spec.ccy_), dayCount_(spec.liborBasis_), jacobianMode_(jacobianMode) {} + : spec_(&spec), slots_(&slots), jacobianMode_(jacobianMode) {} [[nodiscard]] int EvaluationCount() const { return evaluationCount_; } - CurveBlock_ BuildCurvesFromX(const Vector_<>& x) const { - std::map> discountCurves; - std::map> forwardCurves; - for (const auto& slot : *slots_) { - const JointCurveDeclaration_& decl = spec_->curves_[slot.curveIndex]; - if (!decl.calibrateDiscountCurve_) - continue; - discountCurves[decl.targetCollateral_] = - Handle_(BuildDiscountCurveUniqueT(slot.definition, SliceParameters(x, slot)).release()); - } - for (const auto& slot : *slots_) { - const JointCurveDeclaration_& decl = spec_->curves_[slot.curveIndex]; - if (decl.calibrateDiscountCurve_) - continue; - Handle_ base; - if (decl.baseLayeredOverDiscount_) - base = discountCurves.at(decl.targetCollateral_); - forwardCurves[decl.targetTenor_] = - Handle_(BuildDiscountCurveUniqueT(slot.definition, SliceParameters(x, slot), base).release()); - } - return CurveBlock_("joint", ccy_, discountCurves, forwardCurves, dayCount_); + Handle_ BuildCurvesFromX(const Vector_<>& x) const { + return JointCalibrationInternal::BuildCurveBlock(InternalSpec(*spec_), *slots_, x); } [[nodiscard]] Vector_<> F(const Vector_<>& x) const override { ++evaluationCount_; - const CurveBlock_ yc = BuildCurvesFromX(x); + const Handle_ yc = BuildCurvesFromX(x); int totalResiduals = 0; for (const auto& slot : *slots_) - totalResiduals += slot.nInstruments; + totalResiduals += slot.nInstruments_; Vector_<> residuals(totalResiduals); - int offset = 0; - for (const auto& slot : *slots_) { - for (int i = 0; i < slot.nInstruments; ++i) - residuals[offset + i] = (*slot.rates[i])(yc) - slot.marketRates[i]; - offset += slot.nInstruments; - } + JointCalibrationInternal::AppendDoubleResiduals(*slots_, *yc, &residuals); return residuals; } @@ -305,62 +170,12 @@ namespace Dal { return nullptr; } - [[nodiscard]] std::map>> - BuildTemplatedCurves(const Vector_& parameters, - std::map>>& discountStorage) const { - std::map>> forwardStorage; - for (const auto& slot : *slots_) { - const JointCurveDeclaration_& decl = spec_->curves_[slot.curveIndex]; - if (decl.calibrateDiscountCurve_) - discountStorage[decl.targetCollateral_] = - BuildDiscountCurveT(slot.definition, SliceParameters(parameters, slot)); - } - for (const auto& slot : *slots_) { - const JointCurveDeclaration_& decl = spec_->curves_[slot.curveIndex]; - if (decl.calibrateDiscountCurve_) - continue; - if (decl.baseLayeredOverDiscount_) { - Handle_> base(discountStorage.at(decl.targetCollateral_)); - forwardStorage[decl.targetTenor_] = BuildDiscountCurveT>( - slot.definition, SliceParameters(parameters, slot), base); - } else { - forwardStorage[decl.targetTenor_] = - BuildDiscountCurveT(slot.definition, SliceParameters(parameters, slot)); - } - } - return forwardStorage; - } - - [[nodiscard]] Handle_> DiscountRateT(const YCInstrument_& inst) const { - // Caller (ComputeTemplatedResiduals) has already filtered to supported types via - // InstrumentEligibleForAnalyticJacobian; VisitRate returns an empty handle on miss. - return VisitRate( - inst, [](const Deposit_& d) { return d.PrecomputeT(); }, - [](const FRA_& f) { return f.PrecomputeT(); }, - [](const Future_& fu) { return fu.PrecomputeT(); }, - [](const Swap_& s) { return s.PrecomputeT(); }); - } - [[nodiscard]] Vector_ ComputeTemplatedResiduals(const Tape::JointCurveBlock_& block) const { int totalResiduals = 0; for (const auto& slot : *slots_) - totalResiduals += slot.nInstruments; + totalResiduals += slot.nInstruments_; Vector_ residuals(totalResiduals); - int offset = 0; - for (int d = 0; d < static_cast(slots_->size()); ++d) { - const CurveSlot_& slot = (*slots_)[d]; - for (int i = 0; i < slot.nInstruments; ++i) { - const auto& inst = *slot.instruments[i]; - const RateIndexConvention_& conv = *FloatConventionOf(inst); - if (conv.useProjectionCurve_) - residuals[offset + i] = - (*Tape::ProjectionRateAt(inst))(block) - static_cast(slot.marketRates[i]); - else - residuals[offset + i] = (*DiscountRateT(inst))(Tape::YCCtx_(block.Discount(conv.collateral_))) - - static_cast(slot.marketRates[i]); - } - offset += slot.nInstruments; - } + JointCalibrationInternal::AppendTemplatedResiduals(*slots_, block, &residuals); return residuals; } }; @@ -372,16 +187,8 @@ namespace Dal { Vector_ parameters = RegisterCurveParameters(x); Dal::AAD::NewRecording(*tape); - std::map>> discountStorage; - const auto forwardStorage = BuildTemplatedCurves(parameters, discountStorage); - - Tape::JointCurveBlock_ block; - for (const auto& [collateral, curve] : discountStorage) - block.discountCurves[collateral] = curve.get(); - for (const auto& [tenor, curve] : forwardStorage) - block.forwardCurves[tenor] = curve.get(); - - Vector_ residuals = ComputeTemplatedResiduals(block); + const auto storage = JointCalibrationInternal::BuildTypedCurveBlock(InternalSpec(*spec_), *slots_, parameters); + Vector_ residuals = ComputeTemplatedResiduals(storage.block_); return new XCurveJacobian_(HarvestCurveJacobian(*tape, parameters, residuals)); } @@ -403,30 +210,6 @@ namespace Dal { return Underdetermined::Approximate(func, guess, tol, spec.fitTolerance_, weights, controls); } - JointCurveCalibrationDiagnostics_ - BuildCurveDiagnostics(const JointCurveDeclaration_& decl, const CurveSlot_& slot, const CurveBlock_& solvedBlock, bool usedApproximateFit) { - JointCurveCalibrationDiagnostics_ diag; - diag.curveName_ = decl.curveName_; - diag.curveIndex_ = slot.curveIndex; - diag.usedApproximateFit_ = usedApproximateFit; - diag.instrumentNames_.reserve(slot.nInstruments); - diag.marketRates_.reserve(slot.nInstruments); - diag.modelRates_.reserve(slot.nInstruments); - diag.residuals_.reserve(slot.nInstruments); - for (int i = 0; i < slot.nInstruments; ++i) { - const double modelRate = (*slot.rates[i])(solvedBlock); - const double marketRate = slot.marketRates[i]; - diag.instrumentNames_.push_back(slot.instruments[i]->Name()); - diag.marketRates_.push_back(marketRate); - diag.modelRates_.push_back(modelRate); - diag.residuals_.push_back(modelRate - marketRate); - } - const ResidualStats_ stats = ResidualStats(diag.residuals_); - diag.maxAbsResidual_ = stats.maxAbsResidual_; - diag.rmsResidual_ = stats.rmsResidual_; - return diag; - } - [[noreturn]] void ThrowNonConvergence(const JointResidualFunction_& func, const Vector_<>& residuals) { const ResidualStats_ stats = ResidualStats(residuals); THROW(String_("Joint multi-curve calibration failed to converge: maxAbsResidual = ") + String::FromDouble(stats.maxAbsResidual_) + @@ -439,36 +222,18 @@ namespace Dal { Vector_<> g(totalParams); int off = 0; for (const auto& s : slots) { - const Vector_<> sl = BuildGuessSlice(spec, spec.curves_[s.curveIndex], s.nParams); - for (int j = 0; j < s.nParams; ++j) + const Vector_<> sl = BuildGuessSlice(spec, spec.curves_[s.curveIndex_], s.nParams_); + for (int j = 0; j < s.nParams_; ++j) g[off + j] = sl[j]; - off += s.nParams; + off += s.nParams_; } return g; } std::pair>, std::map>> BuildSolvedCurves(const JointMultiCurveCalibrationSpec_& spec, const std::vector& slots, const Vector_<>& solved) { - std::map> dc; - std::map> fc; - for (const auto& s : slots) { - const auto& d = spec.curves_[s.curveIndex]; - if (!d.calibrateDiscountCurve_) - continue; - dc[d.targetCollateral_] = - Handle_(BuildDiscountCurveUniqueT(s.definition, SliceParameters(solved, s)).release()); - } - for (const auto& s : slots) { - const auto& d = spec.curves_[s.curveIndex]; - if (d.calibrateDiscountCurve_) - continue; - Handle_ b; - if (d.baseLayeredOverDiscount_) - b = dc.at(d.targetCollateral_); - fc[d.targetTenor_] = - Handle_(BuildDiscountCurveUniqueT(s.definition, SliceParameters(solved, s), b).release()); - } - return {std::move(dc), std::move(fc)}; + JointCalibrationInternal::CurveMaps_ maps = JointCalibrationInternal::BuildCurveMaps(InternalSpec(spec), slots, solved); + return {std::move(maps.discountCurves_), std::move(maps.forwardCurves_)}; } JointMultiCurveCalibrationResult_ AssembleResult(const JointMultiCurveCalibrationSpec_& spec, @@ -483,7 +248,8 @@ namespace Dal { const bool usedApprox = spec.solveMode_ == CurveSolveMode_::Value_::APPROXIMATE; double jointMaxAbs = 0.0, jointSq = 0.0; for (const auto& s : slots) { - const JointCurveCalibrationDiagnostics_ diag = BuildCurveDiagnostics(spec.curves_[s.curveIndex], s, solvedBlock, usedApprox); + const JointCurveCalibrationDiagnostics_ diag = + JointCalibrationInternal::BuildCurveDiagnostics(spec.curves_[s.curveIndex_], s, solvedBlock, usedApprox); jointMaxAbs = std::max(jointMaxAbs, diag.maxAbsResidual_); for (const double r : diag.residuals_) jointSq += r * r; @@ -509,14 +275,14 @@ namespace Dal { int totalParams = 0, totalResiduals = 0; for (const auto& slot : slots) { - totalParams += slot.nParams; - totalResiduals += slot.nInstruments; + totalParams += slot.nParams_; + totalResiduals += slot.nInstruments_; } const Vector_<> guess = BuildInitialGuess(spec, slots, totalParams); const Vector_<> tol(totalResiduals, spec.tolerance_); - std::unique_ptr weights = BuildJointSmoothing(slots); + std::unique_ptr weights = JointCalibrationInternal::BuildJointSmoothing(slots); JointResidualFunction_ func(spec, slots, options.jacobianMode_); Matrix_<> fwdJacAtSolution; const Vector_<> solved = RunJointSolver(spec, func, guess, tol, *weights, options.computeJacobianAtSolution_ ? &fwdJacAtSolution : nullptr); diff --git a/dal-cpp/dal/curve/jointcalibration_internal.hpp b/dal-cpp/dal/curve/jointcalibration_internal.hpp new file mode 100644 index 000000000..e256421fe --- /dev/null +++ b/dal-cpp/dal/curve/jointcalibration_internal.hpp @@ -0,0 +1,344 @@ +// +// Created by Codex on 2026/7/14. +// + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Dal::JointCalibrationInternal { + struct CurveCollectionSpec_ { + Date_ today_; + String_ ccy_; + DayBasis_ liborBasis_ = DayBasis_("ACT_365F"); + const Vector_* curves_ = nullptr; + String_ context_ = "Joint multi-curve calibration"; + String_ declarationLabel_ = "Declaration"; + }; + + struct CurveSlot_ { + int curveIndex_ = 0; + int paramOffset_ = 0; + int nParams_ = 0; + int residualOffset_ = 0; + int nInstruments_ = 0; + Vector_> instruments_; + Vector_> rates_; + Vector_<> marketRates_; + double smoothingWeight_ = 1.0; + CurveDefinition_ definition_; + CurveParameterLayout_ layout_; + }; + + inline String_ SlotName(const CurveCollectionSpec_& spec, int index) { + REQUIRE(spec.curves_ && index >= 0 && index < static_cast(spec.curves_->size()), "Curve slot index is out of range"); + return spec.declarationLabel_ + " '" + (*spec.curves_)[index].curveName_ + "'"; + } + + inline String_ ForwardDeclarationOffendingInstrument(const JointCurveDeclaration_& declaration) { + for (const auto& instrument : declaration.instruments_) { + const RateIndexConvention_* convention = FloatConventionOf(*instrument); + if (!convention || !convention->useProjectionCurve_) + return instrument->Name(); + } + return String_(); + } + + inline std::pair, std::set> ValidateDeclarationIdentities(const CurveCollectionSpec_& spec) { + REQUIRE(spec.curves_, spec.context_ + " requires curve declarations"); + REQUIRE(!spec.curves_->empty(), spec.context_ + " requires at least one curve declaration"); + std::set collaterals; + std::set tenors; + bool hasDiscount = false; + for (int i = 0; i < static_cast(spec.curves_->size()); ++i) { + const JointCurveDeclaration_& declaration = (*spec.curves_)[i]; + REQUIRE(!declaration.curveName_.empty(), spec.declarationLabel_ + " at index " + String::FromInt(i) + " requires a name"); + if (declaration.calibrateDiscountCurve_) { + hasDiscount = true; + REQUIRE(collaterals.insert(declaration.targetCollateral_).second, spec.context_ + " has duplicate declaration '" + + declaration.curveName_ + "' for discount slot " + + declaration.targetCollateral_.String()); + REQUIRE(!declaration.baseLayeredOverDiscount_, SlotName(spec, i) + " cannot be base-layered because it is a discount slot"); + } else { + REQUIRE(tenors.insert(declaration.targetTenor_).second, spec.context_ + " has duplicate declaration '" + declaration.curveName_ + + "' for forward slot " + declaration.targetTenor_.String()); + } + } + REQUIRE(hasDiscount, spec.context_ + " requires at least one discount-curve declaration"); + return {std::move(collaterals), std::move(tenors)}; + } + + inline std::vector ValidateAndBuildSlots(const CurveCollectionSpec_& spec, int parameterOffset = 0, int residualOffset = 0) { + REQUIRE(spec.today_.IsValid(), spec.context_ + " requires a valid valuation date"); + REQUIRE(!spec.ccy_.empty(), spec.context_ + " requires a currency"); + const auto produced = ValidateDeclarationIdentities(spec); + const auto& producedCollaterals = produced.first; + + std::vector slots; + slots.reserve(spec.curves_->size()); + for (int i = 0; i < static_cast(spec.curves_->size()); ++i) { + const JointCurveDeclaration_& declaration = (*spec.curves_)[i]; + const String_ slotName = SlotName(spec, i); + REQUIRE(!declaration.instruments_.empty(), slotName + " requires at least one instrument"); + REQUIRE(!declaration.knotDates_.empty(), slotName + " requires at least one knot date"); + for (int k = 1; k < static_cast(declaration.knotDates_.size()); ++k) + REQUIRE(declaration.knotDates_[k] > declaration.knotDates_[k - 1], slotName + " knot dates must be strictly increasing"); + REQUIRE(declaration.knotDates_.front() > spec.today_, slotName + " knot dates must be after the valuation date"); + REQUIRE(declaration.smoothingWeight_ > 0.0, slotName + " smoothing weight must be positive"); + + if (!declaration.calibrateDiscountCurve_) { + REQUIRE(declaration.targetTenor_ != PeriodLength_(), slotName + " requires a target tenor"); + REQUIRE(producedCollaterals.count(declaration.targetCollateral_) > 0, + slotName + " target collateral " + declaration.targetCollateral_.String() + + " is not produced by any discount-curve declaration in this spec"); + const String_ offender = ForwardDeclarationOffendingInstrument(declaration); + REQUIRE(offender.empty(), + slotName + " forward instrument " + offender + " has useProjectionCurve_ = false and leaves the forward curve unconstrained"); + } + + CurveSlot_ slot; + slot.curveIndex_ = i; + slot.paramOffset_ = parameterOffset; + slot.residualOffset_ = residualOffset; + slot.smoothingWeight_ = declaration.smoothingWeight_; + slot.definition_ = MakeCurveDefinition(declaration.curveName_, spec.ccy_, declaration.parameterization_, declaration.logDfScheme_, + declaration.knotDates_, spec.today_, spec.liborBasis_); + slot.layout_ = BuildCurveParameterLayout(slot.definition_); + slot.nParams_ = slot.layout_.parameterCount_; + slot.instruments_ = OrderInstruments(declaration.instruments_); + slot.nInstruments_ = static_cast(slot.instruments_.size()); + slot.rates_.reserve(slot.instruments_.size()); + slot.marketRates_.reserve(slot.instruments_.size()); + for (const auto& instrument : slot.instruments_) { + slot.rates_.push_back(instrument->Precompute(Handle_())); + slot.marketRates_.push_back(instrument->MarketRate()); + } + parameterOffset += slot.nParams_; + residualOffset += slot.nInstruments_; + slots.push_back(std::move(slot)); + } + return slots; + } + + template Vector_ SliceParameters(const Vector_& parameters, const CurveSlot_& slot) { + Vector_ result(slot.nParams_); + for (int i = 0; i < slot.nParams_; ++i) + result[i] = parameters[slot.paramOffset_ + i]; + return result; + } + + inline void AddCurveSmoothing(const std::vector& slots, Sparse::TriDiagonal_* weights) { + REQUIRE(weights, "Curve smoothing requires an output matrix"); + for (const auto& slot : slots) { + Vector_ expandedKnots; + expandedKnots.reserve(slot.layout_.parameterCount_); + const int firstFreeNode = slot.layout_.pinnedAnchor_ ? 1 : 0; + for (int node = firstFreeNode; node < slot.layout_.storageNodeCount_; ++node) + for (int parameter = 0; parameter < slot.layout_.paramsPerDeclaredKnot_; ++parameter) + expandedKnots.push_back(DateTime_(slot.definition_.nodeDates_[node])); + REQUIRE(static_cast(expandedKnots.size()) == slot.layout_.parameterCount_, + "Joint curve smoothing dates must match the curve parameter layout"); + Underdetermined::SelfCouplePWC(weights, expandedKnots, slot.smoothingWeight_, slot.paramOffset_); + } + } + + inline std::unique_ptr BuildJointSmoothing(const std::vector& slots) { + int totalParameters = 0; + for (const auto& slot : slots) + totalParameters = std::max(totalParameters, slot.paramOffset_ + slot.nParams_); + auto result = std::make_unique(totalParameters); + AddCurveSmoothing(slots, result.get()); + return result; + } + + inline Vector_<> BuildGuessSlice(const JointCurveDeclaration_& declaration, int parameterCount, double defaultGuess, const String_& context) { + if (!declaration.initialGuessPerNode_.empty()) { + REQUIRE(static_cast(declaration.initialGuessPerNode_.size()) == parameterCount, + context + " initialGuessPerNode_ length must equal its parameter count"); + return declaration.initialGuessPerNode_; + } + return Vector_<>(parameterCount, defaultGuess); + } + + template struct TypedCurveBlockStorage_ { + Tape::JointCurveBlock_ block_; + std::map>> discountCurves_; + std::map>> forwardCurves_; + }; + + template + TypedCurveBlockStorage_ + BuildTypedCurveBlock(const CurveCollectionSpec_& spec, const std::vector& slots, const Vector_& parameters) { + TypedCurveBlockStorage_ result; + for (const auto& slot : slots) { + const JointCurveDeclaration_& declaration = (*spec.curves_)[slot.curveIndex_]; + if (declaration.calibrateDiscountCurve_) + result.discountCurves_[declaration.targetCollateral_] = BuildDiscountCurveT(slot.definition_, SliceParameters(parameters, slot)); + } + for (const auto& slot : slots) { + const JointCurveDeclaration_& declaration = (*spec.curves_)[slot.curveIndex_]; + if (declaration.calibrateDiscountCurve_) + continue; + if (declaration.baseLayeredOverDiscount_) { + Handle_> base(result.discountCurves_.at(declaration.targetCollateral_)); + result.forwardCurves_[declaration.targetTenor_] = + BuildDiscountCurveT>(slot.definition_, SliceParameters(parameters, slot), base); + } else { + result.forwardCurves_[declaration.targetTenor_] = BuildDiscountCurveT(slot.definition_, SliceParameters(parameters, slot)); + } + } + for (const auto& entry : result.discountCurves_) + result.block_.discountCurves[entry.first] = entry.second.get(); + for (const auto& entry : result.forwardCurves_) + result.block_.forwardCurves[entry.first] = entry.second.get(); + return result; + } + + inline Handle_ ReferenceCurveBlock(const CurveCollectionSpec_& spec, const TypedCurveBlockStorage_& storage) { + std::map> discounts; + std::map> forwards; + for (const auto& entry : storage.discountCurves_) + discounts[entry.first] = Handle_(entry.second); + for (const auto& entry : storage.forwardCurves_) + forwards[entry.first] = Handle_(entry.second); + return Handle_(new CurveBlock_(spec.context_, spec.ccy_, discounts, forwards, spec.liborBasis_)); + } + + inline void AppendDoubleResiduals(const std::vector& slots, const CurveBlock_& block, Vector_<>* residuals) { + REQUIRE(residuals, "Curve residual assembly requires an output vector"); + for (const auto& slot : slots) + for (int i = 0; i < slot.nInstruments_; ++i) + (*residuals)[slot.residualOffset_ + i] = (*slot.rates_[i])(block)-slot.marketRates_[i]; + } + + struct CurveMaps_ { + std::map> discountCurves_; + std::map> forwardCurves_; + }; + + inline CurveMaps_ BuildCurveMaps(const CurveCollectionSpec_& spec, const std::vector& slots, const Vector_<>& parameters) { + CurveMaps_ result; + for (const auto& slot : slots) { + const JointCurveDeclaration_& declaration = (*spec.curves_)[slot.curveIndex_]; + if (declaration.calibrateDiscountCurve_) + result.discountCurves_[declaration.targetCollateral_] = + Handle_(BuildDiscountCurveUniqueT(slot.definition_, SliceParameters(parameters, slot)).release()); + } + for (const auto& slot : slots) { + const JointCurveDeclaration_& declaration = (*spec.curves_)[slot.curveIndex_]; + if (declaration.calibrateDiscountCurve_) + continue; + Handle_ base; + if (declaration.baseLayeredOverDiscount_) + base = result.discountCurves_.at(declaration.targetCollateral_); + result.forwardCurves_[declaration.targetTenor_] = + Handle_(BuildDiscountCurveUniqueT(slot.definition_, SliceParameters(parameters, slot), base).release()); + } + return result; + } + + inline Handle_ BuildCurveBlock(const CurveCollectionSpec_& spec, const std::vector& slots, const Vector_<>& parameters) { + const CurveMaps_ maps = BuildCurveMaps(spec, slots, parameters); + return Handle_(new CurveBlock_(spec.context_, spec.ccy_, maps.discountCurves_, maps.forwardCurves_, spec.liborBasis_)); + } + + inline bool SupportedInstrumentType(const YCInstrument_& instrument) { + return VisitRate( + instrument, [](const Deposit_&) { return true; }, [](const FRA_&) { return true; }, [](const Future_&) { return true; }, + [](const Swap_&) { return true; }); + } + + inline String_ InstrumentAnalyticIneligibilityReason(const YCInstrument_& instrument, bool onDiscountDeclaration) { + const RateIndexConvention_* convention = FloatConventionOf(instrument); + if (!convention) + return String_("instrument '") + instrument.Name() + "' has no floating-rate convention"; + if (!SupportedInstrumentType(instrument)) + return String_("instrument '") + instrument.Name() + "' has no templated rate implementation"; + if (onDiscountDeclaration && convention->useProjectionCurve_) + return String_("discount-declaration instrument '") + instrument.Name() + "' projects from a forward slot"; + return String_(); + } + + inline String_ AnalyticIneligibilityReason(const CurveCollectionSpec_& spec, const std::vector& slots) { + if (spec.liborBasis_.String() != String_("ACT_365F")) + return spec.context_ + " requires liborBasis_ == ACT_365F for an analytic Jacobian"; + for (const auto& slot : slots) { + const JointCurveDeclaration_& declaration = (*spec.curves_)[slot.curveIndex_]; + for (const auto& instrument : slot.instruments_) { + const String_ reason = InstrumentAnalyticIneligibilityReason(*instrument, declaration.calibrateDiscountCurve_); + if (!reason.empty()) + return SlotName(spec, slot.curveIndex_) + " is analytically ineligible: " + reason; + } + } + return String_(); + } + + template Handle_> DiscountRate(const YCInstrument_& instrument) { + return VisitRate( + instrument, [](const Deposit_& value) { return value.PrecomputeT(); }, [](const FRA_& value) { return value.PrecomputeT(); }, + [](const Future_& value) { return value.PrecomputeT(); }, [](const Swap_& value) { return value.PrecomputeT(); }); + } + + template + void AppendTemplatedResiduals(const std::vector& slots, const Tape::JointCurveBlock_& block, Vector_* residuals) { + REQUIRE(residuals, "Curve residual assembly requires an output vector"); + for (const auto& slot : slots) { + for (int i = 0; i < slot.nInstruments_; ++i) { + const YCInstrument_& instrument = *slot.instruments_[i]; + const RateIndexConvention_& convention = *FloatConventionOf(instrument); + T_ modelRate; + if (convention.useProjectionCurve_) + modelRate = (*Tape::ProjectionRateAt(instrument))(block); + else + modelRate = (*DiscountRate(instrument))(Tape::YCCtx_(block.Discount(convention.collateral_))); + (*residuals)[slot.residualOffset_ + i] = modelRate - static_cast(slot.marketRates_[i]); + } + } + } + + inline JointCurveCalibrationDiagnostics_ BuildCurveDiagnostics(const JointCurveDeclaration_& declaration, + const CurveSlot_& slot, + const CurveBlock_& solvedBlock, + bool usedApproximateFit) { + JointCurveCalibrationDiagnostics_ result; + result.curveName_ = declaration.curveName_; + result.curveIndex_ = slot.curveIndex_; + result.usedApproximateFit_ = usedApproximateFit; + result.instrumentNames_.reserve(slot.nInstruments_); + result.marketRates_.reserve(slot.nInstruments_); + result.modelRates_.reserve(slot.nInstruments_); + result.residuals_.reserve(slot.nInstruments_); + for (int i = 0; i < slot.nInstruments_; ++i) { + const double modelRate = (*slot.rates_[i])(solvedBlock); + const double marketRate = slot.marketRates_[i]; + result.instrumentNames_.push_back(slot.instruments_[i]->Name()); + result.marketRates_.push_back(marketRate); + result.modelRates_.push_back(modelRate); + result.residuals_.push_back(modelRate - marketRate); + } + const ResidualStats_ stats = ResidualStats(result.residuals_); + result.maxAbsResidual_ = stats.maxAbsResidual_; + result.rmsResidual_ = stats.rmsResidual_; + return result; + } +} // namespace Dal::JointCalibrationInternal diff --git a/dal-cpp/dal/curve/xccyjointcalibration.cpp b/dal-cpp/dal/curve/xccyjointcalibration.cpp new file mode 100644 index 000000000..df6d1e352 --- /dev/null +++ b/dal-cpp/dal/curve/xccyjointcalibration.cpp @@ -0,0 +1,457 @@ +// +// Created by Codex on 2026/7/14. +// + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Dal { + namespace { + using JointCalibrationInternal::CurveCollectionSpec_; + using JointCalibrationInternal::CurveSlot_; + + constexpr const char* KEY_MAX_EVALUATIONS = "MAXEVALUATIONS"; + constexpr const char* KEY_MAX_RESTARTS = "MAXRESTARTS"; + + String_ PairName(const CurrencyPair_& pair) { return String_(pair.domestic_.String()) + "/" + pair.foreign_.String(); } + + String_ TenorName(const PeriodLength_& tenor) { return String::FromInt(tenor.Months()) + "M (" + tenor.String() + ")"; } + + CurveCollectionSpec_ CurrencyCollection(const JointXccyCalibrationSpec_& spec, bool domestic) { + const JointCurrencyCurveSpec_& currency = domestic ? spec.domestic_ : spec.foreign_; + CurveCollectionSpec_ result; + result.today_ = spec.valuationTime_.Date(); + result.ccy_ = currency.ccy_.String(); + result.liborBasis_ = currency.liborBasis_; + result.curves_ = ¤cy.curves_; + result.context_ = String_(domestic ? "Domestic " : "Foreign ") + result.ccy_ + " joint calibration"; + result.declarationLabel_ = String_(domestic ? "Domestic slot" : "Foreign slot"); + return result; + } + + CurveDefinition_ BasisDefinition(const JointXccyCalibrationSpec_& spec) { + return MakeCurveDefinition(spec.basis_.curveName_, spec.pair_.domestic_.String(), spec.basis_.parameterization_, + LogDfScheme_(LogDfScheme_::Value_::LOG_LINEAR), spec.basis_.knotDates_, spec.valuationTime_.Date(), + DayBasis_("ACT_365F")); + } + + String_ DiscountSlotName(const JointCurrencyCurveSpec_& currency, const CollateralType_& collateral) { + for (const auto& declaration : currency.curves_) + if (declaration.calibrateDiscountCurve_ && declaration.targetCollateral_ == collateral) + return declaration.curveName_; + return String_(); + } + + String_ ForwardSlotName(const JointCurrencyCurveSpec_& currency, const PeriodLength_& tenor) { + for (const auto& declaration : currency.curves_) + if (!declaration.calibrateDiscountCurve_ && declaration.targetTenor_ == tenor) + return declaration.curveName_; + return String_(); + } + + void ValidatePlanSlots(const JointXccyCalibrationSpec_& spec, const XccyCashflowPlan_& plan, int instrumentIndex) { + const String_ instrument = String_("XCCY instrument ") + String::FromInt(instrumentIndex) + " for pair " + PairName(spec.pair_); + REQUIRE(plan.config_.pair_ == spec.pair_, instrument + " has mismatched pair " + PairName(plan.config_.pair_)); + const auto& domesticIndex = plan.config_.convention_.domesticIndex_; + const auto& foreignIndex = plan.config_.convention_.foreignIndex_; + REQUIRE(!DiscountSlotName(spec.domestic_, domesticIndex.collateral_).empty(), + instrument + " requires missing domestic discount slot " + domesticIndex.collateral_.String()); + REQUIRE(!DiscountSlotName(spec.foreign_, foreignIndex.collateral_).empty(), + instrument + " requires missing foreign discount slot " + foreignIndex.collateral_.String()); + if (domesticIndex.useProjectionCurve_) + REQUIRE(!ForwardSlotName(spec.domestic_, domesticIndex.forecastTenor_).empty(), + instrument + " requires missing domestic forward slot " + TenorName(domesticIndex.forecastTenor_)); + if (foreignIndex.useProjectionCurve_) + REQUIRE(!ForwardSlotName(spec.foreign_, foreignIndex.forecastTenor_).empty(), + instrument + " requires missing foreign forward slot " + TenorName(foreignIndex.forecastTenor_)); + + const Vector_& spreadPeriods = + plan.config_.convention_.spreadOnForeignLeg_ ? plan.foreignPeriods_ : plan.domesticPeriods_; + bool hasRemainingAnnuity = false; + for (const auto& period : spreadPeriods) { + if (period.schedule_.paymentDate_ >= spec.valuationTime_.Date() && period.accrual_.dcf_ > 0.0) { + hasRemainingAnnuity = true; + break; + } + } + REQUIRE(hasRemainingAnnuity, + instrument + String_(plan.config_.convention_.spreadOnForeignLeg_ ? " has no remaining foreign spread annuity" + : " has no remaining domestic spread annuity")); + } + + Vector_ ValidateAndBuildPlans(const JointXccyCalibrationSpec_& spec) { + REQUIRE(!spec.basis_.instruments_.empty(), + "Joint XCCY calibration requires a non-empty XCCY instrument group for " + PairName(spec.pair_)); + Vector_ result; + result.reserve(spec.basis_.instruments_.size()); + for (int i = 0; i < static_cast(spec.basis_.instruments_.size()); ++i) { + REQUIRE(spec.basis_.instruments_[i], String_("Joint XCCY calibration has an empty XCCY instrument at index ") + String::FromInt(i)); + const auto span = spec.basis_.instruments_[i]->TimeSpan(); + result.push_back(BuildXccyCashflowPlan(span.first, span.second, spec.basis_.instruments_[i]->Config())); + ValidatePlanSlots(spec, result.back(), i); + } + return result; + } + + void ValidateTopLevelSpec(const JointXccyCalibrationSpec_& spec) { + REQUIRE(spec.valuationTime_.IsValid(), "Joint XCCY calibration requires a valid valuation time"); + REQUIRE(spec.pair_.domestic_ == spec.domestic_.ccy_, String_("Joint XCCY pair domestic currency ") + spec.pair_.domestic_.String() + + " does not match domestic curve currency " + spec.domestic_.ccy_.String()); + REQUIRE(spec.pair_.foreign_ == spec.foreign_.ccy_, String_("Joint XCCY pair foreign currency ") + spec.pair_.foreign_.String() + + " does not match foreign curve currency " + spec.foreign_.ccy_.String()); + REQUIRE(spec.collateralCurrency_ == spec.pair_.domestic_, + "Joint XCCY pair " + PairName(spec.pair_) + " supports domestic collateral only, not " + spec.collateralCurrency_.String()); + REQUIRE(std::isfinite(spec.fxSpot_) && spec.fxSpot_ > 0.0, + "Joint XCCY pair " + PairName(spec.pair_) + " requires a positive finite FX spot"); + REQUIRE(spec.tolerance_ > 0.0 && spec.fitTolerance_ > 0.0, "Joint XCCY calibration tolerances must be positive"); + REQUIRE(std::isfinite(spec.initialGuess_), "Joint XCCY calibration initial guess must be finite"); + REQUIRE(spec.maxEvaluations_ > 0 && spec.maxRestarts_ > 0, "Joint XCCY calibration iteration caps must be positive"); + REQUIRE(!spec.basis_.curveName_.empty(), "Joint XCCY calibration requires a named basis declaration"); + REQUIRE(!spec.basis_.knotDates_.empty(), "XCCY basis slot '" + spec.basis_.curveName_ + "' requires at least one knot date"); + REQUIRE(spec.basis_.knotDates_.front() > spec.valuationTime_.Date(), + "XCCY basis slot '" + spec.basis_.curveName_ + "' knot dates must be after the valuation date"); + for (int i = 1; i < static_cast(spec.basis_.knotDates_.size()); ++i) + REQUIRE(spec.basis_.knotDates_[i] > spec.basis_.knotDates_[i - 1], + "XCCY basis slot '" + spec.basis_.curveName_ + "' knot dates must be strictly increasing"); + REQUIRE(spec.basis_.smoothingWeight_ > 0.0, "XCCY basis slot '" + spec.basis_.curveName_ + "' smoothing weight must be positive"); + } + + Handle_ ResolveFixings(const JointXccyCalibrationSpec_& spec, const Vector_& plans) { + if (spec.fixings_) + return spec.fixings_; + Vector_ requests; + for (const auto& plan : plans) { + const Vector_ required = RequiredHistoricalFixings(plan, spec.valuationTime_); + requests.Append(required); + } + return SnapshotGlobalFixings(requests); + } + + struct JointLayout_ { + CurveCollectionSpec_ domesticCollection_; + CurveCollectionSpec_ foreignCollection_; + std::vector domesticSlots_; + std::vector foreignSlots_; + CurveDefinition_ basisDefinition_; + CurveParameterLayout_ basisLayout_; + CurveSlot_ basisSlot_; + int totalParameters_ = 0; + int totalResiduals_ = 0; + }; + + JointLayout_ BuildLayout(const JointXccyCalibrationSpec_& spec) { + JointLayout_ result; + result.domesticCollection_ = CurrencyCollection(spec, true); + result.foreignCollection_ = CurrencyCollection(spec, false); + result.domesticSlots_ = JointCalibrationInternal::ValidateAndBuildSlots(result.domesticCollection_); + int parameterOffset = 0; + int residualOffset = 0; + for (const auto& slot : result.domesticSlots_) { + parameterOffset = std::max(parameterOffset, slot.paramOffset_ + slot.nParams_); + residualOffset = std::max(residualOffset, slot.residualOffset_ + slot.nInstruments_); + } + result.foreignSlots_ = JointCalibrationInternal::ValidateAndBuildSlots(result.foreignCollection_, parameterOffset, residualOffset); + for (const auto& slot : result.foreignSlots_) { + parameterOffset = std::max(parameterOffset, slot.paramOffset_ + slot.nParams_); + residualOffset = std::max(residualOffset, slot.residualOffset_ + slot.nInstruments_); + } + + result.basisDefinition_ = BasisDefinition(spec); + result.basisLayout_ = BuildCurveParameterLayout(result.basisDefinition_); + result.basisSlot_.paramOffset_ = parameterOffset; + result.basisSlot_.nParams_ = result.basisLayout_.parameterCount_; + result.basisSlot_.residualOffset_ = residualOffset; + result.basisSlot_.nInstruments_ = static_cast(spec.basis_.instruments_.size()); + result.basisSlot_.smoothingWeight_ = spec.basis_.smoothingWeight_; + result.basisSlot_.definition_ = result.basisDefinition_; + result.basisSlot_.layout_ = result.basisLayout_; + REQUIRE(result.basisSlot_.nParams_ > 0, "XCCY basis slot '" + spec.basis_.curveName_ + "' requires at least one parameter"); + if (!spec.basis_.initialGuessPerNode_.empty()) + REQUIRE(static_cast(spec.basis_.initialGuessPerNode_.size()) == result.basisSlot_.nParams_, + "XCCY basis slot '" + spec.basis_.curveName_ + "' initialGuessPerNode_ length must equal its parameter count"); + result.totalParameters_ = parameterOffset + result.basisSlot_.nParams_; + result.totalResiduals_ = residualOffset + result.basisSlot_.nInstruments_; + return result; + } + + String_ AnalyticIneligibilityReason(const JointLayout_& layout) { + String_ result = JointCalibrationInternal::AnalyticIneligibilityReason(layout.domesticCollection_, layout.domesticSlots_); + if (!result.empty()) + return result; + return JointCalibrationInternal::AnalyticIneligibilityReason(layout.foreignCollection_, layout.foreignSlots_); + } + + class JointXccyResidualFunction_ : public Underdetermined::Function_ { + const JointXccyCalibrationSpec_* spec_; + const JointLayout_* layout_; + const Vector_* plans_; + Handle_ fixings_; + CurveJacobianMode_ jacobianMode_; + mutable int evaluationCount_ = 0; + + template Vector_ Residuals(const Vector_& parameters) const { + auto domestic = JointCalibrationInternal::BuildTypedCurveBlock(layout_->domesticCollection_, layout_->domesticSlots_, parameters); + auto foreign = JointCalibrationInternal::BuildTypedCurveBlock(layout_->foreignCollection_, layout_->foreignSlots_, parameters); + const auto basis = + BuildDiscountCurveT(layout_->basisDefinition_, JointCalibrationInternal::SliceParameters(parameters, layout_->basisSlot_)); + + Vector_ result(layout_->totalResiduals_); + if constexpr (std::is_same_v) { + const Handle_ domesticBlock = JointCalibrationInternal::ReferenceCurveBlock(layout_->domesticCollection_, domestic); + const Handle_ foreignBlock = JointCalibrationInternal::ReferenceCurveBlock(layout_->foreignCollection_, foreign); + JointCalibrationInternal::AppendDoubleResiduals(layout_->domesticSlots_, *domesticBlock, &result); + JointCalibrationInternal::AppendDoubleResiduals(layout_->foreignSlots_, *foreignBlock, &result); + } else { + JointCalibrationInternal::AppendTemplatedResiduals(layout_->domesticSlots_, domestic.block_, &result); + JointCalibrationInternal::AppendTemplatedResiduals(layout_->foreignSlots_, foreign.block_, &result); + } + for (int i = 0; i < static_cast(plans_->size()); ++i) { + XccyMarketView_ market; + market.valuationTime_ = spec_->valuationTime_; + market.pair_ = spec_->pair_; + market.collateralCurrency_ = spec_->collateralCurrency_; + market.fxSpot_ = T_(spec_->fxSpot_); + market.domestic_ = &domestic.block_; + market.foreign_ = &foreign.block_; + market.basis_ = basis.get(); + result[layout_->basisSlot_.residualOffset_ + i] = + PriceXccyParSpread((*plans_)[i], market, *fixings_) - T_(spec_->basis_.instruments_[i]->MarketRate()); + } + return result; + } + + public: + JointXccyResidualFunction_(const JointXccyCalibrationSpec_& spec, + const JointLayout_& layout, + const Vector_& plans, + const Handle_& fixings, + CurveJacobianMode_ jacobianMode) + : spec_(&spec), layout_(&layout), plans_(&plans), fixings_(fixings), jacobianMode_(jacobianMode) {} + + [[nodiscard]] Vector_<> F(const Vector_<>& parameters) const override { + ++evaluationCount_; + return Residuals(parameters); + } + + [[nodiscard]] Underdetermined::Jacobian_* Gradient(const Vector_<>& parameters, const Vector_<>&) const override { + if (jacobianMode_ != CurveJacobianMode_::Value_::ANALYTIC) + return nullptr; + auto* tape = Dal::AAD::Tape(); + TapeGuard_ guard(tape); + Vector_ activeParameters = RegisterCurveParameters(parameters); + Dal::AAD::NewRecording(*tape); + Vector_ residuals = Residuals(activeParameters); + return new XCurveJacobian_(HarvestCurveJacobian(*tape, activeParameters, residuals)); + } + + [[nodiscard]] int EvaluationCount() const { return evaluationCount_; } + }; + + Vector_<> BuildInitialGuess(const JointXccyCalibrationSpec_& spec, const JointLayout_& layout) { + Vector_<> result(layout.totalParameters_, spec.initialGuess_); + auto appendCurrency = [&](const CurveCollectionSpec_& collection, const std::vector& slots) { + for (const auto& slot : slots) { + const JointCurveDeclaration_& declaration = (*collection.curves_)[slot.curveIndex_]; + const Vector_<> slice = JointCalibrationInternal::BuildGuessSlice( + declaration, slot.nParams_, spec.initialGuess_, JointCalibrationInternal::SlotName(collection, slot.curveIndex_)); + for (int i = 0; i < slot.nParams_; ++i) + result[slot.paramOffset_ + i] = slice[i]; + } + }; + appendCurrency(layout.domesticCollection_, layout.domesticSlots_); + appendCurrency(layout.foreignCollection_, layout.foreignSlots_); + if (!spec.basis_.initialGuessPerNode_.empty()) + for (int i = 0; i < layout.basisSlot_.nParams_; ++i) + result[layout.basisSlot_.paramOffset_ + i] = spec.basis_.initialGuessPerNode_[i]; + return result; + } + + std::unique_ptr BuildSmoothing(const JointLayout_& layout) { + auto result = std::make_unique(layout.totalParameters_); + JointCalibrationInternal::AddCurveSmoothing(layout.domesticSlots_, result.get()); + JointCalibrationInternal::AddCurveSmoothing(layout.foreignSlots_, result.get()); + JointCalibrationInternal::AddCurveSmoothing({layout.basisSlot_}, result.get()); + return result; + } + + struct SolveResult_ { + Vector_<> parameters_; + Matrix_<> effectiveInverse_; + Matrix_<> forwardJacobian_; + bool hasEffectiveInverse_ = false; + bool approximate_ = false; + }; + + SolveResult_ Solve(const JointXccyCalibrationSpec_& spec, + const JointXccyCalibrationOptions_& options, + const JointXccyResidualFunction_& function, + const Vector_<>& guess, + const Sparse::TriDiagonal_& smoothing, + int residualCount) { + Dictionary_ controlsDictionary; + controlsDictionary.Insert(KEY_MAX_EVALUATIONS, Cell_(static_cast(spec.maxEvaluations_))); + controlsDictionary.Insert(KEY_MAX_RESTARTS, Cell_(static_cast(spec.maxRestarts_))); + UnderdeterminedControls_ controls(controlsDictionary); + const Vector_<> tolerance(residualCount, spec.tolerance_); + + SolveResult_ result; + result.approximate_ = spec.solveMode_ == CurveSolveMode_::Value_::APPROXIMATE; + if (result.approximate_) { + result.parameters_ = Underdetermined::Approximate(function, guess, tolerance, spec.fitTolerance_, smoothing, controls); + return result; + } + + result.hasEffectiveInverse_ = options.computeEffJacobianInverse_; + const bool wantForward = options.computeForwardJacobian_ && options.jacobianMode_ == CurveJacobianMode_::Value_::ANALYTIC; + std::unique_ptr decomposition(smoothing.DecomposeSymmetric()); + result.parameters_ = Underdetermined::Find(function, guess, tolerance, *decomposition, controls, + result.hasEffectiveInverse_ ? &result.effectiveInverse_ : nullptr, + wantForward ? &result.forwardJacobian_ : nullptr); + return result; + } + + void AppendRanges(const String_& group, + const CurveCollectionSpec_& collection, + const std::vector& slots, + Vector_* parameterRanges, + Vector_* residualRanges) { + for (const auto& slot : slots) { + const String_ name = group + ":" + (*collection.curves_)[slot.curveIndex_].curveName_; + parameterRanges->push_back({name, slot.paramOffset_, slot.nParams_}); + residualRanges->push_back({name, slot.residualOffset_, slot.nInstruments_}); + } + } + + CrossCurrencyFxForwardCurve_ BuildFxForwards(const JointXccyCalibrationSpec_& spec, + const Handle_& domestic, + const Handle_& foreign, + const Handle_& basis, + const Handle_& fixings) { + CrossCurrencyMarket_ market(domestic, foreign, spec.fxSpot_, spec.valuationTime_, spec.collateralCurrency_, fixings); + market.SetBasisCurve(basis); + CrossCurrencyFxForwardCurve_ result; + result.pair_ = spec.pair_; + result.dates_ = spec.basis_.knotDates_; + const CollateralType_ collateral = spec.basis_.instruments_.front()->Config().convention_.domesticIndex_.collateral_; + for (const auto& date : result.dates_) + result.forwards_.push_back(market.FxForward(spec.valuationTime_.Date(), date, collateral)); + return result; + } + + JointXccyCalibrationResult_ AssembleResult(const JointXccyCalibrationSpec_& spec, + const JointLayout_& layout, + const JointXccyResidualFunction_& function, + const Handle_& fixings, + SolveResult_* solve) { + JointXccyCalibrationResult_ result; + result.usedApproximateFit_ = solve->approximate_; + result.fixings_ = fixings; + result.domesticCurveBlock_ = + JointCalibrationInternal::BuildCurveBlock(layout.domesticCollection_, layout.domesticSlots_, solve->parameters_); + result.foreignCurveBlock_ = + JointCalibrationInternal::BuildCurveBlock(layout.foreignCollection_, layout.foreignSlots_, solve->parameters_); + result.basisCurve_ = + Handle_(BuildDiscountCurveUniqueT( + layout.basisDefinition_, JointCalibrationInternal::SliceParameters(solve->parameters_, layout.basisSlot_)) + .release()); + result.fxForwardCurve_ = BuildFxForwards(spec, result.domesticCurveBlock_, result.foreignCurveBlock_, result.basisCurve_, fixings); + result.residuals_ = function.F(solve->parameters_); + + for (const auto& slot : layout.domesticSlots_) + result.domesticDiagnostics_.push_back(JointCalibrationInternal::BuildCurveDiagnostics( + spec.domestic_.curves_[slot.curveIndex_], slot, *result.domesticCurveBlock_, solve->approximate_)); + for (const auto& slot : layout.foreignSlots_) + result.foreignDiagnostics_.push_back(JointCalibrationInternal::BuildCurveDiagnostics( + spec.foreign_.curves_[slot.curveIndex_], slot, *result.foreignCurveBlock_, solve->approximate_)); + + result.marketRates_ = Vector_<>(layout.totalResiduals_); + result.modelRates_ = Vector_<>(layout.totalResiduals_); + for (const auto& slot : layout.domesticSlots_) { + for (int i = 0; i < slot.nInstruments_; ++i) + result.marketRates_[slot.residualOffset_ + i] = slot.marketRates_[i]; + } + for (const auto& slot : layout.foreignSlots_) { + for (int i = 0; i < slot.nInstruments_; ++i) + result.marketRates_[slot.residualOffset_ + i] = slot.marketRates_[i]; + } + for (int i = 0; i < static_cast(spec.basis_.instruments_.size()); ++i) + result.marketRates_[layout.basisSlot_.residualOffset_ + i] = spec.basis_.instruments_[i]->MarketRate(); + for (int i = 0; i < layout.totalResiduals_; ++i) + result.modelRates_[i] = result.marketRates_[i] + result.residuals_[i]; + + result.xccyDiagnostics_.usedApproximateFit_ = solve->approximate_; + for (int i = 0; i < static_cast(spec.basis_.instruments_.size()); ++i) { + const int row = layout.basisSlot_.residualOffset_ + i; + result.xccyDiagnostics_.instrumentNames_.push_back(spec.basis_.instruments_[i]->Name()); + result.xccyDiagnostics_.marketRates_.push_back(result.marketRates_[row]); + result.xccyDiagnostics_.modelRates_.push_back(result.modelRates_[row]); + result.xccyDiagnostics_.residuals_.push_back(result.residuals_[row]); + } + const ResidualStats_ xccyStats = ResidualStats(result.xccyDiagnostics_.residuals_); + result.xccyDiagnostics_.maxAbsResidual_ = xccyStats.maxAbsResidual_; + result.xccyDiagnostics_.rmsResidual_ = xccyStats.rmsResidual_; + + const ResidualStats_ jointStats = ResidualStats(result.residuals_); + result.jointMaxAbsResidual_ = jointStats.maxAbsResidual_; + result.jointRmsResidual_ = jointStats.rmsResidual_; + result.solverEvaluations_ = function.EvaluationCount(); + if (solve->hasEffectiveInverse_) + result.effJacobianInverse_ = std::move(solve->effectiveInverse_); + result.jacobianAtSolution_ = std::move(solve->forwardJacobian_); + AppendRanges("domestic", layout.domesticCollection_, layout.domesticSlots_, &result.parameterRanges_, &result.residualRanges_); + AppendRanges("foreign", layout.foreignCollection_, layout.foreignSlots_, &result.parameterRanges_, &result.residualRanges_); + result.parameterRanges_.push_back( + {String_("basis:") + spec.basis_.curveName_, layout.basisSlot_.paramOffset_, layout.basisSlot_.nParams_}); + result.residualRanges_.push_back( + {String_("xccy:") + spec.basis_.curveName_, layout.basisSlot_.residualOffset_, layout.basisSlot_.nInstruments_}); + result.converged_ = true; + return result; + } + } // namespace + + JointXccyCalibrationResult_ CalibrateJointXccyMarket(const JointXccyCalibrationSpec_& spec) { + return CalibrateJointXccyMarket(spec, JointXccyCalibrationOptions_()); + } + + JointXccyCalibrationResult_ CalibrateJointXccyMarket(const JointXccyCalibrationSpec_& spec, const JointXccyCalibrationOptions_& options) { + ValidateTopLevelSpec(spec); + REQUIRE(options.jacobianMode_ == CurveJacobianMode_::Value_::ANALYTIC || options.jacobianMode_ == CurveJacobianMode_::Value_::BUMPED, + "Joint XCCY calibration requires ANALYTIC or BUMPED Jacobian mode"); + const JointLayout_ layout = BuildLayout(spec); + const Vector_ plans = ValidateAndBuildPlans(spec); + const Handle_ fixings = ResolveFixings(spec, plans); + if (options.jacobianMode_ == CurveJacobianMode_::Value_::ANALYTIC) { + const String_ reason = AnalyticIneligibilityReason(layout); + REQUIRE(reason.empty(), "Joint XCCY analytic Jacobian is ineligible: " + reason); + } + + const Vector_<> guess = BuildInitialGuess(spec, layout); + const std::unique_ptr smoothing = BuildSmoothing(layout); + JointXccyResidualFunction_ function(spec, layout, plans, fixings, options.jacobianMode_); + SolveResult_ solve = Solve(spec, options, function, guess, *smoothing, layout.totalResiduals_); + JointXccyCalibrationResult_ result = AssembleResult(spec, layout, function, fixings, &solve); + const double convergenceBound = spec.solveMode_ == CurveSolveMode_::Value_::EXACT ? 10.0 * spec.tolerance_ : 10.0 * spec.fitTolerance_; + REQUIRE(result.jointMaxAbsResidual_ <= convergenceBound, "Joint XCCY calibration failed to converge for pair " + PairName(spec.pair_) + + ": maxAbsResidual = " + String::FromDouble(result.jointMaxAbsResidual_) + + " after " + String::FromInt(result.solverEvaluations_) + " evaluations"); + return result; + } +} // namespace Dal diff --git a/dal-cpp/dal/curve/xccyjointcalibration.hpp b/dal-cpp/dal/curve/xccyjointcalibration.hpp new file mode 100644 index 000000000..46732d3d7 --- /dev/null +++ b/dal-cpp/dal/curve/xccyjointcalibration.hpp @@ -0,0 +1,89 @@ +// +// Created by Codex on 2026/7/14. +// + +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace Dal { + struct CalibrationBlockRange_ { + String_ name_; + int offset_ = 0; + int size_ = 0; + }; + + struct JointCurrencyCurveSpec_ { + Ccy_ ccy_; + DayBasis_ liborBasis_ = DayBasis_("ACT_365F"); + Vector_ curves_; + }; + + struct XccyBasisCurveDeclaration_ { + String_ curveName_ = "xccy_basis"; + Vector_> instruments_; + Vector_ knotDates_; + CurveParameterization_ parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + double smoothingWeight_ = 1.0; + Vector_ initialGuessPerNode_; + }; + + struct JointXccyCalibrationSpec_ { + DateTime_ valuationTime_; + CurrencyPair_ pair_; + Ccy_ collateralCurrency_; + double fxSpot_ = 0.0; + JointCurrencyCurveSpec_ domestic_; + JointCurrencyCurveSpec_ foreign_; + XccyBasisCurveDeclaration_ basis_; + Handle_ fixings_; + double tolerance_ = 1.0e-8; + double fitTolerance_ = 1.0e-6; + double initialGuess_ = 0.0; + int maxEvaluations_ = 200; + int maxRestarts_ = 20; + CurveSolveMode_ solveMode_ = CurveSolveMode_::Value_::EXACT; + }; + + struct JointXccyCalibrationOptions_ { + CurveJacobianMode_ jacobianMode_ = CurveJacobianMode_::Value_::ANALYTIC; + bool computeEffJacobianInverse_ = true; + bool computeForwardJacobian_ = true; + }; + + struct JointXccyCalibrationResult_ { + Handle_ domesticCurveBlock_; + Handle_ foreignCurveBlock_; + Handle_ basisCurve_; + CrossCurrencyFxForwardCurve_ fxForwardCurve_; + Handle_ fixings_; + + Vector_ domesticDiagnostics_; + Vector_ foreignDiagnostics_; + CrossCurrencyCalibrationDiagnostics_ xccyDiagnostics_; + Vector_<> marketRates_; + Vector_<> modelRates_; + Vector_<> residuals_; + + Matrix_<> jacobianAtSolution_; + Matrix_<> effJacobianInverse_; + Vector_ parameterRanges_; + Vector_ residualRanges_; + + double jointMaxAbsResidual_ = 0.0; + double jointRmsResidual_ = 0.0; + bool usedApproximateFit_ = false; + bool converged_ = false; + int solverEvaluations_ = 0; + }; + + [[nodiscard]] JointXccyCalibrationResult_ CalibrateJointXccyMarket(const JointXccyCalibrationSpec_& spec); + [[nodiscard]] JointXccyCalibrationResult_ CalibrateJointXccyMarket(const JointXccyCalibrationSpec_& spec, + const JointXccyCalibrationOptions_& options); +} // namespace Dal diff --git a/dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp b/dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp index 99d4bf16b..c301b2c92 100644 --- a/dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp +++ b/dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp @@ -920,6 +920,17 @@ TEST(JointAnalyticJacobianTest, TestAnalyticEligibleAgreesWithBumped) { // The analytic path engaged: jacobianAtSolution_ is populated for ANALYTIC + eligible + EXACT. ASSERT_FALSE(rAnalytic.jacobianAtSolution_.Empty()); ASSERT_TRUE(rBumped.jacobianAtSolution_.Empty()); + int expectedRows = 0; + int expectedColumns = 0; + for (const auto& declaration : spec.curves_) { + expectedRows += static_cast(declaration.instruments_.size()); + expectedColumns += + BuildCurveParameterLayout(MakeCurveDefinition(declaration.curveName_, spec.ccy_, declaration.parameterization_, declaration.logDfScheme_, + declaration.knotDates_, spec.today_, spec.liborBasis_)) + .parameterCount_; + } + ASSERT_EQ(rAnalytic.jacobianAtSolution_.Rows(), expectedRows); + ASSERT_EQ(rAnalytic.jacobianAtSolution_.Cols(), expectedColumns); // Per-pillar OIS DFs agree to the smoothing-fit residual floor (both paths solve the same // system with differently-computed Jacobians; the solution x should be essentially identical). diff --git a/dal-cpp/tests/curve/test_joint_calibration.cpp b/dal-cpp/tests/curve/test_joint_calibration.cpp index 4ad965202..6fd211b07 100644 --- a/dal-cpp/tests/curve/test_joint_calibration.cpp +++ b/dal-cpp/tests/curve/test_joint_calibration.cpp @@ -329,6 +329,22 @@ TEST(JointCalibrationTest, TestJointCalibrationConvergesAndFitsInstruments) { ASSERT_TRUE(result.solverEvaluations_ > 0); } +TEST(JointCalibrationTest, TestSharedInternalExtractionPreservesDuplicateDisplayNamesAcrossDistinctSlots) { + RegisterAll_::Init(); + const Date_ today(2024, 1, 15); + const Ccy_ ccy("USD"); + const PrototypeSet_ proto = BuildPrototypes(today, ccy); + JointMultiCurveCalibrationSpec_ spec = BuildCanonicalJointSpec(today, ccy, proto); + spec.curves_[1].curveName_ = spec.curves_[0].curveName_; + + const JointMultiCurveCalibrationResult_ result = CalibrateJointMultiCurve(spec); + + ASSERT_TRUE(result.converged_); + ASSERT_EQ(result.diagnostics_.size(), 2); + ASSERT_EQ(result.diagnostics_[0].curveName_, result.diagnostics_[1].curveName_); + ASSERT_LE(result.jointMaxAbsResidual_, 1.0e-7); +} + TEST(JointCalibrationTest, TestHomogeneousZeroRateCalibrationPreservesDeclarationAndKnotOrder) { RegisterAll_::Init(); const Date_ today(2024, 1, 15); diff --git a/dal-cpp/tests/curve/test_xccyjointcalibration.cpp b/dal-cpp/tests/curve/test_xccyjointcalibration.cpp new file mode 100644 index 000000000..26f4a0bb7 --- /dev/null +++ b/dal-cpp/tests/curve/test_xccyjointcalibration.cpp @@ -0,0 +1,420 @@ +// +// Created by Codex on 2026/7/14. +// + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Dal; + +namespace { + struct CurrencyFixture_ { + JointCurrencyCurveSpec_ spec_; + Handle_ market_; + Vector_<> discountParameters_; + Vector_<> forwardParameters_; + RateIndexConvention_ discountIndex_; + RateIndexConvention_ forwardIndex_; + }; + + Vector_ JointKnots(const Date_& today) { + return { + Date::AddMonths(today, 6), Date::AddMonths(today, 18), Date::AddMonths(today, 30), Date::AddMonths(today, 42), Date::AddMonths(today, 54), + }; + } + + Vector_ InstrumentMaturities(const Date_& today) { + return { + Date::AddMonths(today, 12), Date::AddMonths(today, 24), Date::AddMonths(today, 36), + Date::AddMonths(today, 48), Date::AddMonths(today, 60), + }; + } + + RateIndexConvention_ MakeIndex(bool projection, const PeriodLength_& tenor) { + RateIndexConvention_ result; + result.useProjectionCurve_ = projection; + result.forecastTenor_ = tenor; + result.dayBasis_ = DayBasis_("ACT_365F"); + result.collateral_ = CollateralType_(CollateralType_::Value_::OIS); + result.fixingLag_ = 0; + result.fixingHolidays_ = Holidays::None(); + result.accrualHolidays_ = Holidays::None(); + return result; + } + + Handle_ QuotedDeposit(const Date_& today, const Date_& maturity, const RateIndexConvention_& index, const CurveBlock_& market) { + const Handle_ prototype(new Deposit_(today, today, maturity, 0.0, index)); + const double quote = (*prototype->Precompute(Handle_()))(market); + return Handle_(new Deposit_(today, today, maturity, quote, index)); + } + + CurrencyFixture_ MakeCurrencyFixture(const Date_& today, + const Ccy_& ccy, + const Vector_& knots, + const Vector_& maturities, + const Vector_<>& discountParameters, + const Vector_<>& forwardParameters) { + CurrencyFixture_ result; + result.discountParameters_ = discountParameters; + result.forwardParameters_ = forwardParameters; + result.discountIndex_ = MakeIndex(false, PeriodLength_("3M")); + result.forwardIndex_ = MakeIndex(true, PeriodLength_("3M")); + + const Handle_ discount( + NewDiscountPWC(String_(ccy.String()) + "_true_ois", ccy.String(), PiecewiseConstant_(knots, discountParameters))); + const Handle_ forward( + NewDiscountPWC(String_(ccy.String()) + "_true_3m", ccy.String(), PiecewiseConstant_(knots, forwardParameters))); + result.market_ = Handle_(new CurveBlock_(String_(ccy.String()) + "_true", ccy.String(), + {{CollateralType_(CollateralType_::Value_::OIS), discount}}, + {{PeriodLength_("3M"), forward}}, DayBasis_("ACT_365F"))); + + JointCurveDeclaration_ discountDeclaration; + discountDeclaration.curveName_ = String_(ccy.String()) + "_ois"; + discountDeclaration.knotDates_ = knots; + discountDeclaration.targetCollateral_ = CollateralType_(CollateralType_::Value_::OIS); + discountDeclaration.calibrateDiscountCurve_ = true; + discountDeclaration.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + discountDeclaration.smoothingWeight_ = 1.0; + + JointCurveDeclaration_ forwardDeclaration; + forwardDeclaration.curveName_ = String_(ccy.String()) + "_3m"; + forwardDeclaration.knotDates_ = knots; + forwardDeclaration.targetCollateral_ = CollateralType_(CollateralType_::Value_::OIS); + forwardDeclaration.targetTenor_ = PeriodLength_("3M"); + forwardDeclaration.calibrateDiscountCurve_ = false; + forwardDeclaration.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + forwardDeclaration.smoothingWeight_ = 1.0; + + for (const auto& maturity : maturities) { + discountDeclaration.instruments_.push_back(QuotedDeposit(today, maturity, result.discountIndex_, *result.market_)); + forwardDeclaration.instruments_.push_back(QuotedDeposit(today, maturity, result.forwardIndex_, *result.market_)); + } + + result.spec_.ccy_ = ccy; + result.spec_.liborBasis_ = DayBasis_("ACT_365F"); + result.spec_.curves_ = {discountDeclaration, forwardDeclaration}; + return result; + } + + CrossCurrencySwapConfig_ MakeXccyConfig(const CurrencyPair_& pair, + const RateIndexConvention_& domesticIndex, + const RateIndexConvention_& foreignIndex, + XccyNotionalMode_ notionalMode) { + RateLegConvention_ leg; + leg.paymentFrequency_ = PeriodLength_("3M"); + leg.dayBasis_ = DayBasis_("ACT_365F"); + leg.accrualHolidays_ = Holidays::None(); + leg.paymentHolidays_ = Holidays::None(); + + CrossCurrencySwapConfig_ result; + result.pair_ = pair; + result.domesticNotional_ = 110.0; + result.foreignNotional_ = 100.0; + result.convention_.initialNotionalExchange_ = true; + result.convention_.finalNotionalExchange_ = true; + result.convention_.spreadOnForeignLeg_ = true; + result.convention_.domesticIndex_ = domesticIndex; + result.convention_.foreignIndex_ = foreignIndex; + result.convention_.domesticLeg_ = leg; + result.convention_.foreignLeg_ = leg; + result.notionalMode_ = notionalMode; + result.fxReset_.fixingLag_ = 0; + result.fxReset_.fixingHolidays_ = Holidays::None(); + result.fxReset_.fixingHour_ = 11; + result.fxReset_.fixingMinute_ = 0; + result.domesticRateFixing_ = {String_(pair.domestic_.String()) + "-JOINT-3M", 11, 0}; + result.foreignRateFixing_ = {String_(pair.foreign_.String()) + "-JOINT-3M", 11, 0}; + return result; + } + + struct JointFixture_ { + JointXccyCalibrationSpec_ spec_; + Vector_<> domesticDiscountParameters_; + Vector_<> domesticForwardParameters_; + Vector_<> foreignDiscountParameters_; + Vector_<> foreignForwardParameters_; + Vector_<> basisParameters_; + }; + + JointFixture_ MakeJointFixture() { + JointFixture_ result; + const Date_ today(2025, 1, 16); + const Vector_ knots = JointKnots(today); + const Vector_ maturities = InstrumentMaturities(today); + result.domesticDiscountParameters_ = {0.0150, 0.0160, 0.0170, 0.0180, 0.0190}; + result.domesticForwardParameters_ = {0.0240, 0.0250, 0.0260, 0.0270, 0.0280}; + result.foreignDiscountParameters_ = {0.0100, 0.0110, 0.0120, 0.0130, 0.0140}; + result.foreignForwardParameters_ = {0.0190, 0.0200, 0.0210, 0.0220, 0.0230}; + result.basisParameters_ = {0.0010, 0.0014, 0.0018, 0.0022, 0.0026}; + + const CurrencyFixture_ domestic = + MakeCurrencyFixture(today, Ccy_("USD"), knots, maturities, result.domesticDiscountParameters_, result.domesticForwardParameters_); + const CurrencyFixture_ foreign = + MakeCurrencyFixture(today, Ccy_("EUR"), knots, maturities, result.foreignDiscountParameters_, result.foreignForwardParameters_); + const CurrencyPair_ pair(Ccy_("USD"), Ccy_("EUR")); + const DateTime_ valuationTime(today, 9, 0); + const Handle_ fixings(new MarketFixingSnapshot_()); + CrossCurrencyMarket_ quoteMarket(domestic.market_, foreign.market_, 1.10, valuationTime, pair.domestic_, fixings); + quoteMarket.SetBasisCurve( + Handle_(NewDiscountPWC("true_xccy_basis", "USD", PiecewiseConstant_(knots, result.basisParameters_)))); + + result.spec_.valuationTime_ = valuationTime; + result.spec_.pair_ = pair; + result.spec_.collateralCurrency_ = pair.domestic_; + result.spec_.fxSpot_ = 1.10; + result.spec_.domestic_ = domestic.spec_; + result.spec_.foreign_ = foreign.spec_; + result.spec_.basis_.curveName_ = "usd_eur_basis"; + result.spec_.basis_.knotDates_ = knots; + result.spec_.basis_.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + result.spec_.basis_.smoothingWeight_ = 1.0; + result.spec_.fixings_ = fixings; + result.spec_.tolerance_ = 1.0e-10; + result.spec_.fitTolerance_ = 1.0e-8; + result.spec_.initialGuess_ = 0.005; + result.spec_.maxEvaluations_ = 500; + result.spec_.maxRestarts_ = 40; + result.spec_.solveMode_ = CurveSolveMode_::Value_::EXACT; + + for (int i = 0; i < static_cast(maturities.size()); ++i) { + const XccyNotionalMode_ mode = i % 2 == 0 ? XccyNotionalMode_::Value_::RESETTABLE : XccyNotionalMode_::Value_::MARK_TO_MARKET; + const CrossCurrencySwapConfig_ config = MakeXccyConfig(pair, domestic.forwardIndex_, foreign.forwardIndex_, mode); + const CrossCurrencySwap_ prototype(today, today, maturities[i], 0.0, config); + const double quote = (*prototype.Precompute())(quoteMarket); + result.spec_.basis_.instruments_.push_back( + Handle_(new CrossCurrencySwap_(today, today, maturities[i], quote, config))); + } + return result; + } + + Vector_<> PwcParameters(const DiscountCurve_& curve) { + const auto* typed = dynamic_cast*>(&curve); + REQUIRE(typed, "Expected a piecewise-constant-forward curve"); + return typed->FRight(); + } + + void AssertParameters(const Vector_<>& actual, const Vector_<>& expected) { + ASSERT_EQ(actual.size(), expected.size()); + for (int i = 0; i < static_cast(actual.size()); ++i) + ASSERT_NEAR(actual[i], expected[i], 1.0e-8) << "parameter=" << i; + } + + void AssertCalibrationFailsWith(const JointXccyCalibrationSpec_& spec, const std::string& expected) { + try { + static_cast(CalibrateJointXccyMarket(spec)); + FAIL() << "Expected joint XCCY calibration to fail with " << expected; + } catch (const Dal::Exception_& exception) { + ASSERT_NE(std::string(exception.what()).find(expected), std::string::npos) << exception.what(); + } + } + + String_ EscapeRegex(const String_& value) { + static const String_ specials("\\.^$|()[]{}*+?"); + String_ result; + for (const char ch : value) { + if (specials.find(ch) != String_::npos) + result.push_back('\\'); + result.push_back(ch); + } + return result; + } + + class ScopedGlobalFixingHistoryRestore_ { + Vector_> saved_; + + public: + explicit ScopedGlobalFixingHistoryRestore_(const MarketFixingSnapshot_::values_t& values) { + for (const auto& indexHistory : values) + saved_.push_back({indexHistory.first, Global::Fixings_().History(indexHistory.first)}); + } + + ~ScopedGlobalFixingHistoryRestore_() { + for (const auto& saved : saved_) { + if (saved.second.vals_.empty()) + (void)ObjectAccess_::Erase(EscapeRegex(String_("##GLOBAL##FixingsFor:") + saved.first + String_("~"))); + else + XGLOBAL::StoreFixings(saved.first, saved.second, false); + } + } + }; + + void StoreGlobalFixings(const MarketFixingSnapshot_::values_t& values, double shift = 0.0) { + for (const auto& indexHistory : values) { + FixHistory_ history; + for (const auto& fixing : indexHistory.second) + history.vals_.push_back({fixing.first, fixing.second + shift}); + XGLOBAL::StoreFixings(indexHistory.first, history, false); + } + } +} // namespace + +TEST(XccyJointCalibrationTest, TestSyntheticMarketRecoversAllCurveParametersAndRepricesEveryGroup) { + const JointFixture_ fixture = MakeJointFixture(); + + const JointXccyCalibrationResult_ result = CalibrateJointXccyMarket(fixture.spec_); + + ASSERT_TRUE(result.converged_); + ASSERT_LE(result.jointMaxAbsResidual_, 1.0e-8); + ASSERT_EQ(result.domesticDiagnostics_.size(), 2); + ASSERT_EQ(result.foreignDiagnostics_.size(), 2); + ASSERT_LE(result.domesticDiagnostics_[0].maxAbsResidual_, 1.0e-8); + ASSERT_LE(result.domesticDiagnostics_[1].maxAbsResidual_, 1.0e-8); + ASSERT_LE(result.foreignDiagnostics_[0].maxAbsResidual_, 1.0e-8); + ASSERT_LE(result.foreignDiagnostics_[1].maxAbsResidual_, 1.0e-8); + ASSERT_LE(result.xccyDiagnostics_.maxAbsResidual_, 1.0e-8); + + AssertParameters(PwcParameters(result.domesticCurveBlock_->Discount(CollateralType_(CollateralType_::Value_::OIS))), + fixture.domesticDiscountParameters_); + AssertParameters(PwcParameters(result.domesticCurveBlock_->Forward(PeriodLength_("3M"), CollateralType_(CollateralType_::Value_::OIS))), + fixture.domesticForwardParameters_); + AssertParameters(PwcParameters(result.foreignCurveBlock_->Discount(CollateralType_(CollateralType_::Value_::OIS))), + fixture.foreignDiscountParameters_); + AssertParameters(PwcParameters(result.foreignCurveBlock_->Forward(PeriodLength_("3M"), CollateralType_(CollateralType_::Value_::OIS))), + fixture.foreignForwardParameters_); + AssertParameters(PwcParameters(*result.basisCurve_), fixture.basisParameters_); + + CrossCurrencyMarket_ solvedMarket(result.domesticCurveBlock_, result.foreignCurveBlock_, fixture.spec_.fxSpot_, fixture.spec_.valuationTime_, + fixture.spec_.collateralCurrency_, result.fixings_); + solvedMarket.SetBasisCurve(result.basisCurve_); + ASSERT_EQ(result.fxForwardCurve_.dates_, fixture.spec_.basis_.knotDates_); + ASSERT_EQ(result.fxForwardCurve_.forwards_.size(), result.fxForwardCurve_.dates_.size()); + for (int i = 0; i < static_cast(result.fxForwardCurve_.dates_.size()); ++i) + ASSERT_NEAR(result.fxForwardCurve_.forwards_[i], solvedMarket.FxForward(result.fxForwardCurve_.dates_[i]), 1.0e-12); + + ASSERT_EQ(result.marketRates_.size(), result.modelRates_.size()); + ASSERT_EQ(result.marketRates_.size(), result.residuals_.size()); + for (int i = 0; i < static_cast(result.residuals_.size()); ++i) + ASSERT_NEAR(result.modelRates_[i], result.marketRates_[i], 1.0e-8) << "residual=" << i; +} + +TEST(XccyJointCalibrationTest, TestValidationRejectsCurrencyAndCollateralMismatchesWithPairNames) { + JointFixture_ fixture = MakeJointFixture(); + fixture.spec_.domestic_.ccy_ = Ccy_("GBP"); + AssertCalibrationFailsWith(fixture.spec_, "USD"); + + fixture = MakeJointFixture(); + fixture.spec_.foreign_.ccy_ = Ccy_("GBP"); + AssertCalibrationFailsWith(fixture.spec_, "EUR"); + + fixture = MakeJointFixture(); + fixture.spec_.collateralCurrency_ = Ccy_("GBP"); + AssertCalibrationFailsWith(fixture.spec_, "USD/EUR"); +} + +TEST(XccyJointCalibrationTest, TestValidationRejectsUnorderedBasisKnotsWithSlotName) { + JointFixture_ fixture = MakeJointFixture(); + std::swap(fixture.spec_.basis_.knotDates_[1], fixture.spec_.basis_.knotDates_[2]); + AssertCalibrationFailsWith(fixture.spec_, fixture.spec_.basis_.curveName_.c_str()); +} + +TEST(XccyJointCalibrationTest, TestValidationRejectsMissingCurveSlotsWithTenor) { + JointFixture_ fixture = MakeJointFixture(); + fixture.spec_.domestic_.curves_.erase(fixture.spec_.domestic_.curves_.begin() + 1); + AssertCalibrationFailsWith(fixture.spec_, "domestic forward slot 3M"); +} + +TEST(XccyJointCalibrationTest, TestValidationRejectsInstrumentWithNoRemainingXccyAnnuity) { + JointFixture_ fixture = MakeJointFixture(); + const CrossCurrencySwapConfig_ config = fixture.spec_.basis_.instruments_.front()->Config(); + const Date_ maturity = fixture.spec_.valuationTime_.Date().AddDays(-1); + const Date_ start = Date::AddMonths(maturity, -12); + fixture.spec_.basis_.instruments_ = { + Handle_(new CrossCurrencySwap_(start, start, maturity, 0.0, config)), + }; + AssertCalibrationFailsWith(fixture.spec_, "no remaining foreign spread annuity"); +} + +TEST(XccyJointCalibrationTest, TestValidationRejectsInvalidSpotWithPairName) { + JointFixture_ fixture = MakeJointFixture(); + fixture.spec_.fxSpot_ = 0.0; + AssertCalibrationFailsWith(fixture.spec_, "USD/EUR"); + + fixture = MakeJointFixture(); + fixture.spec_.fxSpot_ = std::numeric_limits::quiet_NaN(); + AssertCalibrationFailsWith(fixture.spec_, "USD/EUR"); +} + +TEST(XccyJointCalibrationTest, TestValidationRejectsDuplicateDeclarationsWithSlotName) { + JointFixture_ fixture = MakeJointFixture(); + fixture.spec_.domestic_.curves_.push_back(fixture.spec_.domestic_.curves_.front()); + AssertCalibrationFailsWith(fixture.spec_, fixture.spec_.domestic_.curves_.front().curveName_.c_str()); +} + +TEST(XccyJointCalibrationTest, TestValidationRejectsEmptyInstrumentGroupsWithPairName) { + JointFixture_ fixture = MakeJointFixture(); + fixture.spec_.basis_.instruments_.clear(); + AssertCalibrationFailsWith(fixture.spec_, "USD/EUR"); + + fixture = MakeJointFixture(); + fixture.spec_.foreign_.curves_.front().instruments_.clear(); + AssertCalibrationFailsWith(fixture.spec_, fixture.spec_.foreign_.curves_.front().curveName_.c_str()); +} + +TEST(XccyJointCalibrationTest, TestOmittedFixingsCaptureAllHistoricalRequestsOnceAndExplicitSnapshotRemainsAuthoritative) { + JointFixture_ fixture = MakeJointFixture(); + const Date_ today = fixture.spec_.valuationTime_.Date(); + const Vector_ knots = JointKnots(today); + const Vector_ maturities = InstrumentMaturities(today); + const CurrencyFixture_ domestic = MakeCurrencyFixture(today, fixture.spec_.pair_.domestic_, knots, maturities, + fixture.domesticDiscountParameters_, fixture.domesticForwardParameters_); + const CurrencyFixture_ foreign = MakeCurrencyFixture(today, fixture.spec_.pair_.foreign_, knots, maturities, fixture.foreignDiscountParameters_, + fixture.foreignForwardParameters_); + const CrossCurrencySwapConfig_ config = + MakeXccyConfig(fixture.spec_.pair_, domestic.forwardIndex_, foreign.forwardIndex_, XccyNotionalMode_::Value_::MARK_TO_MARKET); + const Date_ startedDate(2024, 10, 15); + const Date_ startedMaturity = Date::AddMonths(startedDate, 12); + const DateTime_ historicalFixing(Date::AddMonths(startedDate, 3), 11, 0); + MarketFixingSnapshot_::values_t values; + values[config.domesticRateFixing_.indexName_][historicalFixing] = 0.040; + values[config.foreignRateFixing_.indexName_][historicalFixing] = 0.030; + values[FxIndexName(config.pair_)][historicalFixing] = 1.20; + const Handle_ explicitFixings(new MarketFixingSnapshot_(values)); + + CrossCurrencyMarket_ quoteMarket(domestic.market_, foreign.market_, fixture.spec_.fxSpot_, fixture.spec_.valuationTime_, + fixture.spec_.collateralCurrency_, explicitFixings); + quoteMarket.SetBasisCurve( + Handle_(NewDiscountPWC("true_started_basis", "USD", PiecewiseConstant_(knots, fixture.basisParameters_)))); + const CrossCurrencySwap_ prototype(startedDate, startedDate, startedMaturity, 0.0, config); + fixture.spec_.basis_.instruments_[0] = Handle_( + new CrossCurrencySwap_(startedDate, startedDate, startedMaturity, (*prototype.Precompute())(quoteMarket), config)); + fixture.spec_.fixings_ = explicitFixings; + + const ScopedGlobalFixingHistoryRestore_ restore(values); + StoreGlobalFixings(values); + const JointXccyCalibrationResult_ explicitResult = CalibrateJointXccyMarket(fixture.spec_); + JointXccyCalibrationSpec_ omittedSpec = fixture.spec_; + omittedSpec.fixings_ = Handle_(); + const JointXccyCalibrationResult_ capturedResult = CalibrateJointXccyMarket(omittedSpec); + + ASSERT_TRUE(capturedResult.fixings_); + for (const auto& indexHistory : values) + for (const auto& fixing : indexHistory.second) + ASSERT_NEAR(capturedResult.fixings_->Require(indexHistory.first, fixing.first, "captured joint XCCY fixing"), fixing.second, 1.0e-12); + StoreGlobalFixings(values, 0.10); + for (const auto& indexHistory : values) + for (const auto& fixing : indexHistory.second) + ASSERT_NEAR(capturedResult.fixings_->Require(indexHistory.first, fixing.first, "immutable joint XCCY fixing"), fixing.second, 1.0e-12); + + AssertParameters(PwcParameters(*capturedResult.basisCurve_), PwcParameters(*explicitResult.basisCurve_)); + ASSERT_NEAR(capturedResult.jointMaxAbsResidual_, explicitResult.jointMaxAbsResidual_, 1.0e-12); + + JointXccyCalibrationSpec_ emptyExplicit = fixture.spec_; + emptyExplicit.fixings_ = Handle_(new MarketFixingSnapshot_()); + AssertCalibrationFailsWith(emptyExplicit, FxIndexName(config.pair_).c_str()); +} diff --git a/dal-cpp/tests/curve/test_xccyjointjacobian.cpp b/dal-cpp/tests/curve/test_xccyjointjacobian.cpp new file mode 100644 index 000000000..e531525d4 --- /dev/null +++ b/dal-cpp/tests/curve/test_xccyjointjacobian.cpp @@ -0,0 +1,284 @@ +// +// Created by Codex on 2026/7/14. +// + +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Dal; + +namespace { + RateIndexConvention_ Index(bool projection) { + RateIndexConvention_ result; + result.useProjectionCurve_ = projection; + result.forecastTenor_ = PeriodLength_("3M"); + result.dayBasis_ = DayBasis_("ACT_365F"); + result.collateral_ = CollateralType_(CollateralType_::Value_::OIS); + result.fixingLag_ = 0; + result.fixingHolidays_ = Holidays::None(); + result.accrualHolidays_ = Holidays::None(); + return result; + } + + Handle_ Pwc(const String_& name, const Ccy_& ccy, const Vector_& knots, const Vector_<>& parameters) { + return Handle_(NewDiscountPWC(name, ccy.String(), PiecewiseConstant_(knots, parameters))); + } + + Handle_ Block( + const String_& name, const Ccy_& ccy, const Vector_& knots, const Vector_<>& discountParameters, const Vector_<>& forwardParameters) { + return Handle_( + new CurveBlock_(name, ccy.String(), {{CollateralType_(CollateralType_::Value_::OIS), Pwc(name + "_ois", ccy, knots, discountParameters)}}, + {{PeriodLength_("3M"), Pwc(name + "_3m", ccy, knots, forwardParameters)}}, DayBasis_("ACT_365F"))); + } + + Handle_ QuoteDeposit(const Date_& today, const Date_& maturity, const RateIndexConvention_& index, const CurveBlock_& block) { + const Handle_ prototype(new Deposit_(today, today, maturity, 0.0, index)); + const double quote = (*prototype->Precompute(Handle_()))(block); + return Handle_(new Deposit_(today, today, maturity, quote, index)); + } + + JointCurrencyCurveSpec_ CurrencySpec( + const Date_& today, const Ccy_& ccy, const Vector_& knots, const Vector_& maturities, const Handle_& market) { + JointCurveDeclaration_ discount; + discount.curveName_ = String_(ccy.String()) + "_ois"; + discount.knotDates_ = knots; + discount.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + discount.targetCollateral_ = CollateralType_(CollateralType_::Value_::OIS); + + JointCurveDeclaration_ forward; + forward.curveName_ = String_(ccy.String()) + "_3m"; + forward.knotDates_ = knots; + forward.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + forward.targetCollateral_ = CollateralType_(CollateralType_::Value_::OIS); + forward.targetTenor_ = PeriodLength_("3M"); + forward.calibrateDiscountCurve_ = false; + for (const auto& maturity : maturities) { + discount.instruments_.push_back(QuoteDeposit(today, maturity, Index(false), *market)); + forward.instruments_.push_back(QuoteDeposit(today, maturity, Index(true), *market)); + } + + JointCurrencyCurveSpec_ result; + result.ccy_ = ccy; + result.curves_ = {discount, forward}; + return result; + } + + CrossCurrencySwapConfig_ XccyConfig(const CurrencyPair_& pair, XccyNotionalMode_ mode) { + RateLegConvention_ leg; + leg.paymentFrequency_ = PeriodLength_("3M"); + leg.dayBasis_ = DayBasis_("ACT_365F"); + leg.accrualHolidays_ = Holidays::None(); + leg.paymentHolidays_ = Holidays::None(); + + CrossCurrencySwapConfig_ result; + result.pair_ = pair; + result.domesticNotional_ = 110.0; + result.foreignNotional_ = 100.0; + result.convention_.initialNotionalExchange_ = true; + result.convention_.finalNotionalExchange_ = true; + result.convention_.spreadOnForeignLeg_ = true; + result.convention_.domesticIndex_ = Index(true); + result.convention_.foreignIndex_ = Index(true); + result.convention_.domesticLeg_ = leg; + result.convention_.foreignLeg_ = leg; + result.notionalMode_ = mode; + result.fxReset_.fixingLag_ = 0; + result.fxReset_.fixingHolidays_ = Holidays::None(); + result.fxReset_.fixingHour_ = 11; + result.fxReset_.fixingMinute_ = 0; + result.domesticRateFixing_ = {"USD-JOINT-JAC", 11, 0}; + result.foreignRateFixing_ = {"EUR-JOINT-JAC", 11, 0}; + return result; + } + + struct Fixture_ { + JointXccyCalibrationSpec_ spec_; + Vector_ knots_; + }; + + Fixture_ MakeFixture() { + Fixture_ result; + const Date_ today(2025, 1, 16); + const Vector_ maturities = {Date::AddMonths(today, 12), Date::AddMonths(today, 24)}; + result.knots_ = {Date::AddMonths(today, 6), Date::AddMonths(today, 18)}; + const Handle_ domestic = Block("usd_true_jac", Ccy_("USD"), result.knots_, {0.015, 0.018}, {0.024, 0.027}); + const Handle_ foreign = Block("eur_true_jac", Ccy_("EUR"), result.knots_, {0.010, 0.013}, {0.019, 0.022}); + const Handle_ fixings(new MarketFixingSnapshot_()); + const CurrencyPair_ pair(Ccy_("USD"), Ccy_("EUR")); + CrossCurrencyMarket_ quoteMarket(domestic, foreign, 1.10, DateTime_(today, 9, 0), pair.domestic_, fixings); + quoteMarket.SetBasisCurve(Pwc("basis_true_jac", pair.domestic_, result.knots_, {0.0010, 0.0020})); + + result.spec_.valuationTime_ = DateTime_(today, 9, 0); + result.spec_.pair_ = pair; + result.spec_.collateralCurrency_ = pair.domestic_; + result.spec_.fxSpot_ = 1.10; + result.spec_.domestic_ = CurrencySpec(today, pair.domestic_, result.knots_, maturities, domestic); + result.spec_.foreign_ = CurrencySpec(today, pair.foreign_, result.knots_, maturities, foreign); + result.spec_.basis_.curveName_ = "usd_eur_basis_jac"; + result.spec_.basis_.knotDates_ = result.knots_; + result.spec_.fixings_ = fixings; + result.spec_.initialGuess_ = 0.005; + result.spec_.tolerance_ = 1.0e-10; + result.spec_.fitTolerance_ = 1.0e-8; + + for (int i = 0; i < static_cast(maturities.size()); ++i) { + const XccyNotionalMode_ mode = i == 0 ? XccyNotionalMode_::Value_::RESETTABLE : XccyNotionalMode_::Value_::MARK_TO_MARKET; + const CrossCurrencySwapConfig_ config = XccyConfig(pair, mode); + const CrossCurrencySwap_ prototype(today, today, maturities[i], 0.0, config); + result.spec_.basis_.instruments_.push_back( + Handle_(new CrossCurrencySwap_(today, today, maturities[i], (*prototype.Precompute())(quoteMarket), config))); + } + return result; + } + + Vector_<> Parameters(const JointXccyCalibrationResult_& result) { + auto extract = [](const DiscountCurve_& curve) { + const auto* typed = dynamic_cast*>(&curve); + REQUIRE(typed, "Expected PWC curve in joint Jacobian fixture"); + return typed->FRight(); + }; + Vector_<> parameters; + parameters.Append(extract(result.domesticCurveBlock_->Discount(CollateralType_(CollateralType_::Value_::OIS)))); + parameters.Append(extract(result.domesticCurveBlock_->Forward(PeriodLength_("3M"), CollateralType_(CollateralType_::Value_::OIS)))); + parameters.Append(extract(result.foreignCurveBlock_->Discount(CollateralType_(CollateralType_::Value_::OIS)))); + parameters.Append(extract(result.foreignCurveBlock_->Forward(PeriodLength_("3M"), CollateralType_(CollateralType_::Value_::OIS)))); + parameters.Append(extract(*result.basisCurve_)); + return parameters; + } + + Vector_<> Slice(const Vector_<>& parameters, int offset, int size) { + return Vector_<>(parameters.begin() + offset, parameters.begin() + offset + size); + } + + Vector_<> Residuals(const Fixture_& fixture, const Vector_<>& parameters) { + const Handle_ domestic = + Block("usd_bumped", fixture.spec_.pair_.domestic_, fixture.knots_, Slice(parameters, 0, 2), Slice(parameters, 2, 2)); + const Handle_ foreign = + Block("eur_bumped", fixture.spec_.pair_.foreign_, fixture.knots_, Slice(parameters, 4, 2), Slice(parameters, 6, 2)); + Vector_<> result; + for (const auto& declaration : fixture.spec_.domestic_.curves_) + for (const auto& instrument : declaration.instruments_) + result.push_back((*instrument->Precompute(Handle_()))(*domestic) - instrument->MarketRate()); + for (const auto& declaration : fixture.spec_.foreign_.curves_) + for (const auto& instrument : declaration.instruments_) + result.push_back((*instrument->Precompute(Handle_()))(*foreign) - instrument->MarketRate()); + + CrossCurrencyMarket_ market(domestic, foreign, fixture.spec_.fxSpot_, fixture.spec_.valuationTime_, fixture.spec_.collateralCurrency_, + fixture.spec_.fixings_); + market.SetBasisCurve(Pwc("basis_bumped", fixture.spec_.pair_.domestic_, fixture.knots_, Slice(parameters, 8, 2))); + for (const auto& instrument : fixture.spec_.basis_.instruments_) + result.push_back((*instrument->Precompute())(market)-instrument->MarketRate()); + return result; + } + + Matrix_<> CentralJacobian(const Fixture_& fixture, const Vector_<>& parameters) { + constexpr double bump = 1.0e-6; + const int rows = static_cast(Residuals(fixture, parameters).size()); + Matrix_<> result(rows, parameters.size()); + for (int column = 0; column < static_cast(parameters.size()); ++column) { + Vector_<> up = parameters; + Vector_<> down = parameters; + up[column] += bump; + down[column] -= bump; + const Vector_<> upResiduals = Residuals(fixture, up); + const Vector_<> downResiduals = Residuals(fixture, down); + for (int row = 0; row < rows; ++row) + result(row, column) = (upResiduals[row] - downResiduals[row]) / (2.0 * bump); + } + return result; + } + + void AssertPartition(const Vector_& ranges, int expectedSize) { + int offset = 0; + for (const auto& range : ranges) { + ASSERT_EQ(range.offset_, offset) << range.name_; + ASSERT_GT(range.size_, 0) << range.name_; + offset += range.size_; + } + ASSERT_EQ(offset, expectedSize); + } +} // namespace + +TEST(XccyJointJacobianTest, TestAnalyticStackMatchesCentralDifferencesAndRangesPartitionRowsAndColumns) { + const Fixture_ fixture = MakeFixture(); + const JointXccyCalibrationResult_ calibrated = CalibrateJointXccyMarket(fixture.spec_); + const Vector_<> parameters = Parameters(calibrated); + const Matrix_<> central = CentralJacobian(fixture, parameters); + + ASSERT_EQ(calibrated.jacobianAtSolution_.Rows(), central.Rows()); + ASSERT_EQ(calibrated.jacobianAtSolution_.Cols(), central.Cols()); + for (int row = 0; row < central.Rows(); ++row) + for (int column = 0; column < central.Cols(); ++column) + ASSERT_NEAR(calibrated.jacobianAtSolution_(row, column), central(row, column), 2.0e-7) << "row=" << row << " column=" << column; + + AssertPartition(calibrated.parameterRanges_, central.Cols()); + AssertPartition(calibrated.residualRanges_, central.Rows()); + ASSERT_EQ(calibrated.parameterRanges_.size(), 5); + ASSERT_EQ(calibrated.residualRanges_.size(), 5); + ASSERT_NE(calibrated.parameterRanges_[0].name_.find("domestic"), String_::npos); + ASSERT_NE(calibrated.parameterRanges_[2].name_.find("foreign"), String_::npos); + ASSERT_NE(calibrated.parameterRanges_[4].name_.find("basis"), String_::npos); + ASSERT_NE(calibrated.residualRanges_[4].name_.find("xccy"), String_::npos); +} + +TEST(XccyJointJacobianTest, TestExactApproximateAnalyticAndBumpedMatrixContracts) { + const Fixture_ fixture = MakeFixture(); + const JointXccyCalibrationResult_ analytic = CalibrateJointXccyMarket(fixture.spec_); + ASSERT_FALSE(analytic.jacobianAtSolution_.Empty()); + ASSERT_FALSE(analytic.effJacobianInverse_.Empty()); + ASSERT_FALSE(analytic.usedApproximateFit_); + + JointXccyCalibrationOptions_ bumpedOptions; + bumpedOptions.jacobianMode_ = CurveJacobianMode_::Value_::BUMPED; + const JointXccyCalibrationResult_ bumped = CalibrateJointXccyMarket(fixture.spec_, bumpedOptions); + ASSERT_TRUE(bumped.jacobianAtSolution_.Empty()); + ASSERT_FALSE(bumped.effJacobianInverse_.Empty()); + + JointXccyCalibrationOptions_ noMatrices; + noMatrices.computeEffJacobianInverse_ = false; + noMatrices.computeForwardJacobian_ = false; + const JointXccyCalibrationResult_ omitted = CalibrateJointXccyMarket(fixture.spec_, noMatrices); + ASSERT_TRUE(omitted.jacobianAtSolution_.Empty()); + ASSERT_TRUE(omitted.effJacobianInverse_.Empty()); + + JointXccyCalibrationSpec_ approximateSpec = fixture.spec_; + approximateSpec.solveMode_ = CurveSolveMode_::Value_::APPROXIMATE; + const JointXccyCalibrationResult_ approximate = CalibrateJointXccyMarket(approximateSpec); + ASSERT_TRUE(approximate.usedApproximateFit_); + ASSERT_TRUE(approximate.jacobianAtSolution_.Empty()); + ASSERT_TRUE(approximate.effJacobianInverse_.Empty()); + ASSERT_TRUE(approximate.xccyDiagnostics_.usedApproximateFit_); + for (const auto& diagnostics : approximate.domesticDiagnostics_) + ASSERT_TRUE(diagnostics.usedApproximateFit_); + for (const auto& diagnostics : approximate.foreignDiagnostics_) + ASSERT_TRUE(diagnostics.usedApproximateFit_); +} + +TEST(XccyJointJacobianTest, TestAnalyticIneligibleFailsWithReasonWhileBumpedRemainsAvailable) { + Fixture_ fixture = MakeFixture(); + fixture.spec_.foreign_.liborBasis_ = DayBasis_("ACT_360"); + try { + static_cast(CalibrateJointXccyMarket(fixture.spec_)); + FAIL() << "Expected analytic eligibility failure"; + } catch (const Dal::Exception_& exception) { + ASSERT_NE(std::string(exception.what()).find("ACT_365F"), std::string::npos) << exception.what(); + ASSERT_NE(std::string(exception.what()).find("Foreign"), std::string::npos) << exception.what(); + } + + JointXccyCalibrationOptions_ bumped; + bumped.jacobianMode_ = CurveJacobianMode_::Value_::BUMPED; + ASSERT_NO_THROW(static_cast(CalibrateJointXccyMarket(fixture.spec_, bumped))); +} From 7d91a8e2d54220fe9767afa6e84e2d296ce6fa08 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 03:07:45 +0800 Subject: [PATCH 11/38] fix(xccy): close joint calibration review gaps --- .../dal/curve/jointcalibration_internal.hpp | 86 ++++++++++++++++++- dal-cpp/dal/curve/xccyjointcalibration.cpp | 12 ++- .../tests/curve/test_joint_calibration.cpp | 16 ++++ .../tests/curve/test_xccyjointcalibration.cpp | 6 ++ .../tests/curve/test_xccyjointjacobian.cpp | 66 +++++++++++++- 5 files changed, 179 insertions(+), 7 deletions(-) diff --git a/dal-cpp/dal/curve/jointcalibration_internal.hpp b/dal-cpp/dal/curve/jointcalibration_internal.hpp index e256421fe..41567776a 100644 --- a/dal-cpp/dal/curve/jointcalibration_internal.hpp +++ b/dal-cpp/dal/curve/jointcalibration_internal.hpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -34,6 +35,7 @@ namespace Dal::JointCalibrationInternal { const Vector_* curves_ = nullptr; String_ context_ = "Joint multi-curve calibration"; String_ declarationLabel_ = "Declaration"; + bool requireCurveNames_ = false; }; struct CurveSlot_ { @@ -72,7 +74,8 @@ namespace Dal::JointCalibrationInternal { bool hasDiscount = false; for (int i = 0; i < static_cast(spec.curves_->size()); ++i) { const JointCurveDeclaration_& declaration = (*spec.curves_)[i]; - REQUIRE(!declaration.curveName_.empty(), spec.declarationLabel_ + " at index " + String::FromInt(i) + " requires a name"); + if (spec.requireCurveNames_) + REQUIRE(!declaration.curveName_.empty(), spec.declarationLabel_ + " at index " + String::FromInt(i) + " requires a name"); if (declaration.calibrateDiscountCurve_) { hasDiscount = true; REQUIRE(collaterals.insert(declaration.targetCollateral_).second, spec.context_ + " has duplicate declaration '" + @@ -293,6 +296,87 @@ namespace Dal::JointCalibrationInternal { return String_(); } + inline bool HasTypedDiscountRoute(const CurveCollectionSpec_& spec, const std::vector& slots, const CollateralType_& collateral) { + bool hasOis = false; + for (const auto& slot : slots) { + const JointCurveDeclaration_& declaration = (*spec.curves_)[slot.curveIndex_]; + if (!declaration.calibrateDiscountCurve_) + continue; + if (declaration.targetCollateral_ == collateral) + return true; + hasOis = hasOis || declaration.targetCollateral_ == CollateralType_(CollateralType_::Value_::OIS); + } + return hasOis; + } + + inline bool HasTypedForwardRoute(const CurveCollectionSpec_& spec, const std::vector& slots, const RateIndexConvention_& index) { + for (const auto& slot : slots) { + const JointCurveDeclaration_& declaration = (*spec.curves_)[slot.curveIndex_]; + if (!declaration.calibrateDiscountCurve_ && declaration.targetTenor_ == index.forecastTenor_) + return true; + } + return HasTypedDiscountRoute(spec, slots, index.collateral_); + } + + inline String_ TypedIndexAnalyticIneligibilityReason(const CurveCollectionSpec_& spec, + const std::vector& slots, + const RateIndexConvention_& index, + const String_& leg) { + if (!HasTypedDiscountRoute(spec, slots, index.collateral_)) + return leg + " discount route " + index.collateral_.String() + " is absent from the typed curve block"; + if (index.useProjectionCurve_ && !HasTypedForwardRoute(spec, slots, index)) + return leg + " projection route " + index.forecastTenor_.String() + " is absent from the typed curve block"; + return String_(); + } + + inline String_ XccyPlanAnalyticIneligibilityReason(const CurveCollectionSpec_& domestic, + const std::vector& domesticSlots, + const CurveCollectionSpec_& foreign, + const std::vector& foreignSlots, + const XccyCashflowPlan_& plan) { + if (domestic.ccy_ != plan.config_.pair_.domestic_.String()) + return "domestic typed curve block currency does not match the plan pair"; + if (foreign.ccy_ != plan.config_.pair_.foreign_.String()) + return "foreign typed curve block currency does not match the plan pair"; + if (plan.domesticPeriods_.empty() || plan.foreignPeriods_.empty()) + return "typed pricing requires coupon periods on both legs"; + + switch (plan.config_.notionalMode_.Switch()) { + case XccyNotionalMode_::Value_::FIXED: + break; + case XccyNotionalMode_::Value_::RESETTABLE: + case XccyNotionalMode_::Value_::MARK_TO_MARKET: + for (int i = 0; i < static_cast(plan.resets_.size()); ++i) { + if (plan.resets_[i].domesticPeriodIndex_ != i + 1 || + plan.resets_[i].domesticPeriodIndex_ >= static_cast(plan.domesticPeriods_.size())) + return "reset event " + String::FromInt(i) + " must map consecutively from the second domestic period"; + } + break; + default: + return String_("typed pricing does not support notional mode ") + plan.config_.notionalMode_.String(); + } + + String_ reason = TypedIndexAnalyticIneligibilityReason(domestic, domesticSlots, plan.config_.convention_.domesticIndex_, "domestic"); + if (!reason.empty()) + return reason; + return TypedIndexAnalyticIneligibilityReason(foreign, foreignSlots, plan.config_.convention_.foreignIndex_, "foreign"); + } + + inline String_ XccyPlansAnalyticIneligibilityReason(const CurveCollectionSpec_& domestic, + const std::vector& domesticSlots, + const CurveCollectionSpec_& foreign, + const std::vector& foreignSlots, + const Vector_& plans, + const Vector_>& instruments) { + REQUIRE(plans.size() == instruments.size(), "XCCY analytic eligibility requires one instrument per cashflow plan"); + for (int i = 0; i < static_cast(plans.size()); ++i) { + const String_ reason = XccyPlanAnalyticIneligibilityReason(domestic, domesticSlots, foreign, foreignSlots, plans[i]); + if (!reason.empty()) + return "XCCY instrument " + String::FromInt(i) + " ('" + instruments[i]->Name() + "') is analytically ineligible: " + reason; + } + return String_(); + } + template Handle_> DiscountRate(const YCInstrument_& instrument) { return VisitRate( instrument, [](const Deposit_& value) { return value.PrecomputeT(); }, [](const FRA_& value) { return value.PrecomputeT(); }, diff --git a/dal-cpp/dal/curve/xccyjointcalibration.cpp b/dal-cpp/dal/curve/xccyjointcalibration.cpp index df6d1e352..756e0352b 100644 --- a/dal-cpp/dal/curve/xccyjointcalibration.cpp +++ b/dal-cpp/dal/curve/xccyjointcalibration.cpp @@ -44,6 +44,7 @@ namespace Dal { result.curves_ = ¤cy.curves_; result.context_ = String_(domestic ? "Domestic " : "Foreign ") + result.ccy_ + " joint calibration"; result.declarationLabel_ = String_(domestic ? "Domestic slot" : "Foreign slot"); + result.requireCurveNames_ = true; return result; } @@ -192,11 +193,16 @@ namespace Dal { return result; } - String_ AnalyticIneligibilityReason(const JointLayout_& layout) { + String_ + AnalyticIneligibilityReason(const JointXccyCalibrationSpec_& spec, const JointLayout_& layout, const Vector_& plans) { String_ result = JointCalibrationInternal::AnalyticIneligibilityReason(layout.domesticCollection_, layout.domesticSlots_); if (!result.empty()) return result; - return JointCalibrationInternal::AnalyticIneligibilityReason(layout.foreignCollection_, layout.foreignSlots_); + result = JointCalibrationInternal::AnalyticIneligibilityReason(layout.foreignCollection_, layout.foreignSlots_); + if (!result.empty()) + return result; + return JointCalibrationInternal::XccyPlansAnalyticIneligibilityReason( + layout.domesticCollection_, layout.domesticSlots_, layout.foreignCollection_, layout.foreignSlots_, plans, spec.basis_.instruments_); } class JointXccyResidualFunction_ : public Underdetermined::Function_ { @@ -439,7 +445,7 @@ namespace Dal { const Vector_ plans = ValidateAndBuildPlans(spec); const Handle_ fixings = ResolveFixings(spec, plans); if (options.jacobianMode_ == CurveJacobianMode_::Value_::ANALYTIC) { - const String_ reason = AnalyticIneligibilityReason(layout); + const String_ reason = AnalyticIneligibilityReason(spec, layout, plans); REQUIRE(reason.empty(), "Joint XCCY analytic Jacobian is ineligible: " + reason); } diff --git a/dal-cpp/tests/curve/test_joint_calibration.cpp b/dal-cpp/tests/curve/test_joint_calibration.cpp index 6fd211b07..0326b1a45 100644 --- a/dal-cpp/tests/curve/test_joint_calibration.cpp +++ b/dal-cpp/tests/curve/test_joint_calibration.cpp @@ -345,6 +345,22 @@ TEST(JointCalibrationTest, TestSharedInternalExtractionPreservesDuplicateDisplay ASSERT_LE(result.jointMaxAbsResidual_, 1.0e-7); } +TEST(JointCalibrationTest, TestSharedInternalExtractionPreservesEmptyLegacyDisplayName) { + RegisterAll_::Init(); + const Date_ today(2024, 1, 15); + const Ccy_ ccy("USD"); + const PrototypeSet_ proto = BuildPrototypes(today, ccy); + JointMultiCurveCalibrationSpec_ spec = BuildCanonicalJointSpec(today, ccy, proto); + spec.curves_[0].curveName_.clear(); + + const JointMultiCurveCalibrationResult_ result = CalibrateJointMultiCurve(spec); + + ASSERT_TRUE(result.converged_); + ASSERT_EQ(result.diagnostics_.size(), 2); + ASSERT_TRUE(result.diagnostics_[0].curveName_.empty()); + ASSERT_LE(result.jointMaxAbsResidual_, 1.0e-7); +} + TEST(JointCalibrationTest, TestHomogeneousZeroRateCalibrationPreservesDeclarationAndKnotOrder) { RegisterAll_::Init(); const Date_ today(2024, 1, 15); diff --git a/dal-cpp/tests/curve/test_xccyjointcalibration.cpp b/dal-cpp/tests/curve/test_xccyjointcalibration.cpp index 26f4a0bb7..091ad435a 100644 --- a/dal-cpp/tests/curve/test_xccyjointcalibration.cpp +++ b/dal-cpp/tests/curve/test_xccyjointcalibration.cpp @@ -356,6 +356,12 @@ TEST(XccyJointCalibrationTest, TestValidationRejectsDuplicateDeclarationsWithSlo AssertCalibrationFailsWith(fixture.spec_, fixture.spec_.domestic_.curves_.front().curveName_.c_str()); } +TEST(XccyJointCalibrationTest, TestValidationKeepsXccyCurveNamesStrict) { + JointFixture_ fixture = MakeJointFixture(); + fixture.spec_.domestic_.curves_.front().curveName_.clear(); + AssertCalibrationFailsWith(fixture.spec_, "Domestic slot"); +} + TEST(XccyJointCalibrationTest, TestValidationRejectsEmptyInstrumentGroupsWithPairName) { JointFixture_ fixture = MakeJointFixture(); fixture.spec_.basis_.instruments_.clear(); diff --git a/dal-cpp/tests/curve/test_xccyjointjacobian.cpp b/dal-cpp/tests/curve/test_xccyjointjacobian.cpp index e531525d4..ce96cb475 100644 --- a/dal-cpp/tests/curve/test_xccyjointjacobian.cpp +++ b/dal-cpp/tests/curve/test_xccyjointjacobian.cpp @@ -9,8 +9,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -109,7 +111,8 @@ namespace { Vector_ knots_; }; - Fixture_ MakeFixture() { + Fixture_ MakeFixture(const Vector_& modes) { + REQUIRE(modes.size() == 2, "Joint XCCY Jacobian fixture requires two notional modes"); Fixture_ result; const Date_ today(2025, 1, 16); const Vector_ maturities = {Date::AddMonths(today, 12), Date::AddMonths(today, 24)}; @@ -135,8 +138,7 @@ namespace { result.spec_.fitTolerance_ = 1.0e-8; for (int i = 0; i < static_cast(maturities.size()); ++i) { - const XccyNotionalMode_ mode = i == 0 ? XccyNotionalMode_::Value_::RESETTABLE : XccyNotionalMode_::Value_::MARK_TO_MARKET; - const CrossCurrencySwapConfig_ config = XccyConfig(pair, mode); + const CrossCurrencySwapConfig_ config = XccyConfig(pair, modes[i]); const CrossCurrencySwap_ prototype(today, today, maturities[i], 0.0, config); result.spec_.basis_.instruments_.push_back( Handle_(new CrossCurrencySwap_(today, today, maturities[i], (*prototype.Precompute())(quoteMarket), config))); @@ -144,6 +146,8 @@ namespace { return result; } + Fixture_ MakeFixture() { return MakeFixture({XccyNotionalMode_::Value_::RESETTABLE, XccyNotionalMode_::Value_::MARK_TO_MARKET}); } + Vector_<> Parameters(const JointXccyCalibrationResult_& result) { auto extract = [](const DiscountCurve_& curve) { const auto* typed = dynamic_cast*>(&curve); @@ -210,8 +214,64 @@ namespace { } ASSERT_EQ(offset, expectedSize); } + + JointCalibrationInternal::CurveCollectionSpec_ Collection(const JointXccyCalibrationSpec_& spec, bool domestic) { + const JointCurrencyCurveSpec_& currency = domestic ? spec.domestic_ : spec.foreign_; + JointCalibrationInternal::CurveCollectionSpec_ result; + result.today_ = spec.valuationTime_.Date(); + result.ccy_ = currency.ccy_.String(); + result.liborBasis_ = currency.liborBasis_; + result.curves_ = ¤cy.curves_; + result.context_ = String_(domestic ? "Domestic " : "Foreign ") + result.ccy_ + " joint calibration"; + result.declarationLabel_ = String_(domestic ? "Domestic slot" : "Foreign slot"); + result.requireCurveNames_ = true; + return result; + } } // namespace +TEST(XccyJointJacobianTest, TestMalformedInternalPlanGetsInstrumentSpecificAnalyticEligibilityReason) { + const Fixture_ fixture = MakeFixture(); + Vector_ plans; + for (const auto& instrument : fixture.spec_.basis_.instruments_) { + const auto span = instrument->TimeSpan(); + plans.push_back(BuildXccyCashflowPlan(span.first, span.second, instrument->Config())); + } + ASSERT_FALSE(plans.front().resets_.empty()); + plans.front().resets_.front().domesticPeriodIndex_ = 0; + + const auto domestic = Collection(fixture.spec_, true); + const auto foreign = Collection(fixture.spec_, false); + const auto domesticSlots = JointCalibrationInternal::ValidateAndBuildSlots(domestic); + const auto foreignSlots = JointCalibrationInternal::ValidateAndBuildSlots(foreign); + const String_ reason = JointCalibrationInternal::XccyPlansAnalyticIneligibilityReason(domestic, domesticSlots, foreign, foreignSlots, plans, + fixture.spec_.basis_.instruments_); + + ASSERT_NE(reason.find("XCCY instrument 0"), String_::npos) << reason; + ASSERT_NE(reason.find("CrossCurrencySwap"), String_::npos) << reason; + ASSERT_NE(reason.find("reset event 0"), String_::npos) << reason; + ASSERT_NE(reason.find("second domestic period"), String_::npos) << reason; +} + +TEST(XccyJointJacobianTest, TestEveryValidPlanModeRemainsAvailableInAnalyticAndBumped) { + const Vector_ modes = { + XccyNotionalMode_::Value_::FIXED, + XccyNotionalMode_::Value_::RESETTABLE, + XccyNotionalMode_::Value_::MARK_TO_MARKET, + }; + for (const auto& mode : modes) { + const Fixture_ fixture = MakeFixture({mode, mode}); + const JointXccyCalibrationResult_ analytic = CalibrateJointXccyMarket(fixture.spec_); + ASSERT_TRUE(analytic.converged_) << mode.String(); + ASSERT_FALSE(analytic.jacobianAtSolution_.Empty()) << mode.String(); + + JointXccyCalibrationOptions_ bumpedOptions; + bumpedOptions.jacobianMode_ = CurveJacobianMode_::Value_::BUMPED; + const JointXccyCalibrationResult_ bumped = CalibrateJointXccyMarket(fixture.spec_, bumpedOptions); + ASSERT_TRUE(bumped.converged_) << mode.String(); + ASSERT_FALSE(bumped.effJacobianInverse_.Empty()) << mode.String(); + } +} + TEST(XccyJointJacobianTest, TestAnalyticStackMatchesCentralDifferencesAndRangesPartitionRowsAndColumns) { const Fixture_ fixture = MakeFixture(); const JointXccyCalibrationResult_ calibrated = CalibrateJointXccyMarket(fixture.spec_); From f26e5e8d23332263f902e5db8bb5cb013dca4d6a Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 03:23:18 +0800 Subject: [PATCH 12/38] feat(public): expose resettable and joint XCCY builders --- dal-public/src/curveinstrument.hpp | 114 ++++++++------ dal-public/src/curveprotocol.hpp | 42 ++++-- dal-public/src/xccycalibration.cpp | 22 +++ dal-public/src/xccycalibration.hpp | 21 +++ dal-public/tests/test_curve_instrument.cpp | 120 +++++++++++---- dal-public/tests/test_curve_protocol.cpp | 39 ++++- dal-public/tests/test_xccy_calibration.cpp | 165 ++++++++++++++++++++- 7 files changed, 425 insertions(+), 98 deletions(-) diff --git a/dal-public/src/curveinstrument.hpp b/dal-public/src/curveinstrument.hpp index c8a71cbff..c7ef93845 100644 --- a/dal-public/src/curveinstrument.hpp +++ b/dal-public/src/curveinstrument.hpp @@ -12,74 +12,96 @@ namespace Dal { - FORCE_INLINE Handle_ DepositNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const RateIndexConvention_& convention) { + struct CrossCurrencySwapConfigBuilder_ { + CurrencyPair_ pair_; + double domesticNotional_ = 100.0; + double foreignNotional_ = 100.0; + CrossCurrencyConvention_ convention_; + XccyNotionalMode_ notionalMode_ = XccyNotionalMode_::Value_::FIXED; + FxResetConvention_ fxReset_; + FixingIdentity_ domesticRateFixing_; + FixingIdentity_ foreignRateFixing_; + + [[nodiscard]] CrossCurrencySwapConfig_ Build() const { + CrossCurrencySwapConfig_ result; + result.pair_ = pair_; + result.domesticNotional_ = domesticNotional_; + result.foreignNotional_ = foreignNotional_; + result.convention_ = convention_; + result.notionalMode_ = notionalMode_; + result.fxReset_ = fxReset_; + result.domesticRateFixing_ = domesticRateFixing_; + result.foreignRateFixing_ = foreignRateFixing_; + return result; + } + }; + + FORCE_INLINE Handle_ + DepositNew(const Date_& tradeDate, const Date_& start, const Date_& maturity, double marketRate, const RateIndexConvention_& convention) { return Handle_(new Deposit_(tradeDate, start, maturity, marketRate, convention)); } - FORCE_INLINE Handle_ FRANew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const RateIndexConvention_& convention) { + FORCE_INLINE Handle_ + FRANew(const Date_& tradeDate, const Date_& start, const Date_& maturity, double marketRate, const RateIndexConvention_& convention) { return Handle_(new FRA_(tradeDate, start, maturity, marketRate, convention)); } FORCE_INLINE Handle_ FutureNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const RateIndexConvention_& convention, - double convexityAdjustment = 0.0) { + const Date_& start, + const Date_& maturity, + double marketRate, + const RateIndexConvention_& convention, + double convexityAdjustment = 0.0) { return Handle_(new Future_(tradeDate, start, maturity, marketRate, convention, convexityAdjustment)); } FORCE_INLINE Handle_ SwapNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const RateLegConvention_& fixedLeg, - const RateIndexConvention_& floatIndex, - const RateLegConvention_& floatLeg) { + const Date_& start, + const Date_& maturity, + double marketRate, + const RateLegConvention_& fixedLeg, + const RateIndexConvention_& floatIndex, + const RateLegConvention_& floatLeg) { return Handle_(new Swap_(tradeDate, start, maturity, marketRate, fixedLeg, floatIndex, floatLeg)); } FORCE_INLINE Handle_ OISSwapNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const RateLegConvention_& fixedLeg, - const RateIndexConvention_& overnightIndex, - const RateLegConvention_& floatLeg) { + const Date_& start, + const Date_& maturity, + double marketRate, + const RateLegConvention_& fixedLeg, + const RateIndexConvention_& overnightIndex, + const RateLegConvention_& floatLeg) { return Handle_(new OISSwap_(tradeDate, start, maturity, marketRate, fixedLeg, overnightIndex, floatLeg)); } FORCE_INLINE Handle_ BasisSwapNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const RateIndexConvention_& spreadIndex, - const RateLegConvention_& spreadLeg, - const RateIndexConvention_& refIndex, - const RateLegConvention_& refLeg) { - return Handle_( - new BasisSwap_(tradeDate, start, maturity, marketRate, spreadIndex, spreadLeg, refIndex, refLeg)); + const Date_& start, + const Date_& maturity, + double marketRate, + const RateIndexConvention_& spreadIndex, + const RateLegConvention_& spreadLeg, + const RateIndexConvention_& refIndex, + const RateLegConvention_& refLeg) { + return Handle_(new BasisSwap_(tradeDate, start, maturity, marketRate, spreadIndex, spreadLeg, refIndex, refLeg)); + } + + FORCE_INLINE Handle_ CrossCurrencySwapNew( + const Date_& tradeDate, const Date_& start, const Date_& maturity, double marketRate, const CrossCurrencySwapConfig_& config) { + return Handle_(new CrossCurrencySwap_(tradeDate, start, maturity, marketRate, config)); } FORCE_INLINE Handle_ CrossCurrencySwapNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const CurrencyPair_& currencies, - double domesticNotional = 100.0, - double foreignNotional = 100.0, - const RateLegConvention_& domesticLeg = RateLegConvention_(), - const RateIndexConvention_& domesticIndex = RateIndexConvention_(), - const RateLegConvention_& foreignLeg = RateLegConvention_(), - const RateIndexConvention_& foreignIndex = RateIndexConvention_()) { + const Date_& start, + const Date_& maturity, + double marketRate, + const CurrencyPair_& currencies, + double domesticNotional = 100.0, + double foreignNotional = 100.0, + const RateLegConvention_& domesticLeg = RateLegConvention_(), + const RateIndexConvention_& domesticIndex = RateIndexConvention_(), + const RateLegConvention_& foreignLeg = RateLegConvention_(), + const RateIndexConvention_& foreignIndex = RateIndexConvention_()) { CrossCurrencyConvention_ convention; convention.domesticLeg_ = domesticLeg; convention.domesticIndex_ = domesticIndex; diff --git a/dal-public/src/curveprotocol.hpp b/dal-public/src/curveprotocol.hpp index 84cc8820b..1e57a584c 100644 --- a/dal-public/src/curveprotocol.hpp +++ b/dal-public/src/curveprotocol.hpp @@ -4,19 +4,21 @@ #pragma once +#include + #include #include +#include #include #include #include +#include #include namespace Dal { // --- CollateralType_ --- - FORCE_INLINE CollateralType_ CollateralType_OIS() { - return CollateralType_(CollateralType_::Value_::OIS); - } + FORCE_INLINE CollateralType_ CollateralType_OIS() { return CollateralType_(CollateralType_::Value_::OIS); } FORCE_INLINE CollateralType_ CollateralType_Libor(const PeriodLength_& tenor) { (void)tenor; @@ -25,18 +27,13 @@ namespace Dal { } // --- PeriodLength_ --- - FORCE_INLINE PeriodLength_ PeriodLength_New(const String_& iso) { - return PeriodLength_(iso); - } + FORCE_INLINE PeriodLength_ PeriodLength_New(const String_& iso) { return PeriodLength_(iso); } // --- DayBasis_ --- - FORCE_INLINE DayBasis_ DayBasis_New(const String_& name) { - return DayBasis_(name); - } + FORCE_INLINE DayBasis_ DayBasis_New(const String_& name) { return DayBasis_(name); } // --- RateLegConvention_ --- - FORCE_INLINE RateLegConvention_ RateLegConvention_New(const PeriodLength_& freq, - const DayBasis_& basis) { + FORCE_INLINE RateLegConvention_ RateLegConvention_New(const PeriodLength_& freq, const DayBasis_& basis) { RateLegConvention_ rlc; rlc.paymentFrequency_ = freq; rlc.dayBasis_ = basis; @@ -51,9 +48,9 @@ namespace Dal { // --- RateIndexConvention_ --- FORCE_INLINE RateIndexConvention_ RateIndexConvention_New(const PeriodLength_& forecastTenor, - const DayBasis_& basis, - const CollateralType_& collateral, - bool useProjectionCurve = false) { + const DayBasis_& basis, + const CollateralType_& collateral, + bool useProjectionCurve = false) { RateIndexConvention_ ric; ric.forecastTenor_ = forecastTenor; ric.dayBasis_ = basis; @@ -73,4 +70,21 @@ namespace Dal { return CurrencyPair_(Ccy_(domestic), Ccy_(foreign)); } + // --- FxResetConvention_ --- + FORCE_INLINE FxResetConvention_ FxResetConventionNew( + int fixingLag, const Holidays_& fixingHolidays, const BizDayConvention_& fixingConvention, int fixingHour, int fixingMinute) { + FxResetConvention_ result; + result.fixingLag_ = fixingLag; + result.fixingHolidays_ = fixingHolidays; + result.fixingConvention_ = fixingConvention; + result.fixingHour_ = fixingHour; + result.fixingMinute_ = fixingMinute; + return result; + } + + // --- MarketFixingSnapshot_ --- + FORCE_INLINE Handle_ MarketFixingSnapshotNew(const std::map>& values) { + return Handle_(new MarketFixingSnapshot_(values)); + } + } // namespace Dal diff --git a/dal-public/src/xccycalibration.cpp b/dal-public/src/xccycalibration.cpp index 5f187a956..855d2828f 100644 --- a/dal-public/src/xccycalibration.cpp +++ b/dal-public/src/xccycalibration.cpp @@ -11,6 +11,9 @@ namespace Dal { CrossCurrencyCalibrationSpec_ CrossCurrencyCalibrationSpecBuilder_::Build() const { CrossCurrencyCalibrationSpec_ result; result.today_ = today_; + result.valuationTime_ = valuationTime_; + result.collateralCurrency_ = collateralCurrency_; + result.fixings_ = fixings_; result.basisPair_ = basisPair_; result.domesticCurveBlock_ = domesticCurveBlock_; result.foreignCurveBlock_ = foreignCurveBlock_; @@ -28,6 +31,25 @@ namespace Dal { return result; } + JointXccyCalibrationSpec_ JointXccyCalibrationSpecBuilder_::Build() const { + JointXccyCalibrationSpec_ result; + result.valuationTime_ = valuationTime_; + result.pair_ = pair_; + result.collateralCurrency_ = collateralCurrency_; + result.fxSpot_ = fxSpot_; + result.domestic_ = domestic_; + result.foreign_ = foreign_; + result.basis_ = basis_; + result.fixings_ = fixings_; + result.tolerance_ = solverOptions_.tolerance_; + result.fitTolerance_ = solverOptions_.fitTolerance_; + result.initialGuess_ = solverOptions_.initialGuess_; + result.maxEvaluations_ = solverOptions_.maxEvaluations_; + result.maxRestarts_ = solverOptions_.maxRestarts_; + result.solveMode_ = solverOptions_.solveMode_; + return result; + } + CrossCurrencyCalibrationResult_ CalibrateXccyMarket(const CrossCurrencyCalibrationSpec_& spec) { return CalibrateCrossCurrencyMarket(spec); } } // namespace Dal diff --git a/dal-public/src/xccycalibration.hpp b/dal-public/src/xccycalibration.hpp index aba9db6a8..ef05bb4aa 100644 --- a/dal-public/src/xccycalibration.hpp +++ b/dal-public/src/xccycalibration.hpp @@ -5,12 +5,18 @@ #pragma once #include +#include #include +#include + namespace Dal { struct CrossCurrencyCalibrationSpecBuilder_ { Date_ today_; + DateTime_ valuationTime_; + Ccy_ collateralCurrency_; + Handle_ fixings_; CurrencyPair_ basisPair_; Handle_ domesticCurveBlock_; Handle_ foreignCurveBlock_; @@ -30,6 +36,21 @@ namespace Dal { [[nodiscard]] CrossCurrencyCalibrationSpec_ Build() const; }; + struct JointXccyCalibrationSpecBuilder_ { + DateTime_ valuationTime_; + CurrencyPair_ pair_; + Ccy_ collateralCurrency_; + double fxSpot_ = 0.0; + JointCurrencyCurveSpec_ domestic_; + JointCurrencyCurveSpec_ foreign_; + XccyBasisCurveDeclaration_ basis_; + Handle_ fixings_; + CurveSolverOptions_ solverOptions_; + + JointXccyCalibrationSpecBuilder_() { solverOptions_.initialGuess_ = 0.0; } + [[nodiscard]] JointXccyCalibrationSpec_ Build() const; + }; + CrossCurrencyCalibrationResult_ CalibrateXccyMarket(const CrossCurrencyCalibrationSpec_& spec); } // namespace Dal diff --git a/dal-public/tests/test_curve_instrument.cpp b/dal-public/tests/test_curve_instrument.cpp index 706d4b215..9500e98d1 100644 --- a/dal-public/tests/test_curve_instrument.cpp +++ b/dal-public/tests/test_curve_instrument.cpp @@ -8,6 +8,8 @@ #include using Dal::BasisSwapNew; +using Dal::CollateralType_OIS; +using Dal::CrossCurrencySwapConfigBuilder_; using Dal::CrossCurrencySwapNew; using Dal::CurrencyPair_New; using Dal::Date_; @@ -18,35 +20,27 @@ using Dal::FutureNew; using Dal::Handle_; using Dal::OISSwapNew; using Dal::PeriodLength_New; +using Dal::RateIndexConvention_; using Dal::RateIndexConvention_New; +using Dal::RateLegConvention_; using Dal::RateLegConvention_New; using Dal::SwapNew; -using Dal::CollateralType_OIS; +using Dal::XccyNotionalMode_; using Dal::YCInstrument_; -using Dal::RateLegConvention_; -using Dal::RateIndexConvention_; namespace { -Date_ Today() { return Date_(2025, 6, 20); } -Date_ Spot() { return Today().AddDays(2); } + Date_ Today() { return Date_(2025, 6, 20); } + Date_ Spot() { return Today().AddDays(2); } -Dal::RateLegConvention_ Fixed6M() { - return RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_365F")); -} + Dal::RateLegConvention_ Fixed6M() { return RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_365F")); } -Dal::RateIndexConvention_ Libor3M() { - return RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360"), - CollateralType_OIS()); -} + Dal::RateIndexConvention_ Libor3M() { return RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360"), CollateralType_OIS()); } -Dal::RateIndexConvention_ OvernightIndex() { - return RateIndexConvention_New(PeriodLength_New("12M"), DayBasis_New("ACT_360"), - CollateralType_OIS()); -} + Dal::RateIndexConvention_ OvernightIndex() { + return RateIndexConvention_New(PeriodLength_New("12M"), DayBasis_New("ACT_360"), CollateralType_OIS()); + } -Dal::RateLegConvention_ Float3M() { - return RateLegConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360")); -} + Dal::RateLegConvention_ Float3M() { return RateLegConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360")); } } // namespace // Deposit @@ -123,10 +117,9 @@ TEST(CurveInstrumentTest, TestOISSwapNew) { TEST(CurveInstrumentTest, TestBasisSwapNew) { Date_ start = Spot(); - Date_ maturity = start.AddDays(3650); // 10 years - auto inst = BasisSwapNew(Today(), start, maturity, 0.0025, - Libor3M(), Float3M(), // spread leg: 3M Libor - OvernightIndex(), Float3M()); // ref leg: OIS + Date_ maturity = start.AddDays(3650); // 10 years + auto inst = BasisSwapNew(Today(), start, maturity, 0.0025, Libor3M(), Float3M(), // spread leg: 3M Libor + OvernightIndex(), Float3M()); // ref leg: OIS ASSERT_TRUE(inst != nullptr); } @@ -137,15 +130,10 @@ TEST(CurveInstrumentTest, TestCrossCurrencySwapNew) { Date_ maturity = start.AddDays(3650); auto currencies = CurrencyPair_New("USD", "EUR"); auto domesticLeg = RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_365F")); - auto domesticIndex = RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360"), - CollateralType_OIS()); + auto domesticIndex = RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360"), CollateralType_OIS()); auto foreignLeg = RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_360")); - auto foreignIndex = RateIndexConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_360"), - CollateralType_OIS()); - auto inst = CrossCurrencySwapNew(Today(), start, maturity, 0.001, - currencies, 100.0, 90.0, - domesticLeg, domesticIndex, - foreignLeg, foreignIndex); + auto foreignIndex = RateIndexConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_360"), CollateralType_OIS()); + auto inst = CrossCurrencySwapNew(Today(), start, maturity, 0.001, currencies, 100.0, 90.0, domesticLeg, domesticIndex, foreignLeg, foreignIndex); ASSERT_TRUE(inst != nullptr); } @@ -156,3 +144,73 @@ TEST(CurveInstrumentTest, TestCrossCurrencySwapNewDefaults) { auto inst = CrossCurrencySwapNew(Today(), start, maturity, 0.001, currencies); ASSERT_TRUE(inst != nullptr); } + +TEST(CurveInstrumentTest, TestCrossCurrencySwapConfigBuilderAndOverloadRoundTripEveryField) { + CrossCurrencySwapConfigBuilder_ builder; + builder.pair_ = CurrencyPair_New("USD", "EUR"); + builder.domesticNotional_ = 125.0; + builder.foreignNotional_ = 113.5; + builder.convention_.initialNotionalExchange_ = false; + builder.convention_.finalNotionalExchange_ = true; + builder.convention_.spreadOnForeignLeg_ = false; + builder.convention_.domesticLeg_ = Fixed6M(); + builder.convention_.domesticIndex_ = Libor3M(); + builder.convention_.foreignLeg_ = Float3M(); + builder.convention_.foreignIndex_ = OvernightIndex(); + builder.notionalMode_ = XccyNotionalMode_::Value_::RESETTABLE; + builder.fxReset_ = Dal::FxResetConventionNew(2, Dal::Holidays_(""), Dal::BizDayConvention_("Following"), 10, 30); + builder.domesticRateFixing_.indexName_ = "USD-SOFR-3M"; + builder.domesticRateFixing_.fixingHour_ = 11; + builder.domesticRateFixing_.fixingMinute_ = 5; + builder.foreignRateFixing_.indexName_ = "EUR-ESTR-3M"; + builder.foreignRateFixing_.fixingHour_ = 12; + builder.foreignRateFixing_.fixingMinute_ = 15; + + const auto config = builder.Build(); + const auto instrument = CrossCurrencySwapNew(Today(), Spot(), Spot().AddDays(3650), 0.0015, config); + const auto& actual = instrument->Config(); + + ASSERT_TRUE(actual.pair_ == builder.pair_); + ASSERT_NEAR(actual.domesticNotional_, 125.0, 1.0e-15); + ASSERT_NEAR(actual.foreignNotional_, 113.5, 1.0e-15); + ASSERT_FALSE(actual.convention_.initialNotionalExchange_); + ASSERT_TRUE(actual.convention_.finalNotionalExchange_); + ASSERT_FALSE(actual.convention_.spreadOnForeignLeg_); + ASSERT_EQ(actual.convention_.domesticLeg_.paymentFrequency_.String(), builder.convention_.domesticLeg_.paymentFrequency_.String()); + ASSERT_EQ(actual.convention_.domesticIndex_.forecastTenor_.String(), builder.convention_.domesticIndex_.forecastTenor_.String()); + ASSERT_EQ(actual.convention_.foreignLeg_.paymentFrequency_.String(), builder.convention_.foreignLeg_.paymentFrequency_.String()); + ASSERT_EQ(actual.convention_.foreignIndex_.forecastTenor_.String(), builder.convention_.foreignIndex_.forecastTenor_.String()); + ASSERT_EQ(actual.notionalMode_.Switch(), XccyNotionalMode_::Value_::RESETTABLE); + ASSERT_EQ(actual.fxReset_.fixingLag_, 2); + ASSERT_EQ(actual.fxReset_.fixingHolidays_, Dal::Holidays_("")); + ASSERT_EQ(actual.fxReset_.fixingConvention_, Dal::BizDayConvention_("Following")); + ASSERT_EQ(actual.fxReset_.fixingHour_, 10); + ASSERT_EQ(actual.fxReset_.fixingMinute_, 30); + ASSERT_EQ(actual.domesticRateFixing_.indexName_, Dal::String_("USD-SOFR-3M")); + ASSERT_EQ(actual.domesticRateFixing_.fixingHour_, 11); + ASSERT_EQ(actual.domesticRateFixing_.fixingMinute_, 5); + ASSERT_EQ(actual.foreignRateFixing_.indexName_, Dal::String_("EUR-ESTR-3M")); + ASSERT_EQ(actual.foreignRateFixing_.fixingHour_, 12); + ASSERT_EQ(actual.foreignRateFixing_.fixingMinute_, 15); +} + +TEST(CurveInstrumentTest, TestCrossCurrencySwapNewLegacyOverloadRemainsFixed) { + const auto pair = CurrencyPair_New("USD", "EUR"); + const auto domesticLeg = Fixed6M(); + const auto domesticIndex = Libor3M(); + const auto foreignLeg = Float3M(); + const auto foreignIndex = OvernightIndex(); + + const auto instrument = + CrossCurrencySwapNew(Today(), Spot(), Spot().AddDays(3650), 0.002, pair, 140.0, 127.0, domesticLeg, domesticIndex, foreignLeg, foreignIndex); + const auto& config = instrument->Config(); + + ASSERT_EQ(config.notionalMode_.Switch(), XccyNotionalMode_::Value_::FIXED); + ASSERT_TRUE(config.pair_ == pair); + ASSERT_NEAR(config.domesticNotional_, 140.0, 1.0e-15); + ASSERT_NEAR(config.foreignNotional_, 127.0, 1.0e-15); + ASSERT_EQ(config.convention_.domesticLeg_.paymentFrequency_.String(), domesticLeg.paymentFrequency_.String()); + ASSERT_EQ(config.convention_.domesticIndex_.forecastTenor_.String(), domesticIndex.forecastTenor_.String()); + ASSERT_EQ(config.convention_.foreignLeg_.paymentFrequency_.String(), foreignLeg.paymentFrequency_.String()); + ASSERT_EQ(config.convention_.foreignIndex_.forecastTenor_.String(), foreignIndex.forecastTenor_.String()); +} diff --git a/dal-public/tests/test_curve_protocol.cpp b/dal-public/tests/test_curve_protocol.cpp index 1acefd5f4..38042c95d 100644 --- a/dal-public/tests/test_curve_protocol.cpp +++ b/dal-public/tests/test_curve_protocol.cpp @@ -4,6 +4,8 @@ #include +#include + #include using Dal::CollateralType_; @@ -11,8 +13,13 @@ using Dal::CollateralType_Libor; using Dal::CollateralType_OIS; using Dal::CurrencyPair_; using Dal::CurrencyPair_New; +using Dal::Date_; +using Dal::DateTime_; using Dal::DayBasis_; using Dal::DayBasis_New; +using Dal::FxResetConventionNew; +using Dal::Holidays_; +using Dal::MarketFixingSnapshotNew; using Dal::PeriodLength_; using Dal::PeriodLength_New; using Dal::RateIndexConvention_; @@ -94,12 +101,36 @@ TEST(CurveProtocolTest, TestCurrencyPairNew) { } TEST(CurveProtocolTest, TestCurrencyPairNewVarious) { - for (const auto& [dom, frn] : { - std::pair{"USD", "EUR"}, - std::pair{"GBP", "JPY"}, - std::pair{"EUR", "CHF"}}) { + for (const auto& [dom, frn] : {std::pair{"USD", "EUR"}, std::pair{"GBP", "JPY"}, + std::pair{"EUR", "CHF"}}) { CurrencyPair_ pair = CurrencyPair_New(dom, frn); ASSERT_TRUE(pair.domestic_.String() != nullptr); ASSERT_TRUE(pair.foreign_.String() != nullptr); } } + +TEST(CurveProtocolTest, TestFxResetConventionNewRoundTripsEveryField) { + const auto reset = FxResetConventionNew(2, Holidays_(""), Dal::BizDayConvention_("Following"), 10, 30); + + ASSERT_EQ(reset.fixingLag_, 2); + ASSERT_EQ(reset.fixingHolidays_, Holidays_("")); + ASSERT_EQ(reset.fixingConvention_, Dal::BizDayConvention_("Following")); + ASSERT_EQ(reset.fixingHour_, 10); + ASSERT_EQ(reset.fixingMinute_, 30); +} + +TEST(CurveProtocolTest, TestMarketFixingSnapshotNewCopiesNestedValues) { + const DateTime_ rateTime(Date_(2025, 6, 19), 11, 0); + const DateTime_ fxTime(Date_(2025, 6, 19), 10, 30); + std::map> values = { + {"USD-SOFR-3M", {{rateTime, 0.04325}}}, + {"FX[EUR/USD]", {{fxTime, 1.0825}}}, + }; + + const auto snapshot = MarketFixingSnapshotNew(values); + values["USD-SOFR-3M"][rateTime] = 0.99; + + ASSERT_NE(snapshot, nullptr); + ASSERT_NEAR(snapshot->Require("USD-SOFR-3M", rateTime, "public snapshot test"), 0.04325, 1.0e-15); + ASSERT_NEAR(snapshot->Require("FX[EUR/USD]", fxTime, "public snapshot test"), 1.0825, 1.0e-15); +} diff --git a/dal-public/tests/test_xccy_calibration.cpp b/dal-public/tests/test_xccy_calibration.cpp index d32f262e8..95908e0e6 100644 --- a/dal-public/tests/test_xccy_calibration.cpp +++ b/dal-public/tests/test_xccy_calibration.cpp @@ -17,8 +17,14 @@ using Dal::CurrencyPair_New; using Dal::CurveBlockNew; using Dal::CurveSolveMode_; using Dal::Date_; +using Dal::DateTime_; using Dal::DayBasis_New; using Dal::DiscountPWLFNew; +using Dal::JointCurveDeclaration_; +using Dal::JointXccyCalibrationOptions_; +using Dal::JointXccyCalibrationResult_; +using Dal::JointXccyCalibrationSpecBuilder_; +using Dal::MarketFixingSnapshotNew; using Dal::PeriodLength_New; using Dal::RateIndexConvention_; using Dal::RateIndexConvention_New; @@ -60,6 +66,17 @@ TEST(XccyCalibrationTest, TestBuilderDefaults) { ASSERT_EQ(builder.solveMode_.Switch(), CurveSolveMode_::Value_::EXACT); } +TEST(XccyCalibrationTest, TestJointBuilderDefaultsMatchCoreJointCalibration) { + JointXccyCalibrationSpecBuilder_ builder; + + ASSERT_NEAR(builder.solverOptions_.tolerance_, 1.0e-8, 1.0e-15); + ASSERT_NEAR(builder.solverOptions_.fitTolerance_, 1.0e-6, 1.0e-15); + ASSERT_NEAR(builder.solverOptions_.initialGuess_, 0.0, 1.0e-15); + ASSERT_EQ(builder.solverOptions_.maxEvaluations_, 200); + ASSERT_EQ(builder.solverOptions_.maxRestarts_, 20); + ASSERT_EQ(builder.solverOptions_.solveMode_.Switch(), CurveSolveMode_::Value_::EXACT); +} + // Build baseline curves for XCCY calibration namespace { @@ -143,6 +160,9 @@ TEST(XccyCalibrationTest, TestBuildRoundTripsEveryField) { CrossCurrencyCalibrationSpecBuilder_ b; b.today_ = Today(); + b.valuationTime_ = DateTime_(Today(), 9, 45); + b.collateralCurrency_ = Dal::Ccy_("USD"); + b.fixings_ = MarketFixingSnapshotNew({{"USD-SOFR-3M", {{DateTime_(Today().AddDays(-1), 11, 0), 0.0425}}}}); b.basisPair_ = CurrencyPair_New("USD", "EUR"); b.domesticCurveBlock_ = curves.domesticBlock_; b.foreignCurveBlock_ = curves.foreignBlock_; @@ -161,9 +181,9 @@ TEST(XccyCalibrationTest, TestBuildRoundTripsEveryField) { auto spec = b.Build(); ASSERT_EQ(spec.today_, b.today_); - ASSERT_FALSE(spec.valuationTime_.IsValid()); - ASSERT_EQ(spec.collateralCurrency_.Switch(), Dal::Ccy_::Value_::_NOT_SET); - ASSERT_FALSE(spec.fixings_); + ASSERT_EQ(spec.valuationTime_, b.valuationTime_); + ASSERT_EQ(spec.collateralCurrency_, b.collateralCurrency_); + ASSERT_EQ(spec.fixings_.get(), b.fixings_.get()); ASSERT_TRUE(spec.basisPair_ == b.basisPair_); ASSERT_EQ(spec.domesticCurveBlock_.get(), b.domesticCurveBlock_.get()); ASSERT_EQ(spec.foreignCurveBlock_.get(), b.foreignCurveBlock_.get()); @@ -179,3 +199,142 @@ TEST(XccyCalibrationTest, TestBuildRoundTripsEveryField) { ASSERT_EQ(spec.knotDates_.size(), static_cast(2)); ASSERT_TRUE(spec.instruments_.empty()); } + +TEST(XccyCalibrationTest, TestJointBuilderRoundTripsEveryField) { + const DateTime_ valuationTime(Today(), 9, 45); + const DateTime_ fixingTime(Today().AddDays(-1), 11, 0); + const auto fixings = MarketFixingSnapshotNew({{"USD-SOFR-3M", {{fixingTime, 0.0425}}}}); + + JointCurveDeclaration_ domesticDiscount; + domesticDiscount.curveName_ = "usd_ois"; + domesticDiscount.instruments_.push_back(DepositNew(Today(), Spot(), Spot().AddDays(30), 0.04, Libor3M())); + domesticDiscount.knotDates_ = {Spot().AddDays(30), Spot().AddDays(365)}; + domesticDiscount.targetCollateral_ = CollateralType_OIS(); + domesticDiscount.targetTenor_ = PeriodLength_New("1M"); + domesticDiscount.calibrateDiscountCurve_ = true; + domesticDiscount.baseLayeredOverDiscount_ = false; + domesticDiscount.parameterization_ = Dal::CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + domesticDiscount.logDfScheme_ = Dal::LogDfScheme_::Value_::LOG_CUBIC_NATURAL; + domesticDiscount.smoothingWeight_ = 1.25; + domesticDiscount.initialGuessPerNode_ = {0.01, 0.02}; + + JointCurveDeclaration_ foreignForward; + foreignForward.curveName_ = "eur_6m"; + foreignForward.instruments_.push_back(DepositNew(Today(), Spot(), Spot().AddDays(180), 0.03, Euribor6M())); + foreignForward.knotDates_ = {Spot().AddDays(180), Spot().AddDays(730)}; + foreignForward.targetCollateral_ = CollateralType_OIS(); + foreignForward.targetTenor_ = PeriodLength_New("6M"); + foreignForward.calibrateDiscountCurve_ = false; + foreignForward.baseLayeredOverDiscount_ = true; + foreignForward.parameterization_ = Dal::CurveParameterization_::Value_::ZERO_RATE; + foreignForward.logDfScheme_ = Dal::LogDfScheme_::Value_::LOG_LINEAR; + foreignForward.smoothingWeight_ = 1.75; + foreignForward.initialGuessPerNode_ = {0.03, 0.04}; + + const auto xccy = CrossCurrencySwapNew(Today(), Spot(), Spot().AddDays(3650), 0.001, CurrencyPair_New("USD", "EUR")); + + JointXccyCalibrationSpecBuilder_ builder; + builder.valuationTime_ = valuationTime; + builder.pair_ = CurrencyPair_New("USD", "EUR"); + builder.collateralCurrency_ = Dal::Ccy_("USD"); + builder.fxSpot_ = 1.0825; + builder.domestic_.ccy_ = Dal::Ccy_("USD"); + builder.domestic_.liborBasis_ = DayBasis_New("ACT_360"); + builder.domestic_.curves_ = {domesticDiscount}; + builder.foreign_.ccy_ = Dal::Ccy_("EUR"); + builder.foreign_.liborBasis_ = DayBasis_New("ACT_365F"); + builder.foreign_.curves_ = {foreignForward}; + builder.basis_.curveName_ = "usd_eur_basis"; + builder.basis_.instruments_ = {xccy}; + builder.basis_.knotDates_ = {Spot().AddDays(3650)}; + builder.basis_.parameterization_ = Dal::CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + builder.basis_.smoothingWeight_ = 2.25; + builder.basis_.initialGuessPerNode_ = {0.0025}; + builder.fixings_ = fixings; + builder.solverOptions_.smoothingWeight_ = 9.5; + builder.solverOptions_.tolerance_ = 5.0e-11; + builder.solverOptions_.fitTolerance_ = 6.0e-7; + builder.solverOptions_.initialGuess_ = 0.0075; + builder.solverOptions_.maxEvaluations_ = 233; + builder.solverOptions_.maxRestarts_ = 17; + builder.solverOptions_.solveMode_ = CurveSolveMode_::Value_::APPROXIMATE; + + const auto spec = builder.Build(); + + ASSERT_EQ(spec.valuationTime_, valuationTime); + ASSERT_TRUE(spec.pair_ == builder.pair_); + ASSERT_EQ(spec.collateralCurrency_, builder.collateralCurrency_); + ASSERT_NEAR(spec.fxSpot_, 1.0825, 1.0e-15); + ASSERT_EQ(spec.domestic_.ccy_, builder.domestic_.ccy_); + ASSERT_EQ(spec.domestic_.liborBasis_.String(), builder.domestic_.liborBasis_.String()); + ASSERT_EQ(spec.domestic_.curves_.size(), static_cast(1)); + ASSERT_EQ(spec.domestic_.curves_.front().curveName_, domesticDiscount.curveName_); + ASSERT_EQ(spec.domestic_.curves_.front().instruments_.front().get(), domesticDiscount.instruments_.front().get()); + ASSERT_EQ(spec.domestic_.curves_.front().knotDates_, domesticDiscount.knotDates_); + ASSERT_EQ(spec.domestic_.curves_.front().targetCollateral_, domesticDiscount.targetCollateral_); + ASSERT_EQ(spec.domestic_.curves_.front().targetTenor_, domesticDiscount.targetTenor_); + ASSERT_EQ(spec.domestic_.curves_.front().calibrateDiscountCurve_, domesticDiscount.calibrateDiscountCurve_); + ASSERT_EQ(spec.domestic_.curves_.front().baseLayeredOverDiscount_, domesticDiscount.baseLayeredOverDiscount_); + ASSERT_EQ(spec.domestic_.curves_.front().parameterization_, domesticDiscount.parameterization_); + ASSERT_EQ(spec.domestic_.curves_.front().logDfScheme_, domesticDiscount.logDfScheme_); + ASSERT_NEAR(spec.domestic_.curves_.front().smoothingWeight_, domesticDiscount.smoothingWeight_, 1.0e-15); + ASSERT_EQ(spec.domestic_.curves_.front().initialGuessPerNode_, domesticDiscount.initialGuessPerNode_); + ASSERT_EQ(spec.foreign_.ccy_, builder.foreign_.ccy_); + ASSERT_EQ(spec.foreign_.liborBasis_.String(), builder.foreign_.liborBasis_.String()); + ASSERT_EQ(spec.foreign_.curves_.size(), static_cast(1)); + ASSERT_EQ(spec.foreign_.curves_.front().curveName_, foreignForward.curveName_); + ASSERT_EQ(spec.foreign_.curves_.front().instruments_.front().get(), foreignForward.instruments_.front().get()); + ASSERT_EQ(spec.foreign_.curves_.front().knotDates_, foreignForward.knotDates_); + ASSERT_EQ(spec.foreign_.curves_.front().targetCollateral_, foreignForward.targetCollateral_); + ASSERT_EQ(spec.foreign_.curves_.front().targetTenor_, foreignForward.targetTenor_); + ASSERT_EQ(spec.foreign_.curves_.front().calibrateDiscountCurve_, foreignForward.calibrateDiscountCurve_); + ASSERT_EQ(spec.foreign_.curves_.front().baseLayeredOverDiscount_, foreignForward.baseLayeredOverDiscount_); + ASSERT_EQ(spec.foreign_.curves_.front().parameterization_, foreignForward.parameterization_); + ASSERT_EQ(spec.foreign_.curves_.front().logDfScheme_, foreignForward.logDfScheme_); + ASSERT_NEAR(spec.foreign_.curves_.front().smoothingWeight_, foreignForward.smoothingWeight_, 1.0e-15); + ASSERT_EQ(spec.foreign_.curves_.front().initialGuessPerNode_, foreignForward.initialGuessPerNode_); + ASSERT_EQ(spec.basis_.curveName_, builder.basis_.curveName_); + ASSERT_EQ(spec.basis_.instruments_.front().get(), xccy.get()); + ASSERT_EQ(spec.basis_.knotDates_, builder.basis_.knotDates_); + ASSERT_EQ(spec.basis_.parameterization_, builder.basis_.parameterization_); + ASSERT_NEAR(spec.basis_.smoothingWeight_, 2.25, 1.0e-15); + ASSERT_EQ(spec.basis_.initialGuessPerNode_, builder.basis_.initialGuessPerNode_); + ASSERT_EQ(spec.fixings_.get(), fixings.get()); + ASSERT_NEAR(spec.tolerance_, 5.0e-11, 1.0e-15); + ASSERT_NEAR(spec.fitTolerance_, 6.0e-7, 1.0e-15); + ASSERT_NEAR(spec.initialGuess_, 0.0075, 1.0e-15); + ASSERT_EQ(spec.maxEvaluations_, 233); + ASSERT_EQ(spec.maxRestarts_, 17); + ASSERT_EQ(spec.solveMode_.Switch(), CurveSolveMode_::Value_::APPROXIMATE); +} + +TEST(XccyCalibrationTest, TestJointOptionsAndResultLayoutArePublic) { + JointXccyCalibrationOptions_ options; + options.jacobianMode_ = Dal::CurveJacobianMode_::Value_::BUMPED; + options.computeEffJacobianInverse_ = false; + options.computeForwardJacobian_ = false; + using CalibrateWithOptions_ = JointXccyCalibrationResult_ (*)(const Dal::JointXccyCalibrationSpec_&, const JointXccyCalibrationOptions_&); + const CalibrateWithOptions_ calibrate = static_cast(&Dal::CalibrateJointXccyMarket); + + ASSERT_NE(calibrate, nullptr); + ASSERT_EQ(options.jacobianMode_.Switch(), Dal::CurveJacobianMode_::Value_::BUMPED); + ASSERT_FALSE(options.computeEffJacobianInverse_); + ASSERT_FALSE(options.computeForwardJacobian_); + + JointXccyCalibrationResult_ result; + result.parameterRanges_ = {{"domestic:usd_ois", 0, 2}, {"basis:usd_eur", 2, 1}}; + result.residualRanges_ = {{"domestic:usd_ois", 0, 2}, {"xccy:usd_eur", 2, 1}}; + result.marketRates_ = {0.04, 0.041, 0.001}; + result.modelRates_ = {0.04, 0.041, 0.001}; + result.residuals_ = {0.0, 0.0, 0.0}; + + ASSERT_EQ(result.parameterRanges_.size(), static_cast(2)); + ASSERT_EQ(result.parameterRanges_[1].name_, Dal::String_("basis:usd_eur")); + ASSERT_EQ(result.parameterRanges_[1].offset_, 2); + ASSERT_EQ(result.parameterRanges_[1].size_, 1); + ASSERT_EQ(result.residualRanges_.size(), static_cast(2)); + ASSERT_EQ(result.residualRanges_[1].name_, Dal::String_("xccy:usd_eur")); + ASSERT_EQ(result.marketRates_.size(), static_cast(3)); + ASSERT_EQ(result.modelRates_.size(), static_cast(3)); + ASSERT_EQ(result.residuals_.size(), static_cast(3)); +} From 8fce2998b5d4df202ef7bb92d46928c023f5d48e Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 03:30:45 +0800 Subject: [PATCH 13/38] fix(public): add joint XCCY result accessors --- dal-public/src/curveinstrument.hpp | 85 ++++++++++++---------- dal-public/src/xccycalibration.cpp | 22 ++++++ dal-public/src/xccycalibration.hpp | 11 +++ dal-public/tests/test_curve_instrument.cpp | 48 +++++++----- dal-public/tests/test_xccy_calibration.cpp | 15 ++++ 5 files changed, 125 insertions(+), 56 deletions(-) diff --git a/dal-public/src/curveinstrument.hpp b/dal-public/src/curveinstrument.hpp index c7ef93845..cbe040508 100644 --- a/dal-public/src/curveinstrument.hpp +++ b/dal-public/src/curveinstrument.hpp @@ -36,54 +36,61 @@ namespace Dal { } }; - FORCE_INLINE Handle_ - DepositNew(const Date_& tradeDate, const Date_& start, const Date_& maturity, double marketRate, const RateIndexConvention_& convention) { + FORCE_INLINE Handle_ DepositNew(const Date_& tradeDate, + const Date_& start, + const Date_& maturity, + double marketRate, + const RateIndexConvention_& convention) { return Handle_(new Deposit_(tradeDate, start, maturity, marketRate, convention)); } - FORCE_INLINE Handle_ - FRANew(const Date_& tradeDate, const Date_& start, const Date_& maturity, double marketRate, const RateIndexConvention_& convention) { + FORCE_INLINE Handle_ FRANew(const Date_& tradeDate, + const Date_& start, + const Date_& maturity, + double marketRate, + const RateIndexConvention_& convention) { return Handle_(new FRA_(tradeDate, start, maturity, marketRate, convention)); } FORCE_INLINE Handle_ FutureNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const RateIndexConvention_& convention, - double convexityAdjustment = 0.0) { + const Date_& start, + const Date_& maturity, + double marketRate, + const RateIndexConvention_& convention, + double convexityAdjustment = 0.0) { return Handle_(new Future_(tradeDate, start, maturity, marketRate, convention, convexityAdjustment)); } FORCE_INLINE Handle_ SwapNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const RateLegConvention_& fixedLeg, - const RateIndexConvention_& floatIndex, - const RateLegConvention_& floatLeg) { + const Date_& start, + const Date_& maturity, + double marketRate, + const RateLegConvention_& fixedLeg, + const RateIndexConvention_& floatIndex, + const RateLegConvention_& floatLeg) { return Handle_(new Swap_(tradeDate, start, maturity, marketRate, fixedLeg, floatIndex, floatLeg)); } FORCE_INLINE Handle_ OISSwapNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const RateLegConvention_& fixedLeg, - const RateIndexConvention_& overnightIndex, - const RateLegConvention_& floatLeg) { + const Date_& start, + const Date_& maturity, + double marketRate, + const RateLegConvention_& fixedLeg, + const RateIndexConvention_& overnightIndex, + const RateLegConvention_& floatLeg) { return Handle_(new OISSwap_(tradeDate, start, maturity, marketRate, fixedLeg, overnightIndex, floatLeg)); } FORCE_INLINE Handle_ BasisSwapNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const RateIndexConvention_& spreadIndex, - const RateLegConvention_& spreadLeg, - const RateIndexConvention_& refIndex, - const RateLegConvention_& refLeg) { - return Handle_(new BasisSwap_(tradeDate, start, maturity, marketRate, spreadIndex, spreadLeg, refIndex, refLeg)); + const Date_& start, + const Date_& maturity, + double marketRate, + const RateIndexConvention_& spreadIndex, + const RateLegConvention_& spreadLeg, + const RateIndexConvention_& refIndex, + const RateLegConvention_& refLeg) { + return Handle_( + new BasisSwap_(tradeDate, start, maturity, marketRate, spreadIndex, spreadLeg, refIndex, refLeg)); } FORCE_INLINE Handle_ CrossCurrencySwapNew( @@ -92,16 +99,16 @@ namespace Dal { } FORCE_INLINE Handle_ CrossCurrencySwapNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const CurrencyPair_& currencies, - double domesticNotional = 100.0, - double foreignNotional = 100.0, - const RateLegConvention_& domesticLeg = RateLegConvention_(), - const RateIndexConvention_& domesticIndex = RateIndexConvention_(), - const RateLegConvention_& foreignLeg = RateLegConvention_(), - const RateIndexConvention_& foreignIndex = RateIndexConvention_()) { + const Date_& start, + const Date_& maturity, + double marketRate, + const CurrencyPair_& currencies, + double domesticNotional = 100.0, + double foreignNotional = 100.0, + const RateLegConvention_& domesticLeg = RateLegConvention_(), + const RateIndexConvention_& domesticIndex = RateIndexConvention_(), + const RateLegConvention_& foreignLeg = RateLegConvention_(), + const RateIndexConvention_& foreignIndex = RateIndexConvention_()) { CrossCurrencyConvention_ convention; convention.domesticLeg_ = domesticLeg; convention.domesticIndex_ = domesticIndex; diff --git a/dal-public/src/xccycalibration.cpp b/dal-public/src/xccycalibration.cpp index 855d2828f..7c80a85ff 100644 --- a/dal-public/src/xccycalibration.cpp +++ b/dal-public/src/xccycalibration.cpp @@ -50,6 +50,28 @@ namespace Dal { return result; } + const Handle_& JointXccyResultDomesticBlock(const JointXccyCalibrationResult_& result) { return result.domesticCurveBlock_; } + + const Handle_& JointXccyResultForeignBlock(const JointXccyCalibrationResult_& result) { return result.foreignCurveBlock_; } + + const Handle_& JointXccyResultBasisCurve(const JointXccyCalibrationResult_& result) { return result.basisCurve_; } + + const CrossCurrencyFxForwardCurve_& JointXccyResultFxForwards(const JointXccyCalibrationResult_& result) { return result.fxForwardCurve_; } + + const Vector_<>& JointXccyResultMarketRates(const JointXccyCalibrationResult_& result) { return result.marketRates_; } + + const Vector_<>& JointXccyResultModelRates(const JointXccyCalibrationResult_& result) { return result.modelRates_; } + + const Vector_<>& JointXccyResultResiduals(const JointXccyCalibrationResult_& result) { return result.residuals_; } + + const Matrix_<>& JointXccyResultJacobian(const JointXccyCalibrationResult_& result) { return result.jacobianAtSolution_; } + + const Vector_& JointXccyResultParameterRanges(const JointXccyCalibrationResult_& result) { + return result.parameterRanges_; + } + + const Vector_& JointXccyResultResidualRanges(const JointXccyCalibrationResult_& result) { return result.residualRanges_; } + CrossCurrencyCalibrationResult_ CalibrateXccyMarket(const CrossCurrencyCalibrationSpec_& spec) { return CalibrateCrossCurrencyMarket(spec); } } // namespace Dal diff --git a/dal-public/src/xccycalibration.hpp b/dal-public/src/xccycalibration.hpp index ef05bb4aa..bdf0e7b86 100644 --- a/dal-public/src/xccycalibration.hpp +++ b/dal-public/src/xccycalibration.hpp @@ -51,6 +51,17 @@ namespace Dal { [[nodiscard]] JointXccyCalibrationSpec_ Build() const; }; + [[nodiscard]] const Handle_& JointXccyResultDomesticBlock(const JointXccyCalibrationResult_& result); + [[nodiscard]] const Handle_& JointXccyResultForeignBlock(const JointXccyCalibrationResult_& result); + [[nodiscard]] const Handle_& JointXccyResultBasisCurve(const JointXccyCalibrationResult_& result); + [[nodiscard]] const CrossCurrencyFxForwardCurve_& JointXccyResultFxForwards(const JointXccyCalibrationResult_& result); + [[nodiscard]] const Vector_<>& JointXccyResultMarketRates(const JointXccyCalibrationResult_& result); + [[nodiscard]] const Vector_<>& JointXccyResultModelRates(const JointXccyCalibrationResult_& result); + [[nodiscard]] const Vector_<>& JointXccyResultResiduals(const JointXccyCalibrationResult_& result); + [[nodiscard]] const Matrix_<>& JointXccyResultJacobian(const JointXccyCalibrationResult_& result); + [[nodiscard]] const Vector_& JointXccyResultParameterRanges(const JointXccyCalibrationResult_& result); + [[nodiscard]] const Vector_& JointXccyResultResidualRanges(const JointXccyCalibrationResult_& result); + CrossCurrencyCalibrationResult_ CalibrateXccyMarket(const CrossCurrencyCalibrationSpec_& spec); } // namespace Dal diff --git a/dal-public/tests/test_curve_instrument.cpp b/dal-public/tests/test_curve_instrument.cpp index 9500e98d1..518582908 100644 --- a/dal-public/tests/test_curve_instrument.cpp +++ b/dal-public/tests/test_curve_instrument.cpp @@ -8,7 +8,6 @@ #include using Dal::BasisSwapNew; -using Dal::CollateralType_OIS; using Dal::CrossCurrencySwapConfigBuilder_; using Dal::CrossCurrencySwapNew; using Dal::CurrencyPair_New; @@ -20,27 +19,36 @@ using Dal::FutureNew; using Dal::Handle_; using Dal::OISSwapNew; using Dal::PeriodLength_New; -using Dal::RateIndexConvention_; using Dal::RateIndexConvention_New; -using Dal::RateLegConvention_; using Dal::RateLegConvention_New; using Dal::SwapNew; +using Dal::CollateralType_OIS; using Dal::XccyNotionalMode_; using Dal::YCInstrument_; +using Dal::RateLegConvention_; +using Dal::RateIndexConvention_; namespace { - Date_ Today() { return Date_(2025, 6, 20); } - Date_ Spot() { return Today().AddDays(2); } +Date_ Today() { return Date_(2025, 6, 20); } +Date_ Spot() { return Today().AddDays(2); } - Dal::RateLegConvention_ Fixed6M() { return RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_365F")); } +Dal::RateLegConvention_ Fixed6M() { + return RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_365F")); +} - Dal::RateIndexConvention_ Libor3M() { return RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360"), CollateralType_OIS()); } +Dal::RateIndexConvention_ Libor3M() { + return RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360"), + CollateralType_OIS()); +} - Dal::RateIndexConvention_ OvernightIndex() { - return RateIndexConvention_New(PeriodLength_New("12M"), DayBasis_New("ACT_360"), CollateralType_OIS()); - } +Dal::RateIndexConvention_ OvernightIndex() { + return RateIndexConvention_New(PeriodLength_New("12M"), DayBasis_New("ACT_360"), + CollateralType_OIS()); +} - Dal::RateLegConvention_ Float3M() { return RateLegConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360")); } +Dal::RateLegConvention_ Float3M() { + return RateLegConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360")); +} } // namespace // Deposit @@ -117,9 +125,10 @@ TEST(CurveInstrumentTest, TestOISSwapNew) { TEST(CurveInstrumentTest, TestBasisSwapNew) { Date_ start = Spot(); - Date_ maturity = start.AddDays(3650); // 10 years - auto inst = BasisSwapNew(Today(), start, maturity, 0.0025, Libor3M(), Float3M(), // spread leg: 3M Libor - OvernightIndex(), Float3M()); // ref leg: OIS + Date_ maturity = start.AddDays(3650); // 10 years + auto inst = BasisSwapNew(Today(), start, maturity, 0.0025, + Libor3M(), Float3M(), // spread leg: 3M Libor + OvernightIndex(), Float3M()); // ref leg: OIS ASSERT_TRUE(inst != nullptr); } @@ -130,10 +139,15 @@ TEST(CurveInstrumentTest, TestCrossCurrencySwapNew) { Date_ maturity = start.AddDays(3650); auto currencies = CurrencyPair_New("USD", "EUR"); auto domesticLeg = RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_365F")); - auto domesticIndex = RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360"), CollateralType_OIS()); + auto domesticIndex = RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_360"), + CollateralType_OIS()); auto foreignLeg = RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_360")); - auto foreignIndex = RateIndexConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_360"), CollateralType_OIS()); - auto inst = CrossCurrencySwapNew(Today(), start, maturity, 0.001, currencies, 100.0, 90.0, domesticLeg, domesticIndex, foreignLeg, foreignIndex); + auto foreignIndex = RateIndexConvention_New(PeriodLength_New("6M"), DayBasis_New("ACT_360"), + CollateralType_OIS()); + auto inst = CrossCurrencySwapNew(Today(), start, maturity, 0.001, + currencies, 100.0, 90.0, + domesticLeg, domesticIndex, + foreignLeg, foreignIndex); ASSERT_TRUE(inst != nullptr); } diff --git a/dal-public/tests/test_xccy_calibration.cpp b/dal-public/tests/test_xccy_calibration.cpp index 95908e0e6..e8683f120 100644 --- a/dal-public/tests/test_xccy_calibration.cpp +++ b/dal-public/tests/test_xccy_calibration.cpp @@ -338,3 +338,18 @@ TEST(XccyCalibrationTest, TestJointOptionsAndResultLayoutArePublic) { ASSERT_EQ(result.modelRates_.size(), static_cast(3)); ASSERT_EQ(result.residuals_.size(), static_cast(3)); } + +TEST(XccyCalibrationTest, TestJointResultAccessorsReferenceEveryPlannedView) { + JointXccyCalibrationResult_ result; + + ASSERT_EQ(&Dal::JointXccyResultDomesticBlock(result), &result.domesticCurveBlock_); + ASSERT_EQ(&Dal::JointXccyResultForeignBlock(result), &result.foreignCurveBlock_); + ASSERT_EQ(&Dal::JointXccyResultBasisCurve(result), &result.basisCurve_); + ASSERT_EQ(&Dal::JointXccyResultFxForwards(result), &result.fxForwardCurve_); + ASSERT_EQ(&Dal::JointXccyResultMarketRates(result), &result.marketRates_); + ASSERT_EQ(&Dal::JointXccyResultModelRates(result), &result.modelRates_); + ASSERT_EQ(&Dal::JointXccyResultResiduals(result), &result.residuals_); + ASSERT_EQ(&Dal::JointXccyResultJacobian(result), &result.jacobianAtSolution_); + ASSERT_EQ(&Dal::JointXccyResultParameterRanges(result), &result.parameterRanges_); + ASSERT_EQ(&Dal::JointXccyResultResidualRanges(result), &result.residualRanges_); +} From 8dd73f2e950e7af8c7ce2ec5239543659c3dc548 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 03:50:28 +0800 Subject: [PATCH 14/38] feat(python): bind resettable and joint XCCY APIs --- dal-python/src/bindings/curve.cpp | 627 ++++++++++++++++++---- dal-python/tests/test_xccy_calibration.py | 45 ++ dal-python/tests/test_xccy_joint.py | 125 +++++ dal-python/tests/test_xccy_resettable.py | 127 +++++ 4 files changed, 829 insertions(+), 95 deletions(-) create mode 100644 dal-python/tests/test_xccy_joint.py create mode 100644 dal-python/tests/test_xccy_resettable.py diff --git a/dal-python/src/bindings/curve.cpp b/dal-python/src/bindings/curve.cpp index 6a105191d..106f0b410 100644 --- a/dal-python/src/bindings/curve.cpp +++ b/dal-python/src/bindings/curve.cpp @@ -6,10 +6,12 @@ #include -#include #include #include +#include +#include #include +#include #include #include @@ -22,6 +24,98 @@ using namespace Dal; namespace { + template + void DefReadWriteAliases(py::class_& cls, const char* legacyName, const char* snakeName, Member_ Class_::* member) { + cls.def_readwrite(legacyName, member).def_readwrite(snakeName, member); + } + + template + void DefPropertyAliases(py::class_& cls, const char* legacyName, const char* snakeName, Getter_ getter, Setter_ setter) { + cls.def_property(legacyName, getter, setter).def_property(snakeName, getter, setter); + } + + template void DefStringAliases(py::class_& cls, const char* legacyName, const char* snakeName, String_ Class_::* member) { + DefPropertyAliases( + cls, legacyName, snakeName, [member](const Class_& value) { return std::string((value.*member).c_str()); }, + [member](Class_& value, const char* text) { value.*member = String_(text); }); + } + + template py::list InstrumentHandlesToList(const Vector_>& instruments) { + py::list result; + for (const auto& instrument : instruments) + result.append(std::const_pointer_cast(Handle_(instrument))); + return result; + } + + template void SetInstrumentHandles(Vector_>* destination, const py::iterable& instruments) { + destination->clear(); + for (const auto item : instruments) { + const auto instrument = py::cast>(item); + destination->push_back(Handle_(std::const_pointer_cast(instrument))); + } + } + + py::list DatesToList(const Vector_& dates) { + py::list result; + for (const auto& date : dates) + result.append(date); + return result; + } + + void SetDates(Vector_* destination, const py::iterable& dates) { + destination->clear(); + for (const auto item : dates) + destination->push_back(py::cast(item)); + } + + py::list DoublesToList(const Vector_<>& values) { + py::list result; + for (const auto value : values) + result.append(value); + return result; + } + + template py::list ValuesToList(const Vector_& values) { + py::list result; + for (const auto& value : values) + result.append(value); + return result; + } + + void SetDoubles(Vector_<>* destination, const py::iterable& values) { + destination->clear(); + for (const auto item : values) + destination->push_back(py::cast(item)); + } + + std::shared_ptr MutableSnapshot(const Handle_& snapshot) { + return std::const_pointer_cast(Handle_(snapshot)); + } + + Handle_ ConstSnapshot(const std::shared_ptr& snapshot) { + return Handle_(std::const_pointer_cast(snapshot)); + } + + MarketFixingSnapshot_::values_t SnapshotValues(const py::dict& values) { + MarketFixingSnapshot_::values_t result; + for (const auto outerItem : py::reinterpret_borrow(values.attr("items")())) { + const auto pair = py::reinterpret_borrow(outerItem); + const String_ indexName(py::cast(pair[0])); + const auto history = py::cast(pair[1]); + for (const auto innerItem : py::reinterpret_borrow(history.attr("items")())) { + const auto fixing = py::reinterpret_borrow(innerItem); + result[indexName][py::cast(fixing[0])] = py::cast(fixing[1]); + } + } + return result; + } + + void AddMatrixSnakeCaseAliases(py::module_& m) { + const py::object matrixClass = m.attr("DoubleMatrix_"); + matrixClass.attr("rows") = py::cpp_function([](const Matrix_<>& matrix) { return matrix.Rows(); }, py::is_method(matrixClass)); + matrixClass.attr("cols") = py::cpp_function([](const Matrix_<>& matrix) { return matrix.Cols(); }, py::is_method(matrixClass)); + } + void init_bindings_curve_handles(py::module_& m) { py::class_>(m, "YCInstrument_"); py::class_>(m, "DiscountCurve_") @@ -34,9 +128,23 @@ namespace { // Value types used as function parameters / return values py::class_(m, "CurveCalibrationSpec_"); py::class_(m, "CrossCurrencyCalibrationSpec_"); + py::class_(m, "JointXccyCalibrationSpec_"); } void init_bindings_curve_protocol(py::module_& m) { + py::class_(m, "DateTime_") + .def(py::init<>()) + .def(py::init(), py::arg("date"), py::arg("hour"), py::arg("minute") = 0, py::arg("second") = 0) + .def_property_readonly("date", &DateTime_::Date) + .def_property_readonly("fraction", &DateTime_::Frac) + .def("IsValid", &DateTime_::IsValid) + .def("is_valid", &DateTime_::IsValid) + .def("__repr__", [](const DateTime_& value) { return std::string(DateTime::ToString(value).c_str()); }); + + py::class_(m, "Ccy_") + .def(py::init([](const char* src) { return Ccy_(String_(src)); }), py::arg("src")) + .def("__repr__", [](const Ccy_& ccy) { return std::string(ccy.String()); }); + py::class_(m, "CollateralType_") .def(py::init(), py::arg("src")) .def("__repr__", [](const CollateralType_& ct) { return std::string(ct.String()); }); @@ -58,29 +166,134 @@ namespace { m.def("DayBasis_New", [](const char* name) { return DayBasis_New(String_(name)); }, py::arg("name")); - py::class_(m, "RateLegConvention_") - .def(py::init<>()); + auto rateLeg = py::class_(m, "RateLegConvention_"); + rateLeg.def(py::init<>()); + DefReadWriteAliases(rateLeg, "paymentLag_", "payment_lag", &RateLegConvention_::paymentLag_); + DefReadWriteAliases(rateLeg, "paymentFrequency_", "payment_frequency", &RateLegConvention_::paymentFrequency_); + DefReadWriteAliases(rateLeg, "dayBasis_", "day_basis", &RateLegConvention_::dayBasis_); + DefPropertyAliases( + rateLeg, "businessDayConvention_", "business_day_convention", + [](const RateLegConvention_& value) { return value.businessDayConvention_.Switch(); }, + [](RateLegConvention_& value, BizDayConvention_::Value_ convention) { value.businessDayConvention_ = BizDayConvention_(convention); }); + DefPropertyAliases( + rateLeg, "paymentConvention_", "payment_convention", [](const RateLegConvention_& value) { return value.paymentConvention_.Switch(); }, + [](RateLegConvention_& value, BizDayConvention_::Value_ convention) { value.paymentConvention_ = BizDayConvention_(convention); }); + DefReadWriteAliases(rateLeg, "accrualHolidays_", "accrual_holidays", &RateLegConvention_::accrualHolidays_); + DefReadWriteAliases(rateLeg, "paymentHolidays_", "payment_holidays", &RateLegConvention_::paymentHolidays_); + DefReadWriteAliases(rateLeg, "endOfMonth_", "end_of_month", &RateLegConvention_::endOfMonth_); m.def("RateLegConvention_New", &RateLegConvention_New, py::arg("freq"), py::arg("basis")); - py::class_(m, "RateIndexConvention_") - .def(py::init<>()); + auto rateIndex = py::class_(m, "RateIndexConvention_"); + rateIndex.def(py::init<>()); + DefReadWriteAliases(rateIndex, "spotLag_", "spot_lag", &RateIndexConvention_::spotLag_); + DefReadWriteAliases(rateIndex, "fixingLag_", "fixing_lag", &RateIndexConvention_::fixingLag_); + DefReadWriteAliases(rateIndex, "useProjectionCurve_", "use_projection_curve", &RateIndexConvention_::useProjectionCurve_); + DefReadWriteAliases(rateIndex, "forecastTenor_", "forecast_tenor", &RateIndexConvention_::forecastTenor_); + DefReadWriteAliases(rateIndex, "dayBasis_", "day_basis", &RateIndexConvention_::dayBasis_); + DefPropertyAliases( + rateIndex, "businessDayConvention_", "business_day_convention", + [](const RateIndexConvention_& value) { return value.businessDayConvention_.Switch(); }, + [](RateIndexConvention_& value, BizDayConvention_::Value_ convention) { value.businessDayConvention_ = BizDayConvention_(convention); }); + DefReadWriteAliases(rateIndex, "fixingHolidays_", "fixing_holidays", &RateIndexConvention_::fixingHolidays_); + DefReadWriteAliases(rateIndex, "accrualHolidays_", "accrual_holidays", &RateIndexConvention_::accrualHolidays_); + DefReadWriteAliases(rateIndex, "endOfMonth_", "end_of_month", &RateIndexConvention_::endOfMonth_); + DefReadWriteAliases(rateIndex, "collateral_", "collateral", &RateIndexConvention_::collateral_); m.def("RateIndexConvention_New", &RateIndexConvention_New, py::arg("forecast_tenor"), py::arg("basis"), py::arg("collateral"), py::arg("use_projection_curve") = false); - py::class_(m, "CurrencyPair_"); + auto currencyPair = py::class_(m, "CurrencyPair_"); + DefReadWriteAliases(currencyPair, "domestic_", "domestic", &CurrencyPair_::domestic_); + DefReadWriteAliases(currencyPair, "foreign_", "foreign", &CurrencyPair_::foreign_); m.def("CurrencyPair_New", [](const char* domestic, const char* foreign) { return CurrencyPair_New(String_(domestic), String_(foreign)); }, py::arg("domestic"), py::arg("foreign")); + + auto fixingIdentity = py::class_(m, "FixingIdentity_"); + fixingIdentity.def(py::init<>()); + DefStringAliases(fixingIdentity, "indexName_", "index_name", &FixingIdentity_::indexName_); + DefReadWriteAliases(fixingIdentity, "fixingHour_", "fixing_hour", &FixingIdentity_::fixingHour_); + DefReadWriteAliases(fixingIdentity, "fixingMinute_", "fixing_minute", &FixingIdentity_::fixingMinute_); + + auto fxReset = py::class_(m, "FxResetConvention_"); + fxReset.def(py::init<>()); + DefReadWriteAliases(fxReset, "fixingLag_", "fixing_lag", &FxResetConvention_::fixingLag_); + DefReadWriteAliases(fxReset, "fixingHolidays_", "fixing_holidays", &FxResetConvention_::fixingHolidays_); + DefPropertyAliases( + fxReset, "fixingConvention_", "fixing_convention", [](const FxResetConvention_& value) { return value.fixingConvention_.Switch(); }, + [](FxResetConvention_& value, BizDayConvention_::Value_ convention) { value.fixingConvention_ = BizDayConvention_(convention); }); + DefReadWriteAliases(fxReset, "fixingHour_", "fixing_hour", &FxResetConvention_::fixingHour_); + DefReadWriteAliases(fxReset, "fixingMinute_", "fixing_minute", &FxResetConvention_::fixingMinute_); + + m.def( + "FxResetConvention_New", + [](int fixingLag, const Holidays_& fixingHolidays, BizDayConvention_::Value_ fixingConvention, int fixingHour, int fixingMinute) { + return FxResetConventionNew(fixingLag, fixingHolidays, BizDayConvention_(fixingConvention), fixingHour, fixingMinute); + }, + py::arg("fixing_lag"), py::arg("fixing_holidays"), py::arg("fixing_convention"), py::arg("fixing_hour"), py::arg("fixing_minute")); + + auto snapshot = py::class_>(m, "MarketFixingSnapshot_"); + snapshot + .def("Find", [](const MarketFixingSnapshot_& value, const char* indexName, + const DateTime_& fixingTime) { return value.Find(String_(indexName), fixingTime); }) + .def("find", [](const MarketFixingSnapshot_& value, const char* indexName, + const DateTime_& fixingTime) { return value.Find(String_(indexName), fixingTime); }) + .def("Require", [](const MarketFixingSnapshot_& value, const char* indexName, const DateTime_& fixingTime, + const char* context) { return value.Require(String_(indexName), fixingTime, String_(context)); }) + .def("require", [](const MarketFixingSnapshot_& value, const char* indexName, const DateTime_& fixingTime, const char* context) { + return value.Require(String_(indexName), fixingTime, String_(context)); + }); + + m.def( + "MarketFixingSnapshot_New", [](const py::dict& values) { return MutableSnapshot(MarketFixingSnapshotNew(SnapshotValues(values))); }, + py::arg("values")); } void init_bindings_curve_instruments(py::module_& m) { + auto convention = py::class_(m, "CrossCurrencyConvention_"); + convention.def(py::init<>()); + DefReadWriteAliases(convention, "initialNotionalExchange_", "initial_notional_exchange", &CrossCurrencyConvention_::initialNotionalExchange_); + DefReadWriteAliases(convention, "finalNotionalExchange_", "final_notional_exchange", &CrossCurrencyConvention_::finalNotionalExchange_); + DefReadWriteAliases(convention, "spreadOnForeignLeg_", "spread_on_foreign_leg", &CrossCurrencyConvention_::spreadOnForeignLeg_); + DefReadWriteAliases(convention, "domesticIndex_", "domestic_index", &CrossCurrencyConvention_::domesticIndex_); + DefReadWriteAliases(convention, "domesticLeg_", "domestic_leg", &CrossCurrencyConvention_::domesticLeg_); + DefReadWriteAliases(convention, "foreignIndex_", "foreign_index", &CrossCurrencyConvention_::foreignIndex_); + DefReadWriteAliases(convention, "foreignLeg_", "foreign_leg", &CrossCurrencyConvention_::foreignLeg_); + + auto config = py::class_(m, "CrossCurrencySwapConfig_"); + config.def(py::init<>()); + DefReadWriteAliases(config, "pair_", "pair", &CrossCurrencySwapConfig_::pair_); + DefReadWriteAliases(config, "domesticNotional_", "domestic_notional", &CrossCurrencySwapConfig_::domesticNotional_); + DefReadWriteAliases(config, "foreignNotional_", "foreign_notional", &CrossCurrencySwapConfig_::foreignNotional_); + DefReadWriteAliases(config, "convention_", "convention", &CrossCurrencySwapConfig_::convention_); + DefPropertyAliases( + config, "notionalMode_", "notional_mode", [](const CrossCurrencySwapConfig_& value) { return value.notionalMode_.Switch(); }, + [](CrossCurrencySwapConfig_& value, XccyNotionalMode_::Value_ mode) { value.notionalMode_ = XccyNotionalMode_(mode); }); + DefReadWriteAliases(config, "fxReset_", "fx_reset", &CrossCurrencySwapConfig_::fxReset_); + DefReadWriteAliases(config, "domesticRateFixing_", "domestic_rate_fixing", &CrossCurrencySwapConfig_::domesticRateFixing_); + DefReadWriteAliases(config, "foreignRateFixing_", "foreign_rate_fixing", &CrossCurrencySwapConfig_::foreignRateFixing_); + + auto configBuilder = py::class_(m, "CrossCurrencySwapConfigBuilder_"); + configBuilder.def(py::init<>()); + DefReadWriteAliases(configBuilder, "pair_", "pair", &CrossCurrencySwapConfigBuilder_::pair_); + DefReadWriteAliases(configBuilder, "domesticNotional_", "domestic_notional", &CrossCurrencySwapConfigBuilder_::domesticNotional_); + DefReadWriteAliases(configBuilder, "foreignNotional_", "foreign_notional", &CrossCurrencySwapConfigBuilder_::foreignNotional_); + DefReadWriteAliases(configBuilder, "convention_", "convention", &CrossCurrencySwapConfigBuilder_::convention_); + DefPropertyAliases( + configBuilder, "notionalMode_", "notional_mode", + [](const CrossCurrencySwapConfigBuilder_& value) { return value.notionalMode_.Switch(); }, + [](CrossCurrencySwapConfigBuilder_& value, XccyNotionalMode_::Value_ mode) { value.notionalMode_ = XccyNotionalMode_(mode); }); + DefReadWriteAliases(configBuilder, "fxReset_", "fx_reset", &CrossCurrencySwapConfigBuilder_::fxReset_); + DefReadWriteAliases(configBuilder, "domesticRateFixing_", "domestic_rate_fixing", &CrossCurrencySwapConfigBuilder_::domesticRateFixing_); + DefReadWriteAliases(configBuilder, "foreignRateFixing_", "foreign_rate_fixing", &CrossCurrencySwapConfigBuilder_::foreignRateFixing_); + configBuilder.def("Build", &CrossCurrencySwapConfigBuilder_::Build).def("build", &CrossCurrencySwapConfigBuilder_::Build); + m.def("Deposit_New", [](const Date_& tradeDate, const Date_& start, const Date_& maturity, double marketRate, const RateIndexConvention_& convention) @@ -179,6 +392,15 @@ namespace { py::arg("domestic_index") = RateIndexConvention_(), py::arg("foreign_leg") = RateLegConvention_(), py::arg("foreign_index") = RateIndexConvention_()); + + m.def( + "CrossCurrencySwap_New", + [](const Date_& tradeDate, const Date_& start, const Date_& maturity, double marketRate, + const CrossCurrencySwapConfig_& config) -> std::shared_ptr { + return std::const_pointer_cast( + Handle_(CrossCurrencySwapNew(tradeDate, start, maturity, marketRate, config))); + }, + py::arg("trade_date"), py::arg("start"), py::arg("maturity"), py::arg("market_rate"), py::arg("config")); } void init_bindings_curve_data(py::module_& m) { @@ -296,6 +518,11 @@ namespace { .value("BUMPED", CurveJacobianMode_::Value_::BUMPED) .value("ANALYTIC", CurveJacobianMode_::Value_::ANALYTIC); + py::enum_(m, "XccyNotionalMode") + .value("FIXED", XccyNotionalMode_::Value_::FIXED) + .value("RESETTABLE", XccyNotionalMode_::Value_::RESETTABLE) + .value("MARK_TO_MARKET", XccyNotionalMode_::Value_::MARK_TO_MARKET); + py::enum_(m, "LogDfScheme") .value("LOG_LINEAR", LogDfScheme_::Value_::LOG_LINEAR) .value("LOG_CUBIC_NATURAL", LogDfScheme_::Value_::LOG_CUBIC_NATURAL) @@ -477,110 +704,320 @@ namespace { } void init_bindings_curve_xccy(py::module_& m) { - py::class_(m, "CrossCurrencyCalibrationSpecBuilder_") - .def(py::init<>()) - .def_readwrite("today_", &CrossCurrencyCalibrationSpecBuilder_::today_) - .def_readwrite("basisPair_", &CrossCurrencyCalibrationSpecBuilder_::basisPair_) - .def_readwrite("fxSpot_", &CrossCurrencyCalibrationSpecBuilder_::fxSpot_) - .def_readwrite("fxForwardCollateral_", &CrossCurrencyCalibrationSpecBuilder_::fxForwardCollateral_) - .def_readwrite("smoothingWeight_", &CrossCurrencyCalibrationSpecBuilder_::smoothingWeight_) - .def_readwrite("tolerance_", &CrossCurrencyCalibrationSpecBuilder_::tolerance_) - .def_readwrite("fitTolerance_", &CrossCurrencyCalibrationSpecBuilder_::fitTolerance_) - .def_readwrite("initialGuess_", &CrossCurrencyCalibrationSpecBuilder_::initialGuess_) - .def_readwrite("maxEvaluations_", &CrossCurrencyCalibrationSpecBuilder_::maxEvaluations_) - .def_readwrite("maxRestarts_", &CrossCurrencyCalibrationSpecBuilder_::maxRestarts_) - .def_property("solveMode_", - [](const CrossCurrencyCalibrationSpecBuilder_& b) { return b.solveMode_.Switch(); }, - [](CrossCurrencyCalibrationSpecBuilder_& b, CurveSolveMode_::Value_ v) { - b.solveMode_ = CurveSolveMode_(v); - }) - .def_property("domesticCurveBlock_", - nullptr, - [](CrossCurrencyCalibrationSpecBuilder_& b, - const std::shared_ptr& block) { - b.domesticCurveBlock_ = Handle_( - std::const_pointer_cast(block)); - }) - .def_property("foreignCurveBlock_", - nullptr, - [](CrossCurrencyCalibrationSpecBuilder_& b, - const std::shared_ptr& block) { - b.foreignCurveBlock_ = Handle_( - std::const_pointer_cast(block)); - }) - .def_property("instruments_", - nullptr, - [](CrossCurrencyCalibrationSpecBuilder_& b, const py::iterable& instruments) { - b.instruments_.clear(); - for (auto item : instruments) { - auto handle = py::cast>(item); - b.instruments_.push_back(Handle_( - std::const_pointer_cast(handle))); - } - }) - .def_property("knotDates_", - nullptr, - [](CrossCurrencyCalibrationSpecBuilder_& b, const py::iterable& dates) { - b.knotDates_.clear(); - for (auto item : dates) - b.knotDates_.push_back(py::cast(item)); - }) - .def("Build", &CrossCurrencyCalibrationSpecBuilder_::Build); + auto xccyBuilder = py::class_(m, "CrossCurrencyCalibrationSpecBuilder_"); + xccyBuilder.def(py::init<>()); + DefReadWriteAliases(xccyBuilder, "today_", "today", &CrossCurrencyCalibrationSpecBuilder_::today_); + DefReadWriteAliases(xccyBuilder, "valuationTime_", "valuation_time", &CrossCurrencyCalibrationSpecBuilder_::valuationTime_); + DefReadWriteAliases(xccyBuilder, "collateralCurrency_", "collateral_currency", &CrossCurrencyCalibrationSpecBuilder_::collateralCurrency_); + DefPropertyAliases( + xccyBuilder, "fixings_", "fixings", [](const CrossCurrencyCalibrationSpecBuilder_& value) { return MutableSnapshot(value.fixings_); }, + [](CrossCurrencyCalibrationSpecBuilder_& value, const std::shared_ptr& fixings) { + value.fixings_ = ConstSnapshot(fixings); + }); + DefReadWriteAliases(xccyBuilder, "basisPair_", "basis_pair", &CrossCurrencyCalibrationSpecBuilder_::basisPair_); + DefReadWriteAliases(xccyBuilder, "fxSpot_", "fx_spot", &CrossCurrencyCalibrationSpecBuilder_::fxSpot_); + DefReadWriteAliases(xccyBuilder, "fxForwardCollateral_", "fx_forward_collateral", + &CrossCurrencyCalibrationSpecBuilder_::fxForwardCollateral_); + DefReadWriteAliases(xccyBuilder, "smoothingWeight_", "smoothing_weight", &CrossCurrencyCalibrationSpecBuilder_::smoothingWeight_); + DefReadWriteAliases(xccyBuilder, "tolerance_", "tolerance", &CrossCurrencyCalibrationSpecBuilder_::tolerance_); + DefReadWriteAliases(xccyBuilder, "fitTolerance_", "fit_tolerance", &CrossCurrencyCalibrationSpecBuilder_::fitTolerance_); + DefReadWriteAliases(xccyBuilder, "initialGuess_", "initial_guess", &CrossCurrencyCalibrationSpecBuilder_::initialGuess_); + DefReadWriteAliases(xccyBuilder, "maxEvaluations_", "max_evaluations", &CrossCurrencyCalibrationSpecBuilder_::maxEvaluations_); + DefReadWriteAliases(xccyBuilder, "maxRestarts_", "max_restarts", &CrossCurrencyCalibrationSpecBuilder_::maxRestarts_); + DefPropertyAliases( + xccyBuilder, "solveMode_", "solve_mode", [](const CrossCurrencyCalibrationSpecBuilder_& value) { return value.solveMode_.Switch(); }, + [](CrossCurrencyCalibrationSpecBuilder_& value, CurveSolveMode_::Value_ mode) { value.solveMode_ = CurveSolveMode_(mode); }); + DefPropertyAliases( + xccyBuilder, "domesticCurveBlock_", "domestic_curve_block", + [](const CrossCurrencyCalibrationSpecBuilder_& value) { return std::const_pointer_cast(value.domesticCurveBlock_); }, + [](CrossCurrencyCalibrationSpecBuilder_& value, const std::shared_ptr& block) { + value.domesticCurveBlock_ = Handle_(std::const_pointer_cast(block)); + }); + DefPropertyAliases( + xccyBuilder, "foreignCurveBlock_", "foreign_curve_block", + [](const CrossCurrencyCalibrationSpecBuilder_& value) { return std::const_pointer_cast(value.foreignCurveBlock_); }, + [](CrossCurrencyCalibrationSpecBuilder_& value, const std::shared_ptr& block) { + value.foreignCurveBlock_ = Handle_(std::const_pointer_cast(block)); + }); + DefPropertyAliases( + xccyBuilder, "instruments_", "instruments", + [](const CrossCurrencyCalibrationSpecBuilder_& value) { return InstrumentHandlesToList(value.instruments_); }, + [](CrossCurrencyCalibrationSpecBuilder_& value, const py::iterable& instruments) { + SetInstrumentHandles(&value.instruments_, instruments); + }); + DefPropertyAliases( + xccyBuilder, "knotDates_", "knot_dates", [](const CrossCurrencyCalibrationSpecBuilder_& value) { return DatesToList(value.knotDates_); }, + [](CrossCurrencyCalibrationSpecBuilder_& value, const py::iterable& dates) { SetDates(&value.knotDates_, dates); }); + xccyBuilder.def("Build", &CrossCurrencyCalibrationSpecBuilder_::Build).def("build", &CrossCurrencyCalibrationSpecBuilder_::Build); - py::class_(m, "CrossCurrencyCalibrationDiagnostics_") - .def_property_readonly("marketRates_", [](const CrossCurrencyCalibrationDiagnostics_& d) -> py::list { - py::list result; - for (auto& v : d.marketRates_) result.append(v); - return result; - }) - .def_property_readonly("modelRates_", [](const CrossCurrencyCalibrationDiagnostics_& d) -> py::list { - py::list result; - for (auto& v : d.modelRates_) result.append(v); - return result; - }) - .def_property_readonly("residuals_", [](const CrossCurrencyCalibrationDiagnostics_& d) -> py::list { - py::list result; - for (auto& v : d.residuals_) result.append(v); - return result; - }) - .def_property_readonly("maxAbsResidual_", [](const CrossCurrencyCalibrationDiagnostics_& d) { return d.maxAbsResidual_; }) - .def_property_readonly("rmsResidual_", [](const CrossCurrencyCalibrationDiagnostics_& d) { return d.rmsResidual_; }); + auto xccyDiagnostics = py::class_(m, "CrossCurrencyCalibrationDiagnostics_"); + xccyDiagnostics + .def_property_readonly("marketRates_", + [](const CrossCurrencyCalibrationDiagnostics_& value) { return DoublesToList(value.marketRates_); }) + .def_property_readonly("market_rates", + [](const CrossCurrencyCalibrationDiagnostics_& value) { return DoublesToList(value.marketRates_); }) + .def_property_readonly("modelRates_", [](const CrossCurrencyCalibrationDiagnostics_& value) { return DoublesToList(value.modelRates_); }) + .def_property_readonly("model_rates", [](const CrossCurrencyCalibrationDiagnostics_& value) { return DoublesToList(value.modelRates_); }) + .def_property_readonly("residuals_", [](const CrossCurrencyCalibrationDiagnostics_& value) { return DoublesToList(value.residuals_); }) + .def_property_readonly("residuals", [](const CrossCurrencyCalibrationDiagnostics_& value) { return DoublesToList(value.residuals_); }) + .def_property_readonly("maxAbsResidual_", [](const CrossCurrencyCalibrationDiagnostics_& value) { return value.maxAbsResidual_; }) + .def_property_readonly("max_abs_residual", [](const CrossCurrencyCalibrationDiagnostics_& value) { return value.maxAbsResidual_; }) + .def_property_readonly("rmsResidual_", [](const CrossCurrencyCalibrationDiagnostics_& value) { return value.rmsResidual_; }) + .def_property_readonly("rms_residual", [](const CrossCurrencyCalibrationDiagnostics_& value) { return value.rmsResidual_; }); - py::class_(m, "CrossCurrencyFxForwardCurve_") - .def_property_readonly("pair_", [](const CrossCurrencyFxForwardCurve_& c) { return c.pair_; }) - .def_property_readonly("dates_", [](const CrossCurrencyFxForwardCurve_& c) -> py::list { - py::list result; - for (auto& d : c.dates_) result.append(d); - return result; - }) - .def_property_readonly("forwards_", [](const CrossCurrencyFxForwardCurve_& c) -> py::list { + auto fxForwardCurve = py::class_(m, "CrossCurrencyFxForwardCurve_"); + fxForwardCurve.def_property_readonly("pair_", [](const CrossCurrencyFxForwardCurve_& value) { return value.pair_; }) + .def_property_readonly("pair", [](const CrossCurrencyFxForwardCurve_& value) { return value.pair_; }) + .def_property_readonly("dates_", [](const CrossCurrencyFxForwardCurve_& value) { return DatesToList(value.dates_); }) + .def_property_readonly("dates", [](const CrossCurrencyFxForwardCurve_& value) { return DatesToList(value.dates_); }) + .def_property_readonly("forwards_", [](const CrossCurrencyFxForwardCurve_& value) { return DoublesToList(value.forwards_); }) + .def_property_readonly("forwards", [](const CrossCurrencyFxForwardCurve_& value) { return DoublesToList(value.forwards_); }); + + auto xccyResult = py::class_(m, "CrossCurrencyCalibrationResult_"); + const auto market = [](const CrossCurrencyCalibrationResult_& value) { return std::make_shared(value.market_); }; + const auto fxForwards = py::cpp_function( + [](const CrossCurrencyCalibrationResult_& value) -> const CrossCurrencyFxForwardCurve_& { return value.fxForwardCurve_; }, + py::return_value_policy::reference_internal); + const auto diagnostics = py::cpp_function( + [](const CrossCurrencyCalibrationResult_& value) -> const CrossCurrencyCalibrationDiagnostics_& { return value.diagnostics_; }, + py::return_value_policy::reference_internal); + xccyResult.def_property_readonly("market_", market).def_property_readonly("market", market); + xccyResult.def_property_readonly("fxForwardCurve_", fxForwards).def_property_readonly("fx_forward_curve", fxForwards); + xccyResult.def_property_readonly("diagnostics_", diagnostics).def_property_readonly("diagnostics", diagnostics); + + auto solverOptions = py::class_(m, "CurveSolverOptions_"); + solverOptions.def(py::init<>()); + DefReadWriteAliases(solverOptions, "smoothingWeight_", "smoothing_weight", &CurveSolverOptions_::smoothingWeight_); + DefReadWriteAliases(solverOptions, "tolerance_", "tolerance", &CurveSolverOptions_::tolerance_); + DefReadWriteAliases(solverOptions, "fitTolerance_", "fit_tolerance", &CurveSolverOptions_::fitTolerance_); + DefReadWriteAliases(solverOptions, "initialGuess_", "initial_guess", &CurveSolverOptions_::initialGuess_); + DefReadWriteAliases(solverOptions, "maxEvaluations_", "max_evaluations", &CurveSolverOptions_::maxEvaluations_); + DefReadWriteAliases(solverOptions, "maxRestarts_", "max_restarts", &CurveSolverOptions_::maxRestarts_); + DefPropertyAliases( + solverOptions, "solveMode_", "solve_mode", [](const CurveSolverOptions_& value) { return value.solveMode_.Switch(); }, + [](CurveSolverOptions_& value, CurveSolveMode_::Value_ mode) { value.solveMode_ = CurveSolveMode_(mode); }); + + auto curveDeclaration = py::class_(m, "JointCurveDeclaration_"); + curveDeclaration.def(py::init<>()); + DefStringAliases(curveDeclaration, "curveName_", "curve_name", &JointCurveDeclaration_::curveName_); + DefPropertyAliases( + curveDeclaration, "instruments_", "instruments", + [](const JointCurveDeclaration_& value) { return InstrumentHandlesToList(value.instruments_); }, + [](JointCurveDeclaration_& value, const py::iterable& instruments) { SetInstrumentHandles(&value.instruments_, instruments); }); + DefPropertyAliases( + curveDeclaration, "knotDates_", "knot_dates", [](const JointCurveDeclaration_& value) { return DatesToList(value.knotDates_); }, + [](JointCurveDeclaration_& value, const py::iterable& dates) { SetDates(&value.knotDates_, dates); }); + DefReadWriteAliases(curveDeclaration, "targetCollateral_", "target_collateral", &JointCurveDeclaration_::targetCollateral_); + DefReadWriteAliases(curveDeclaration, "targetTenor_", "target_tenor", &JointCurveDeclaration_::targetTenor_); + DefReadWriteAliases(curveDeclaration, "calibrateDiscountCurve_", "calibrate_discount_curve", + &JointCurveDeclaration_::calibrateDiscountCurve_); + DefReadWriteAliases(curveDeclaration, "baseLayeredOverDiscount_", "base_layered_over_discount", + &JointCurveDeclaration_::baseLayeredOverDiscount_); + DefPropertyAliases( + curveDeclaration, "parameterization_", "parameterization", + [](const JointCurveDeclaration_& value) { return value.parameterization_.Switch(); }, + [](JointCurveDeclaration_& value, CurveParameterization_::Value_ parameterization) { + value.parameterization_ = CurveParameterization_(parameterization); + }); + DefPropertyAliases( + curveDeclaration, "logDfScheme_", "log_df_scheme", [](const JointCurveDeclaration_& value) { return value.logDfScheme_.Switch(); }, + [](JointCurveDeclaration_& value, LogDfScheme_::Value_ scheme) { value.logDfScheme_ = LogDfScheme_(scheme); }); + DefReadWriteAliases(curveDeclaration, "smoothingWeight_", "smoothing_weight", &JointCurveDeclaration_::smoothingWeight_); + DefPropertyAliases( + curveDeclaration, "initialGuessPerNode_", "initial_guess_per_node", + [](const JointCurveDeclaration_& value) { return DoublesToList(value.initialGuessPerNode_); }, + [](JointCurveDeclaration_& value, const py::iterable& values) { SetDoubles(&value.initialGuessPerNode_, values); }); + + auto currencySpec = py::class_(m, "JointCurrencyCurveSpec_"); + currencySpec.def(py::init<>()); + DefReadWriteAliases(currencySpec, "ccy_", "ccy", &JointCurrencyCurveSpec_::ccy_); + DefReadWriteAliases(currencySpec, "liborBasis_", "libor_basis", &JointCurrencyCurveSpec_::liborBasis_); + DefPropertyAliases( + currencySpec, "curves_", "curves", + [](const JointCurrencyCurveSpec_& value) { py::list result; - for (auto& v : c.forwards_) result.append(v); + for (const auto& curve : value.curves_) + result.append(curve); return result; + }, + [](JointCurrencyCurveSpec_& value, const py::iterable& curves) { + value.curves_.clear(); + for (const auto curve : curves) + value.curves_.push_back(py::cast(curve)); }); - py::class_(m, "CrossCurrencyCalibrationResult_") - .def_property_readonly("market_", [](const CrossCurrencyCalibrationResult_& r) - -> std::shared_ptr { - const auto& mkt = r.market_; - return std::make_shared(mkt); - }) - .def_property_readonly("fxForwardCurve_", py::cpp_function([](const CrossCurrencyCalibrationResult_& r) -> const CrossCurrencyFxForwardCurve_& { - return r.fxForwardCurve_; - }, py::return_value_policy::reference_internal)) - .def_property_readonly("diagnostics_", py::cpp_function([](const CrossCurrencyCalibrationResult_& r) -> const CrossCurrencyCalibrationDiagnostics_& { - return r.diagnostics_; - }, py::return_value_policy::reference_internal)); + auto basisDeclaration = py::class_(m, "XccyBasisCurveDeclaration_"); + basisDeclaration.def(py::init<>()); + DefStringAliases(basisDeclaration, "curveName_", "curve_name", &XccyBasisCurveDeclaration_::curveName_); + DefPropertyAliases( + basisDeclaration, "instruments_", "instruments", + [](const XccyBasisCurveDeclaration_& value) { return InstrumentHandlesToList(value.instruments_); }, + [](XccyBasisCurveDeclaration_& value, const py::iterable& instruments) { SetInstrumentHandles(&value.instruments_, instruments); }); + DefPropertyAliases( + basisDeclaration, "knotDates_", "knot_dates", [](const XccyBasisCurveDeclaration_& value) { return DatesToList(value.knotDates_); }, + [](XccyBasisCurveDeclaration_& value, const py::iterable& dates) { SetDates(&value.knotDates_, dates); }); + DefPropertyAliases( + basisDeclaration, "parameterization_", "parameterization", + [](const XccyBasisCurveDeclaration_& value) { return value.parameterization_.Switch(); }, + [](XccyBasisCurveDeclaration_& value, CurveParameterization_::Value_ parameterization) { + value.parameterization_ = CurveParameterization_(parameterization); + }); + DefReadWriteAliases(basisDeclaration, "smoothingWeight_", "smoothing_weight", &XccyBasisCurveDeclaration_::smoothingWeight_); + DefPropertyAliases( + basisDeclaration, "initialGuessPerNode_", "initial_guess_per_node", + [](const XccyBasisCurveDeclaration_& value) { return DoublesToList(value.initialGuessPerNode_); }, + [](XccyBasisCurveDeclaration_& value, const py::iterable& values) { SetDoubles(&value.initialGuessPerNode_, values); }); + + auto jointBuilder = py::class_(m, "JointXccyCalibrationSpecBuilder_"); + jointBuilder.def(py::init<>()); + DefReadWriteAliases(jointBuilder, "valuationTime_", "valuation_time", &JointXccyCalibrationSpecBuilder_::valuationTime_); + DefReadWriteAliases(jointBuilder, "pair_", "pair", &JointXccyCalibrationSpecBuilder_::pair_); + DefReadWriteAliases(jointBuilder, "collateralCurrency_", "collateral_currency", &JointXccyCalibrationSpecBuilder_::collateralCurrency_); + DefReadWriteAliases(jointBuilder, "fxSpot_", "fx_spot", &JointXccyCalibrationSpecBuilder_::fxSpot_); + DefReadWriteAliases(jointBuilder, "domestic_", "domestic", &JointXccyCalibrationSpecBuilder_::domestic_); + DefReadWriteAliases(jointBuilder, "foreign_", "foreign", &JointXccyCalibrationSpecBuilder_::foreign_); + DefReadWriteAliases(jointBuilder, "basis_", "basis", &JointXccyCalibrationSpecBuilder_::basis_); + DefPropertyAliases( + jointBuilder, "fixings_", "fixings", [](const JointXccyCalibrationSpecBuilder_& value) { return MutableSnapshot(value.fixings_); }, + [](JointXccyCalibrationSpecBuilder_& value, const std::shared_ptr& fixings) { + value.fixings_ = ConstSnapshot(fixings); + }); + DefReadWriteAliases(jointBuilder, "solverOptions_", "solver_options", &JointXccyCalibrationSpecBuilder_::solverOptions_); + jointBuilder.def("Build", &JointXccyCalibrationSpecBuilder_::Build).def("build", &JointXccyCalibrationSpecBuilder_::Build); + + auto jointOptions = py::class_(m, "JointXccyCalibrationOptions_"); + jointOptions.def(py::init<>()); + DefPropertyAliases( + jointOptions, "jacobianMode_", "jacobian_mode", [](const JointXccyCalibrationOptions_& value) { return value.jacobianMode_.Switch(); }, + [](JointXccyCalibrationOptions_& value, CurveJacobianMode_::Value_ mode) { value.jacobianMode_ = CurveJacobianMode_(mode); }); + DefReadWriteAliases(jointOptions, "computeEffJacobianInverse_", "compute_eff_jacobian_inverse", + &JointXccyCalibrationOptions_::computeEffJacobianInverse_); + DefReadWriteAliases(jointOptions, "computeForwardJacobian_", "compute_forward_jacobian", + &JointXccyCalibrationOptions_::computeForwardJacobian_); + + auto blockRange = py::class_(m, "CalibrationBlockRange_"); + blockRange.def(py::init<>()); + DefStringAliases(blockRange, "name_", "name", &CalibrationBlockRange_::name_); + DefReadWriteAliases(blockRange, "offset_", "offset", &CalibrationBlockRange_::offset_); + DefReadWriteAliases(blockRange, "size_", "size", &CalibrationBlockRange_::size_); + + auto jointDiagnostics = py::class_(m, "JointCurveCalibrationDiagnostics_"); + jointDiagnostics.def(py::init<>()); + DefStringAliases(jointDiagnostics, "curveName_", "curve_name", &JointCurveCalibrationDiagnostics_::curveName_); + DefReadWriteAliases(jointDiagnostics, "curveIndex_", "curve_index", &JointCurveCalibrationDiagnostics_::curveIndex_); + jointDiagnostics + .def_property_readonly("instrumentNames_", + [](const JointCurveCalibrationDiagnostics_& value) { + py::list result; + for (const auto& name : value.instrumentNames_) + result.append(std::string(name.c_str())); + return result; + }) + .def_property_readonly("instrument_names", + [](const JointCurveCalibrationDiagnostics_& value) { + py::list result; + for (const auto& name : value.instrumentNames_) + result.append(std::string(name.c_str())); + return result; + }) + .def_property_readonly("marketRates_", [](const JointCurveCalibrationDiagnostics_& value) { return DoublesToList(value.marketRates_); }) + .def_property_readonly("market_rates", [](const JointCurveCalibrationDiagnostics_& value) { return DoublesToList(value.marketRates_); }) + .def_property_readonly("modelRates_", [](const JointCurveCalibrationDiagnostics_& value) { return DoublesToList(value.modelRates_); }) + .def_property_readonly("model_rates", [](const JointCurveCalibrationDiagnostics_& value) { return DoublesToList(value.modelRates_); }) + .def_property_readonly("residuals_", [](const JointCurveCalibrationDiagnostics_& value) { return DoublesToList(value.residuals_); }) + .def_property_readonly("residuals", [](const JointCurveCalibrationDiagnostics_& value) { return DoublesToList(value.residuals_); }); + DefReadWriteAliases(jointDiagnostics, "maxAbsResidual_", "max_abs_residual", &JointCurveCalibrationDiagnostics_::maxAbsResidual_); + DefReadWriteAliases(jointDiagnostics, "rmsResidual_", "rms_residual", &JointCurveCalibrationDiagnostics_::rmsResidual_); + DefReadWriteAliases(jointDiagnostics, "usedApproximateFit_", "used_approximate_fit", &JointCurveCalibrationDiagnostics_::usedApproximateFit_); + + auto jointResult = py::class_(m, "JointXccyCalibrationResult_"); + const auto domesticBlock = [](const JointXccyCalibrationResult_& value) { + return std::const_pointer_cast(JointXccyResultDomesticBlock(value)); + }; + const auto foreignBlock = [](const JointXccyCalibrationResult_& value) { + return std::const_pointer_cast(JointXccyResultForeignBlock(value)); + }; + const auto basisCurve = [](const JointXccyCalibrationResult_& value) { + return std::const_pointer_cast(JointXccyResultBasisCurve(value)); + }; + const auto jointFxForwards = py::cpp_function( + [](const JointXccyCalibrationResult_& value) -> const CrossCurrencyFxForwardCurve_& { return JointXccyResultFxForwards(value); }, + py::return_value_policy::reference_internal); + const auto jointMatrix = + py::cpp_function([](const JointXccyCalibrationResult_& value) -> const Matrix_<>& { return JointXccyResultJacobian(value); }, + py::return_value_policy::reference_internal); + const auto inverseMatrix = + py::cpp_function([](const JointXccyCalibrationResult_& value) -> const Matrix_<>& { return value.effJacobianInverse_; }, + py::return_value_policy::reference_internal); + jointResult.def_property_readonly("domesticCurveBlock_", domesticBlock).def_property_readonly("domestic_curve_block", domesticBlock); + jointResult.def_property_readonly("foreignCurveBlock_", foreignBlock).def_property_readonly("foreign_curve_block", foreignBlock); + jointResult.def_property_readonly("basisCurve_", basisCurve).def_property_readonly("basis_curve", basisCurve); + jointResult.def_property_readonly("fxForwardCurve_", jointFxForwards).def_property_readonly("fx_forward_curve", jointFxForwards); + jointResult.def_property_readonly("fixings_", [](const JointXccyCalibrationResult_& value) { return MutableSnapshot(value.fixings_); }) + .def_property_readonly("fixings", [](const JointXccyCalibrationResult_& value) { return MutableSnapshot(value.fixings_); }); + jointResult + .def_property_readonly("domesticDiagnostics_", + [](const JointXccyCalibrationResult_& value) { return ValuesToList(value.domesticDiagnostics_); }) + .def_property_readonly("domestic_diagnostics", + [](const JointXccyCalibrationResult_& value) { return ValuesToList(value.domesticDiagnostics_); }) + .def_property_readonly("foreignDiagnostics_", + [](const JointXccyCalibrationResult_& value) { return ValuesToList(value.foreignDiagnostics_); }) + .def_property_readonly("foreign_diagnostics", + [](const JointXccyCalibrationResult_& value) { return ValuesToList(value.foreignDiagnostics_); }); + const auto jointXccyDiagnostics = py::cpp_function( + [](const JointXccyCalibrationResult_& value) -> const CrossCurrencyCalibrationDiagnostics_& { return value.xccyDiagnostics_; }, + py::return_value_policy::reference_internal); + jointResult.def_property_readonly("xccyDiagnostics_", jointXccyDiagnostics).def_property_readonly("xccy_diagnostics", jointXccyDiagnostics); + jointResult + .def_property_readonly("marketRates_", + [](const JointXccyCalibrationResult_& value) { return DoublesToList(JointXccyResultMarketRates(value)); }) + .def_property_readonly("market_rates", + [](const JointXccyCalibrationResult_& value) { return DoublesToList(JointXccyResultMarketRates(value)); }) + .def_property_readonly("modelRates_", + [](const JointXccyCalibrationResult_& value) { return DoublesToList(JointXccyResultModelRates(value)); }) + .def_property_readonly("model_rates", + [](const JointXccyCalibrationResult_& value) { return DoublesToList(JointXccyResultModelRates(value)); }) + .def_property_readonly("residuals_", + [](const JointXccyCalibrationResult_& value) { return DoublesToList(JointXccyResultResiduals(value)); }) + .def_property_readonly("residuals", + [](const JointXccyCalibrationResult_& value) { return DoublesToList(JointXccyResultResiduals(value)); }); + jointResult.def_property_readonly("jacobianAtSolution_", jointMatrix).def_property_readonly("jacobian_at_solution", jointMatrix); + jointResult.def_property_readonly("effJacobianInverse_", inverseMatrix).def_property_readonly("eff_jacobian_inverse", inverseMatrix); + jointResult + .def_property_readonly("parameterRanges_", + [](const JointXccyCalibrationResult_& value) { return ValuesToList(JointXccyResultParameterRanges(value)); }) + .def_property_readonly("parameter_ranges", + [](const JointXccyCalibrationResult_& value) { return ValuesToList(JointXccyResultParameterRanges(value)); }) + .def_property_readonly("residualRanges_", + [](const JointXccyCalibrationResult_& value) { return ValuesToList(JointXccyResultResidualRanges(value)); }) + .def_property_readonly("residual_ranges", + [](const JointXccyCalibrationResult_& value) { return ValuesToList(JointXccyResultResidualRanges(value)); }); + jointResult.def_property_readonly("jointMaxAbsResidual_", [](const JointXccyCalibrationResult_& value) { return value.jointMaxAbsResidual_; }) + .def_property_readonly("joint_max_abs_residual", [](const JointXccyCalibrationResult_& value) { return value.jointMaxAbsResidual_; }) + .def_property_readonly("jointRmsResidual_", [](const JointXccyCalibrationResult_& value) { return value.jointRmsResidual_; }) + .def_property_readonly("joint_rms_residual", [](const JointXccyCalibrationResult_& value) { return value.jointRmsResidual_; }) + .def_property_readonly("usedApproximateFit_", [](const JointXccyCalibrationResult_& value) { return value.usedApproximateFit_; }) + .def_property_readonly("used_approximate_fit", [](const JointXccyCalibrationResult_& value) { return value.usedApproximateFit_; }) + .def_property_readonly("converged_", [](const JointXccyCalibrationResult_& value) { return value.converged_; }) + .def_property_readonly("converged", [](const JointXccyCalibrationResult_& value) { return value.converged_; }) + .def_property_readonly("solverEvaluations_", [](const JointXccyCalibrationResult_& value) { return value.solverEvaluations_; }) + .def_property_readonly("solver_evaluations", [](const JointXccyCalibrationResult_& value) { return value.solverEvaluations_; }); m.def("CalibrateXccyMarket", &CalibrateXccyMarket, py::arg("spec")); + m.def("CalibrateJointXccyMarket", py::overload_cast(&CalibrateJointXccyMarket), py::arg("spec")); + m.def("CalibrateJointXccyMarket", + py::overload_cast(&CalibrateJointXccyMarket), py::arg("spec"), + py::arg("options")); + + AddMatrixSnakeCaseAliases(m); } } // anonymous namespace void init_bindings_curve(py::module_& m) { init_bindings_curve_handles(m); init_bindings_curve_protocol(m); - init_bindings_curve_instruments(m); init_bindings_curve_enums(m); + init_bindings_curve_instruments(m); init_bindings_curve_data(m); init_bindings_curve_calibration_builder(m); init_bindings_curve_calibration_diagnostics(m); diff --git a/dal-python/tests/test_xccy_calibration.py b/dal-python/tests/test_xccy_calibration.py index 81ff6f711..f518e3f37 100644 --- a/dal-python/tests/test_xccy_calibration.py +++ b/dal-python/tests/test_xccy_calibration.py @@ -51,6 +51,51 @@ def test_xccy_builder_can_set_fields(): assert builder.tolerance_ == 1.0e-8 # nosec B101 - pytest assertions are intentional +def test_xccy_builder_new_fields_have_legacy_and_snake_case_names(): + """Reset-aware fields do not replace the existing underscore surface.""" + builder = dal.CrossCurrencyCalibrationSpecBuilder_() + valuation_time = dal.DateTime_(_today(), 9, 45) + snapshot = dal.MarketFixingSnapshot_New({}) + + builder.valuation_time = valuation_time + builder.collateralCurrency_ = dal.Ccy_("USD") + builder.fixings = snapshot + builder.fx_spot = 1.10 + + assert builder.valuationTime_ is not None # nosec B101 - pytest assertions are intentional + assert builder.collateral_currency is not None # nosec B101 - pytest assertions are intentional + assert builder.fixings_ is snapshot # nosec B101 - pytest assertions are intentional + assert builder.fxSpot_ == 1.10 # nosec B101 - pytest assertions are intentional + + +def test_legacy_xccy_constructor_accepts_every_original_positional_argument(): + """The config overload leaves the original positional order untouched.""" + currencies = dal.CurrencyPair_New("USD", "EUR") + domestic_leg = dal.RateLegConvention_New(dal.PeriodLength_New("6M"), dal.DayBasis_New("ACT_365F")) + domestic_index = dal.RateIndexConvention_New( + dal.PeriodLength_New("3M"), dal.DayBasis_New("ACT_360"), dal.CollateralType_OIS() + ) + foreign_leg = dal.RateLegConvention_New(dal.PeriodLength_New("6M"), dal.DayBasis_New("ACT_360")) + foreign_index = dal.RateIndexConvention_New( + dal.PeriodLength_New("6M"), dal.DayBasis_New("ACT_360"), dal.CollateralType_OIS() + ) + + instrument = dal.CrossCurrencySwap_New( + _today(), + _spot(), + _spot().AddDays(365), + 0.001, + currencies, + 125.0, + 113.5, + domestic_leg, + domestic_index, + foreign_leg, + foreign_index, + ) + assert instrument is not None # nosec B101 - pytest assertions are intentional + + # ---- Cross-currency calibration ---- def _make_xccy_instruments(fx_spot): diff --git a/dal-python/tests/test_xccy_joint.py b/dal-python/tests/test_xccy_joint.py new file mode 100644 index 000000000..1a4332c97 --- /dev/null +++ b/dal-python/tests/test_xccy_joint.py @@ -0,0 +1,125 @@ +"""Python coverage for joint domestic, foreign, and XCCY calibration.""" + +import dal + + +def _today(): + return dal.Date_(2025, 1, 16) + + +def _joint_curve(name, ccy, market_rate): + maturity = _today().AddDays(365) + index = dal.RateIndexConvention_New( + dal.PeriodLength_New("3M"), dal.DayBasis_New("ACT_365F"), dal.CollateralType_OIS() + ) + declaration = dal.JointCurveDeclaration_() + declaration.curveName_ = name + declaration.instruments = [dal.Deposit_New(_today(), _today(), maturity, market_rate, index)] + declaration.knot_dates = [maturity] + declaration.targetCollateral_ = dal.CollateralType_OIS() + declaration.calibrate_discount_curve = True + declaration.parameterization_ = dal.CurveParameterization.PIECEWISE_CONSTANT_FWD + declaration.log_df_scheme = dal.LogDfScheme.LOG_LINEAR + + result = dal.JointCurrencyCurveSpec_() + result.ccy = dal.Ccy_(ccy) + result.liborBasis_ = dal.DayBasis_New("ACT_365F") + result.curves = [declaration] + return result + + +def _basis_instrument(): + maturity = _today().AddDays(365) + pair = dal.CurrencyPair_New("USD", "EUR") + domestic_leg = dal.RateLegConvention_New(dal.PeriodLength_New("6M"), dal.DayBasis_New("ACT_365F")) + foreign_leg = dal.RateLegConvention_New(dal.PeriodLength_New("6M"), dal.DayBasis_New("ACT_360")) + domestic_index = dal.RateIndexConvention_New( + dal.PeriodLength_New("3M"), dal.DayBasis_New("ACT_365F"), dal.CollateralType_OIS() + ) + foreign_index = dal.RateIndexConvention_New( + dal.PeriodLength_New("3M"), dal.DayBasis_New("ACT_365F"), dal.CollateralType_OIS() + ) + return dal.CrossCurrencySwap_New( + _today(), + _today(), + maturity, + 0.001, + pair, + 110.0, + 100.0, + domestic_leg, + domestic_index, + foreign_leg, + foreign_index, + ) + + +def _joint_spec(): + maturity = _today().AddDays(365) + basis = dal.XccyBasisCurveDeclaration_() + basis.curve_name = "usd_eur_basis" + basis.instruments_ = [_basis_instrument()] + basis.knot_dates = [maturity] + basis.parameterization = dal.CurveParameterization.PIECEWISE_CONSTANT_FWD + basis.smoothingWeight_ = 1.0 + basis.initial_guess_per_node = [0.001] + + builder = dal.JointXccyCalibrationSpecBuilder_() + builder.valuationTime_ = dal.DateTime_(_today(), 0, 0) + builder.pair = dal.CurrencyPair_New("USD", "EUR") + builder.collateral_currency = dal.Ccy_("USD") + builder.fxSpot_ = 1.10 + builder.domestic = _joint_curve("usd_ois", "USD", 0.04) + builder.foreign_ = _joint_curve("eur_ois", "EUR", 0.03) + builder.basis = basis + builder.fixings_ = dal.MarketFixingSnapshot_New({}) + builder.solver_options.initialGuess_ = 0.01 + builder.solverOptions_.tolerance_ = 1.0e-9 + builder.solver_options.max_evaluations = 400 + return builder.Build() + + +def _assert_result_layout(result, expect_jacobian=True): + assert result.converged_ # nosec B101 + assert result.converged # nosec B101 + assert len(result.marketRates_) == len(result.model_rates) # nosec B101 + assert len(result.residuals) == len(result.market_rates) # nosec B101 + assert sum(r.size_ for r in result.residualRanges_) == len(result.residuals_) # nosec B101 + assert sum(r.size for r in result.parameter_ranges) > 0 # nosec B101 + assert result.parameterRanges_[0].name_ == result.parameter_ranges[0].name # nosec B101 + if expect_jacobian: + assert result.jacobianAtSolution_.rows() == sum(r.size_ for r in result.residualRanges_) # nosec B101 + assert result.jacobian_at_solution.cols() == sum(r.size for r in result.parameter_ranges) # nosec B101 + else: + assert result.jacobian_at_solution.rows() == 0 # nosec B101 + assert result.jacobianAtSolution_.cols() == 0 # nosec B101 + assert result.domesticCurveBlock_ is not None # nosec B101 + assert result.foreign_curve_block is not None # nosec B101 + assert result.basisCurve_ is not None # nosec B101 + assert result.fx_forward_curve is not None # nosec B101 + + +def test_joint_builder_defaults_and_default_calibration_result_layout(): + """The default overload exposes every joint result block and range.""" + defaults = dal.JointXccyCalibrationSpecBuilder_() + assert defaults.solverOptions_.initialGuess_ == 0.0 # nosec B101 + assert defaults.solver_options.initial_guess == 0.0 # nosec B101 + + _assert_result_layout(dal.CalibrateJointXccyMarket(_joint_spec())) + + +def test_joint_options_overload_and_aliases(): + """The options overload accepts the Python enum and matrix toggles.""" + options = dal.JointXccyCalibrationOptions_() + options.jacobian_mode = dal.CurveJacobianMode.BUMPED + options.computeEffJacobianInverse_ = False + options.compute_forward_jacobian = False + + assert options.jacobianMode_ == dal.CurveJacobianMode.BUMPED # nosec B101 + assert not options.compute_eff_jacobian_inverse # nosec B101 + assert not options.computeForwardJacobian_ # nosec B101 + + result = dal.CalibrateJointXccyMarket(_joint_spec(), options) + _assert_result_layout(result, expect_jacobian=False) + assert result.effJacobianInverse_.rows() == 0 # nosec B101 + assert result.eff_jacobian_inverse.cols() == 0 # nosec B101 diff --git a/dal-python/tests/test_xccy_resettable.py b/dal-python/tests/test_xccy_resettable.py new file mode 100644 index 000000000..364ac79b5 --- /dev/null +++ b/dal-python/tests/test_xccy_resettable.py @@ -0,0 +1,127 @@ +"""Python coverage for resettable and mark-to-market XCCY instruments.""" + +import pytest + +import dal + + +def _today(): + return dal.Date_(2025, 6, 20) + + +def _spot(): + return _today().AddDays(2) + + +def _make_baseline_curves(): + knot_dates = [_spot(), _spot().AddDays(3650)] + usd_ois = dal.DiscountPWLF_New("usd_ois", "USD", knot_dates, [0.04, 0.04]) + eur_ois = dal.DiscountPWLF_New("eur_ois", "EUR", knot_dates, [0.03, 0.03]) + return ( + dal.CurveBlock_New(usd_ois, libor_basis=dal.DayBasis_New("ACT_365F")), + dal.CurveBlock_New(eur_ois, libor_basis=dal.DayBasis_New("ACT_360")), + ) + + +def _make_mtm_config(): + domestic_leg = dal.RateLegConvention_New(dal.PeriodLength_New("3M"), dal.DayBasis_New("ACT_365F")) + foreign_leg = dal.RateLegConvention_New(dal.PeriodLength_New("3M"), dal.DayBasis_New("ACT_360")) + domestic_index = dal.RateIndexConvention_New( + dal.PeriodLength_New("3M"), dal.DayBasis_New("ACT_360"), dal.CollateralType_OIS() + ) + foreign_index = dal.RateIndexConvention_New( + dal.PeriodLength_New("3M"), dal.DayBasis_New("ACT_360"), dal.CollateralType_OIS() + ) + + convention = dal.CrossCurrencyConvention_() + convention.initial_notional_exchange = True + convention.finalNotionalExchange_ = True + convention.spread_on_foreign_leg = True + convention.domestic_leg = domestic_leg + convention.domesticIndex_ = domestic_index + convention.foreign_leg = foreign_leg + convention.foreignIndex_ = foreign_index + + domestic_fixing = dal.FixingIdentity_() + domestic_fixing.index_name = "USD-SOFR-3M" + domestic_fixing.fixingHour_ = 11 + domestic_fixing.fixing_minute = 0 + + foreign_fixing = dal.FixingIdentity_() + foreign_fixing.indexName_ = "EUR-EURIBOR-3M" + foreign_fixing.fixing_hour = 11 + foreign_fixing.fixingMinute_ = 0 + + builder = dal.CrossCurrencySwapConfigBuilder_() + builder.pair = dal.CurrencyPair_New("USD", "EUR") + builder.domesticNotional_ = 110.0 + builder.foreign_notional = 100.0 + builder.convention = convention + builder.notional_mode = dal.XccyNotionalMode.MARK_TO_MARKET + builder.fx_reset = dal.FxResetConvention_New( + 0, dal.Holidays_(""), dal.BizDayConvention_.FOLLOWING, 10, 30 + ) + builder.domestic_rate_fixing = domestic_fixing + builder.foreignRateFixing_ = foreign_fixing + return builder.Build() + + +def test_resettable_config_and_config_overload_preserve_field_aliases(): + """Config values are writable through old and snake-case spellings.""" + assert dal.XccyNotionalMode.RESETTABLE is not None # nosec B101 + config = _make_mtm_config() + + assert config.notionalMode_ == dal.XccyNotionalMode.MARK_TO_MARKET # nosec B101 + assert config.notional_mode == dal.XccyNotionalMode.MARK_TO_MARKET # nosec B101 + assert config.domesticNotional_ == 110.0 # nosec B101 + assert config.foreign_notional == 100.0 # nosec B101 + assert config.fxReset_.fixingHour_ == 10 # nosec B101 + assert config.domestic_rate_fixing.index_name == "USD-SOFR-3M" # nosec B101 + + instrument = dal.CrossCurrencySwap_New( + _today(), _today().AddDays(-92), _today().AddDays(273), 0.001, config + ) + assert instrument is not None # nosec B101 + + +def test_market_fixing_snapshot_accepts_nested_mapping(): + """Nested Python mappings copy rate and FX fixing histories.""" + rate_fixing_time = dal.DateTime_(_today().AddDays(-92), 11, 0) + foreign_fixing_time = dal.DateTime_(_today().AddDays(-92), 11, 0) + fx_fixing_time = dal.DateTime_(_today().AddDays(-1), 10, 30) + values = { + "USD-SOFR-3M": {rate_fixing_time: 0.0525}, + "EUR-EURIBOR-3M": {foreign_fixing_time: 0.0310}, + "FX[EUR/USD]": {fx_fixing_time: 1.10}, + } + + snapshot = dal.MarketFixingSnapshot_New(values) + values["USD-SOFR-3M"][rate_fixing_time] = 0.99 + + assert snapshot is not None # nosec B101 + assert snapshot.find("USD-SOFR-3M", rate_fixing_time) == pytest.approx(0.0525) # nosec B101 + assert snapshot.Require("FX[EUR/USD]", fx_fixing_time, "Python snapshot test") == pytest.approx(1.10) # nosec B101 + + +def test_in_progress_mtm_swap_requires_historical_fixings(): + """An explicit empty snapshot cannot silently fall back to global history.""" + domestic, foreign = _make_baseline_curves() + start = _today().AddDays(-92) + maturity = _today().AddDays(273) + swap = dal.CrossCurrencySwap_New(_today(), start, maturity, 0.001, _make_mtm_config()) + + builder = dal.CrossCurrencyCalibrationSpecBuilder_() + builder.today_ = _today() + builder.valuation_time = dal.DateTime_(_today(), 12, 0) + builder.collateral_currency = dal.Ccy_("USD") + builder.fixings = dal.MarketFixingSnapshot_New({}) + builder.basisPair_ = dal.CurrencyPair_New("USD", "EUR") + builder.domestic_curve_block = domestic + builder.foreignCurveBlock_ = foreign + builder.fx_spot = 1.10 + builder.instruments = [swap] + builder.knot_dates = [maturity] + builder.solveMode_ = dal.CurveSolveMode.APPROXIMATE + + with pytest.raises(RuntimeError, match=r"FX\[EUR/USD\]"): + dal.CalibrateXccyMarket(builder.Build()) From 8bbf4aaca274c45948b9f05c4dd9551927ef4b21 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 04:19:58 +0800 Subject: [PATCH 15/38] feat(excel): expose resettable and joint XCCY functions --- .superpowers/sdd/task-9-report.md | 124 ++++++ .../auto/MG_Calibrate_JointXccy_public.htm | 48 +++ .../auto/MG_Calibrate_JointXccy_public.inc | 74 ++++ .../MG_CrossCurrencySwapConfig_New_public.htm | 51 +++ .../MG_CrossCurrencySwapConfig_New_public.inc | 82 ++++ ...MG_CrossCurrencySwap_Config_New_public.htm | 41 ++ ...MG_CrossCurrencySwap_Config_New_public.inc | 52 +++ ...alibrationResult_Get_BasisCurve_public.htm | 37 ++ ...alibrationResult_Get_BasisCurve_public.inc | 40 ++ ...brationResult_Get_DomesticBlock_public.htm | 37 ++ ...brationResult_Get_DomesticBlock_public.inc | 40 ++ ...ibrationResult_Get_ForeignBlock_public.htm | 37 ++ ...ibrationResult_Get_ForeignBlock_public.inc | 40 ++ ..._JointXccyCalibrationResult_Get_public.htm | 38 ++ ..._JointXccyCalibrationResult_Get_public.inc | 43 +++ .../MG_MarketFixingSnapshot_New_public.htm | 39 ++ .../MG_MarketFixingSnapshot_New_public.inc | 46 +++ .../MG_XccyResetConvention_New_public.htm | 41 ++ .../MG_XccyResetConvention_New_public.inc | 52 +++ dal-excel/src/__curve_storable.hpp | 53 ++- dal-excel/src/__curveinstrument.cpp | 135 ++++++- dal-excel/src/__curveprotocol.cpp | 96 ++++- dal-excel/src/__xccycalibration.cpp | 360 +++++++++++++++++- dal-excel/tests/test_excel_api.cpp | 184 ++++++++- 24 files changed, 1723 insertions(+), 67 deletions(-) create mode 100644 .superpowers/sdd/task-9-report.md create mode 100644 dal-excel/auto/MG_Calibrate_JointXccy_public.htm create mode 100644 dal-excel/auto/MG_Calibrate_JointXccy_public.inc create mode 100644 dal-excel/auto/MG_CrossCurrencySwapConfig_New_public.htm create mode 100644 dal-excel/auto/MG_CrossCurrencySwapConfig_New_public.inc create mode 100644 dal-excel/auto/MG_CrossCurrencySwap_Config_New_public.htm create mode 100644 dal-excel/auto/MG_CrossCurrencySwap_Config_New_public.inc create mode 100644 dal-excel/auto/MG_JointXccyCalibrationResult_Get_BasisCurve_public.htm create mode 100644 dal-excel/auto/MG_JointXccyCalibrationResult_Get_BasisCurve_public.inc create mode 100644 dal-excel/auto/MG_JointXccyCalibrationResult_Get_DomesticBlock_public.htm create mode 100644 dal-excel/auto/MG_JointXccyCalibrationResult_Get_DomesticBlock_public.inc create mode 100644 dal-excel/auto/MG_JointXccyCalibrationResult_Get_ForeignBlock_public.htm create mode 100644 dal-excel/auto/MG_JointXccyCalibrationResult_Get_ForeignBlock_public.inc create mode 100644 dal-excel/auto/MG_JointXccyCalibrationResult_Get_public.htm create mode 100644 dal-excel/auto/MG_JointXccyCalibrationResult_Get_public.inc create mode 100644 dal-excel/auto/MG_MarketFixingSnapshot_New_public.htm create mode 100644 dal-excel/auto/MG_MarketFixingSnapshot_New_public.inc create mode 100644 dal-excel/auto/MG_XccyResetConvention_New_public.htm create mode 100644 dal-excel/auto/MG_XccyResetConvention_New_public.inc diff --git a/.superpowers/sdd/task-9-report.md b/.superpowers/sdd/task-9-report.md new file mode 100644 index 000000000..063370c48 --- /dev/null +++ b/.superpowers/sdd/task-9-report.md @@ -0,0 +1,124 @@ +# Task 9 Report: Excel Resettable and Joint XCCY Surface + +## Status + +- Starting reviewed head: `8dd73f2e950e7af8c7ce2ec5239543659c3dc548` +- Scope: Excel storables, worksheet implementations, generated registration/help sources, and Excel API smoke tests +- Result: locally complete on Linux; the exact pushed head still requires the prescribed Windows `dal_excel` and `dal_excel_tests` CI build + +## Implemented surface + +- Added `StorableFxResetConvention_`, `StorableMarketFixingSnapshot_`, `StorableCrossCurrencySwapConfig_`, and `StorableJointXccyCalibrationResult_`. +- The joint result wrapper retains the solved domestic block, foreign block, and basis curve handles so the dedicated Excel accessors cannot outlive their result dependencies. +- Added `XCCYRESETCONVENTION.NEW` with fixing lag, holiday center, business-day convention, and fixing-time validation. +- Added `MARKETFIXINGSNAPSHOT.NEW` over parallel index-name, fixing-time, and value arrays. It validates equal lengths and duplicate observations before constructing the core immutable snapshot; core validation continues to enforce non-empty names, valid timestamps, and positive finite values. +- Added the prerequisite `CROSSCURRENCYSWAPCONFIG.NEW` handle factory, which captures nested convention handles, reset mode and identities, and notionals. +- Added `CROSSCURRENCYSWAP.CONFIG.NEW(tradeDate, start, maturity, marketRate, config)` for configured/resettable swap construction. +- Added `CALIBRATE.JOINTXCCY` for one domestic discount declaration, one foreign discount declaration, and one basis declaration. It accepts a valuation timestamp, currency-pair handle, collateral currency, FX spot, the three instrument/knot groups, an optional fixing-snapshot handle, and optional two-column settings. +- The settings dictionary maps curve names, LIBOR bases, parameterizations, log-DF schemes, per-block smoothing, solver controls, solve/Jacobian modes, and matrix-output flags. +- Added dedicated result-handle getters for `domesticBlock`, `foreignBlock`, and `basisCurve`. +- Added the generic matrix getter for `fxForwards`, `marketRates`, `modelRates`, `residuals`, `jacobian`, `parameterRanges`, and `residualRanges`. +- Unknown joint result attributes list all ten accepted views and direct handle views to the dedicated getters. +- Preserved the existing `CROSSCURRENCYSWAP.NEW` and `CALIBRATE.XCCYMARKET` registrations and kept all legacy generated includes ahead of the additive registrations in their original order. + +## Generated files + +Machinist added these 18 Excel sources/help files and changed no `dal-cpp/dal/auto` file: + +- `dal-excel/auto/MG_XccyResetConvention_New_public.inc` +- `dal-excel/auto/MG_XccyResetConvention_New_public.htm` +- `dal-excel/auto/MG_MarketFixingSnapshot_New_public.inc` +- `dal-excel/auto/MG_MarketFixingSnapshot_New_public.htm` +- `dal-excel/auto/MG_CrossCurrencySwapConfig_New_public.inc` +- `dal-excel/auto/MG_CrossCurrencySwapConfig_New_public.htm` +- `dal-excel/auto/MG_CrossCurrencySwap_Config_New_public.inc` +- `dal-excel/auto/MG_CrossCurrencySwap_Config_New_public.htm` +- `dal-excel/auto/MG_Calibrate_JointXccy_public.inc` +- `dal-excel/auto/MG_Calibrate_JointXccy_public.htm` +- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_DomesticBlock_public.inc` +- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_DomesticBlock_public.htm` +- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_ForeignBlock_public.inc` +- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_ForeignBlock_public.htm` +- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_BasisCurve_public.inc` +- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_BasisCurve_public.htm` +- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_public.inc` +- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_public.htm` + +## TDD evidence + +### RED + +The repository's Linux CMake configuration intentionally skips `dal-excel`, so the first requested target build established the platform boundary: + +```text +cmake --build build --target dal_excel_tests -j 4 +gmake: *** No rule to make target 'dal_excel_tests'. Stop. +``` + +After the Machinist declarations were added but before generated outputs were accepted, the drift target failed on exactly the 18 missing Excel files listed above. This was the intended generated-source RED. + +### GREEN + +A Linux-portable test-only compile of the Excel implementation sources exercised the same public functions without Office/COM dependencies: + +```text +/tmp/dal_excel_api_tests '--gtest_filter=ExcelApiTest.*' +[==========] Running 4 tests from 1 test suite. +[ PASSED ] 4 tests. +``` + +The smoke suite covers configured resettable swap construction, rejection of non-parallel fixing arrays, an actual small three-block joint calibration plus every result view, and the complete unknown-attribute error list. + +## Final verification + +Generation and generated-source drift: + +```text +cmake --build build --target dal_generate -j 4 +[100%] Built target dal_generate + +cmake --build build --target dal_check_generated -j 4 +[100%] Built target dal_check_generated +``` + +The generation log reported `Wrote 0 files` for DAL core on the final pass; `git diff --name-only -- dal-cpp/dal/auto` is empty. + +Underlying public API regressions: + +```text +build/dal-public/dal_public_tests \ + '--gtest_filter=CurveProtocolTest.*:CurveInstrumentTest.*:XccyCalibrationTest.*' +[==========] 33 tests from 3 test suites ran. +[ PASSED ] 33 tests. +``` + +Underlying core XCCY regressions: + +```text +build/dal-cpp/dal_cpp_tests \ + '--gtest_filter=XccyBasisJacobianTest.*:XccyJointCalibrationTest.*:XccyJointJacobianTest.*:XccyMarketTest.*:XccyPricingTest.*' +[==========] 64 tests from 5 test suites ran. +[ PASSED ] 64 tests. +``` + +Additional checks: + +- `_WIN32` syntax-only compilation passed for all three changed Excel implementation sources, including all generated `.inc` registrations, using a Linux `__declspec` compatibility shim. +- `_WIN32` syntax-only compilation passed for `test_excel_api.cpp` through a wrapper that loads Google Test before defining `_WIN32`. +- `clang-format --dry-run --Werror -sort-includes=0` passed for the five manually changed C++ files. +- `git diff --cached --check` passed for the manually maintained source, test, and report files. As with the pre-existing Machinist outputs, generated `.inc`/`.htm` files retain template-emitted trailing whitespace and terminal blank lines; they were not hand-edited because doing so would create generated drift. +- `python3 .github/scripts/check_docs.py` passed for 38 Markdown files. + +## Files in task commit + +- `.superpowers/sdd/task-9-report.md` +- `dal-excel/src/__curve_storable.hpp` +- `dal-excel/src/__curveprotocol.cpp` +- `dal-excel/src/__curveinstrument.cpp` +- `dal-excel/src/__xccycalibration.cpp` +- `dal-excel/tests/test_excel_api.cpp` +- the 18 generated files listed above + +## Platform boundary and concerns + +The Linux host cannot build the real Excel add-in or `dal_excel_tests` because `dal-excel/CMakeLists.txt` omits those targets off Windows. The local portable compile, generated-registration syntax checks, and core/public regressions are green, but the prescribed exact-head Windows CI must still build `dal_excel` and run `dal_excel_tests`; no local Windows success is claimed. diff --git a/dal-excel/auto/MG_Calibrate_JointXccy_public.htm b/dal-excel/auto/MG_Calibrate_JointXccy_public.htm new file mode 100644 index 000000000..a1022da85 --- /dev/null +++ b/dal-excel/auto/MG_Calibrate_JointXccy_public.htm @@ -0,0 +1,48 @@ + + + + +Calibrate_JointXccy + + + + + +

Calibrate_JointXccy (in Excel: CALIBRATE.JOINTXCCY)

+Jointly calibrate one domestic discount curve, one foreign discount curve, and one cross-currency basis curve + +

Inputs:

+ + + + + + + + + + + + + + + + +
NameTypeOptional?
valuationTimecellValuation timestamp as an Excel date-time cell
currencieshandle of type StorableCurrencyPairThe domestic and foreign currency pair
collateralCurrencystringCollateral currency code (currently the domestic currency)
fxSpotnumberPositive domestic-per-foreign FX spot
domesticInstrumentsvector of handlesDomestic yield-curve calibration instrument handles
domesticKnotDatesvector of datesDomestic discount-curve knot dates
foreignInstrumentsvector of handlesForeign yield-curve calibration instrument handles
foreignKnotDatesvector of datesForeign discount-curve knot dates
basisInstrumentsvector of handlesCross-currency swap instrument handles
basisKnotDatesvector of datesCross-currency basis-curve knot dates
fixingshandle of type StorableMarketFixingSnapshotyesImmutable fixing snapshot (created with MARKETFIXINGSNAPSHOT.NEW)
settingsmatrix of cellsyesOptional two-column (key,value) settings. Keys: domesticCurveName, foreignCurveName, basisCurveName, domesticLiborBasis, foreignLiborBasis, domesticParameterization, foreignParameterization, basisParameterization, domesticLogDfScheme, foreignLogDfScheme, domesticSmoothingWeight, foreignSmoothingWeight, basisSmoothingWeight, tolerance, fitTolerance, initialGuess, maxEvaluations, maxRestarts, solveMode, jacobianMode, computeEffJacobianInverse, computeForwardJacobian
+ + + + + +

Outputs:

+ + + + +
NameType
resulthandle of type StorableJointXccyCalibrationResultThe joint calibration result
+ + + + + + diff --git a/dal-excel/auto/MG_Calibrate_JointXccy_public.inc b/dal-excel/auto/MG_Calibrate_JointXccy_public.inc new file mode 100644 index 000000000..c5a815d2e --- /dev/null +++ b/dal-excel/auto/MG_Calibrate_JointXccy_public.inc @@ -0,0 +1,74 @@ + + +extern "C" __declspec(dllexport) OPER_* xl_Calibrate_JointXccy + (const OPER_* xl_valuationTime, const OPER_* xl_currencies, const OPER_* xl_collateralCurrency, const OPER_* xl_fxSpot, const OPER_* xl_domesticInstruments, const OPER_* xl_domesticKnotDates, const OPER_* xl_foreignInstruments, const OPER_* xl_foreignKnotDates, const OPER_* xl_basisInstruments, const OPER_* xl_basisKnotDates, const OPER_* xl_fixings, const OPER_* xl_settings) +{ + Excel::InitializeSessionIfNeeded(); +ENV_SEED_TYPE(ObjectAccess_); + const char* argName = 0; + try + { + Logging::Write("Calibrate_JointXccy"); + argName = "valuationTime (input #1)"; + const Cell_ valuationTime = Excel::ToCell(xl_valuationTime); + argName = "currencies (input #2)"; + const Handle_ currencies = Excel::ToHandle(_env, xl_currencies); + argName = "collateralCurrency (input #3)"; + const String_ collateralCurrency = Excel::ToString(xl_collateralCurrency); + argName = "fxSpot (input #4)"; + const double fxSpot = Excel::ToDouble(xl_fxSpot); + argName = "domesticInstruments (input #5)"; + const Vector_> domesticInstruments = Excel::ToHandleBaseVector(_env, xl_domesticInstruments); + argName = "domesticKnotDates (input #6)"; + const Vector_ domesticKnotDates = Excel::ToDateVector(xl_domesticKnotDates); + argName = "foreignInstruments (input #7)"; + const Vector_> foreignInstruments = Excel::ToHandleBaseVector(_env, xl_foreignInstruments); + argName = "foreignKnotDates (input #8)"; + const Vector_ foreignKnotDates = Excel::ToDateVector(xl_foreignKnotDates); + argName = "basisInstruments (input #9)"; + const Vector_> basisInstruments = Excel::ToHandleBaseVector(_env, xl_basisInstruments); + argName = "basisKnotDates (input #10)"; + const Vector_ basisKnotDates = Excel::ToDateVector(xl_basisKnotDates); + argName = "fixings (input #11)"; + const Handle_ fixings = Excel::ToHandle(_env, xl_fixings, true); + argName = "settings (input #12)"; + const Matrix_ settings = Excel::ToCellMatrix(xl_settings, true); + REQUIRE(settings.Cols() == 2 || settings.Empty(), "must have two columns (key, value)"); + argName = 0; + Handle_ result; + Calibrate_JointXccy(valuationTime, currencies, collateralCurrency, fxSpot, domesticInstruments, domesticKnotDates, foreignInstruments, foreignKnotDates, basisInstruments, basisKnotDates, fixings, settings, &result); + Excel::Retval_ retval; + retval.Load(_env, result); + return retval.ToXloper(); + } + catch (std::exception& e) + { + return Excel::Error(e.what(), argName); + } + catch (...) + { + return Excel::Error("Unknown error", argName); + } +} + +struct XlRegister_Calibrate_JointXccy_ +{ + XlRegister_Calibrate_JointXccy_() + { + Vector_ argHelp; + argHelp.push_back("Valuation timestamp as an Excel date-time cell"); + argHelp.push_back("The domestic and foreign currency pair"); + argHelp.push_back("Collateral currency code (currently the domestic currency)"); + argHelp.push_back("Positive domestic-per-foreign FX spot"); + argHelp.push_back("Domestic yield-curve calibration instrument handles"); + argHelp.push_back("Domestic discount-curve knot dates"); + argHelp.push_back("Foreign yield-curve calibration instrument handles"); + argHelp.push_back("Foreign discount-curve knot dates"); + argHelp.push_back("Cross-currency swap instrument handles"); + argHelp.push_back("Cross-currency basis-curve knot dates"); + argHelp.push_back("Immutable fixing snapshot (created with MARKETFIXINGSNAPSHOT.NEW)"); + argHelp.push_back("Optional two-column (key,value) settings. Keys: domesticCurveName, foreignCurveName, basisCurveName, domesticLiborBasis, foreignLiborBasis, domesticParameterization, foreignParameterization, basisParameterization, domesticLogDfScheme, foreignLogDfScheme, domesticSmoothingWeight, foreignSmoothingWeight, basisSmoothingWeight, tolerance, fitTolerance, initialGuess, maxEvaluations, maxRestarts, solveMode, jacobianMode, computeEffJacobianInverse, computeForwardJacobian"); + Excel::Register("Base", "xl_Calibrate_JointXccy", "CALIBRATE.JOINTXCCY", "Jointly calibrate one domestic discount curve, one foreign discount curve, and one cross-currency basis curve", "QQQQQQQQQQQQQ", "valuationTime,currencies,collateralCurrency,fxSpot,domesticInstruments,domesticKnotDates,foreignInstruments,foreignKnotDates,basisInstruments,basisKnotDates,[fixings],[settings]", argHelp, false); + } +}; +static XlRegister_Calibrate_JointXccy_ The_Calibrate_JointXccy_XlRegisterer; diff --git a/dal-excel/auto/MG_CrossCurrencySwapConfig_New_public.htm b/dal-excel/auto/MG_CrossCurrencySwapConfig_New_public.htm new file mode 100644 index 000000000..ba7653a21 --- /dev/null +++ b/dal-excel/auto/MG_CrossCurrencySwapConfig_New_public.htm @@ -0,0 +1,51 @@ + + + + +CrossCurrencySwapConfig_New + + + + + +

CrossCurrencySwapConfig_New (in Excel: CROSSCURRENCYSWAPCONFIG.NEW)

+Create a configured cross-currency swap input handle + +

Inputs:

+ + + + + + + + + + + + + + + + + + + +
NameTypeOptional?
currencieshandle of type StorableCurrencyPairThe currency pair (domestic, foreign)
domesticLeghandle of type StorableRateLegConventionThe domestic leg convention
domesticIndexhandle of type StorableRateIndexConventionThe domestic rate index convention
foreignLeghandle of type StorableRateLegConventionThe foreign leg convention
foreignIndexhandle of type StorableRateIndexConventionThe foreign rate index convention
resetConventionhandle of type StorableFxResetConventionThe FX reset convention (created with XCCYRESETCONVENTION.NEW)
notionalModestringNotional behavior: FIXED, RESETTABLE, or MARK_TO_MARKET
domesticRateIndexstringDomestic rate fixing index name
domesticRateFixingHourintegerDomestic rate fixing hour in the range 0 to 23
domesticRateFixingMinuteintegerDomestic rate fixing minute in the range 0 to 59
foreignRateIndexstringForeign rate fixing index name
foreignRateFixingHourintegerForeign rate fixing hour in the range 0 to 23
foreignRateFixingMinuteintegerForeign rate fixing minute in the range 0 to 59
domesticNotionalnumberyes (default = 100.0)Domestic notional
foreignNotionalnumberyes (default = 100.0)Foreign notional
+ + + + + +

Outputs:

+ + + + +
NameType
confighandle of type StorableCrossCurrencySwapConfigThe configured cross-currency swap input
+ + + + + + diff --git a/dal-excel/auto/MG_CrossCurrencySwapConfig_New_public.inc b/dal-excel/auto/MG_CrossCurrencySwapConfig_New_public.inc new file mode 100644 index 000000000..f51630481 --- /dev/null +++ b/dal-excel/auto/MG_CrossCurrencySwapConfig_New_public.inc @@ -0,0 +1,82 @@ + + +extern "C" __declspec(dllexport) OPER_* xl_CrossCurrencySwapConfig_New + (const OPER_* xl_currencies, const OPER_* xl_domesticLeg, const OPER_* xl_domesticIndex, const OPER_* xl_foreignLeg, const OPER_* xl_foreignIndex, const OPER_* xl_resetConvention, const OPER_* xl_notionalMode, const OPER_* xl_domesticRateIndex, const OPER_* xl_domesticRateFixingHour, const OPER_* xl_domesticRateFixingMinute, const OPER_* xl_foreignRateIndex, const OPER_* xl_foreignRateFixingHour, const OPER_* xl_foreignRateFixingMinute, const OPER_* xl_domesticNotional, const OPER_* xl_foreignNotional) +{ + Excel::InitializeSessionIfNeeded(); +ENV_SEED_TYPE(ObjectAccess_); + const char* argName = 0; + try + { + Logging::Write("CrossCurrencySwapConfig_New"); + argName = "currencies (input #1)"; + const Handle_ currencies = Excel::ToHandle(_env, xl_currencies); + argName = "domesticLeg (input #2)"; + const Handle_ domesticLeg = Excel::ToHandle(_env, xl_domesticLeg); + argName = "domesticIndex (input #3)"; + const Handle_ domesticIndex = Excel::ToHandle(_env, xl_domesticIndex); + argName = "foreignLeg (input #4)"; + const Handle_ foreignLeg = Excel::ToHandle(_env, xl_foreignLeg); + argName = "foreignIndex (input #5)"; + const Handle_ foreignIndex = Excel::ToHandle(_env, xl_foreignIndex); + argName = "resetConvention (input #6)"; + const Handle_ resetConvention = Excel::ToHandle(_env, xl_resetConvention); + argName = "notionalMode (input #7)"; + const String_ notionalMode = Excel::ToString(xl_notionalMode); + argName = "domesticRateIndex (input #8)"; + const String_ domesticRateIndex = Excel::ToString(xl_domesticRateIndex); + argName = "domesticRateFixingHour (input #9)"; + const int domesticRateFixingHour = Excel::ToInt(xl_domesticRateFixingHour); + argName = "domesticRateFixingMinute (input #10)"; + const int domesticRateFixingMinute = Excel::ToInt(xl_domesticRateFixingMinute); + argName = "foreignRateIndex (input #11)"; + const String_ foreignRateIndex = Excel::ToString(xl_foreignRateIndex); + argName = "foreignRateFixingHour (input #12)"; + const int foreignRateFixingHour = Excel::ToInt(xl_foreignRateFixingHour); + argName = "foreignRateFixingMinute (input #13)"; + const int foreignRateFixingMinute = Excel::ToInt(xl_foreignRateFixingMinute); + argName = "domesticNotional (input #14)"; + const double domesticNotional = Excel::ToDouble(xl_domesticNotional, true).value_or(100.0); + argName = "foreignNotional (input #15)"; + const double foreignNotional = Excel::ToDouble(xl_foreignNotional, true).value_or(100.0); + argName = 0; + Handle_ config; + CrossCurrencySwapConfig_New(currencies, domesticLeg, domesticIndex, foreignLeg, foreignIndex, resetConvention, notionalMode, domesticRateIndex, domesticRateFixingHour, domesticRateFixingMinute, foreignRateIndex, foreignRateFixingHour, foreignRateFixingMinute, domesticNotional, foreignNotional, &config); + Excel::Retval_ retval; + retval.Load(_env, config); + return retval.ToXloper(); + } + catch (std::exception& e) + { + return Excel::Error(e.what(), argName); + } + catch (...) + { + return Excel::Error("Unknown error", argName); + } +} + +struct XlRegister_CrossCurrencySwapConfig_New_ +{ + XlRegister_CrossCurrencySwapConfig_New_() + { + Vector_ argHelp; + argHelp.push_back("The currency pair (domestic, foreign)"); + argHelp.push_back("The domestic leg convention"); + argHelp.push_back("The domestic rate index convention"); + argHelp.push_back("The foreign leg convention"); + argHelp.push_back("The foreign rate index convention"); + argHelp.push_back("The FX reset convention (created with XCCYRESETCONVENTION.NEW)"); + argHelp.push_back("Notional behavior: FIXED, RESETTABLE, or MARK_TO_MARKET"); + argHelp.push_back("Domestic rate fixing index name"); + argHelp.push_back("Domestic rate fixing hour in the range 0 to 23"); + argHelp.push_back("Domestic rate fixing minute in the range 0 to 59"); + argHelp.push_back("Foreign rate fixing index name"); + argHelp.push_back("Foreign rate fixing hour in the range 0 to 23"); + argHelp.push_back("Foreign rate fixing minute in the range 0 to 59"); + argHelp.push_back("Domestic notional"); + argHelp.push_back("Foreign notional"); + Excel::Register("Base", "xl_CrossCurrencySwapConfig_New", "CROSSCURRENCYSWAPCONFIG.NEW", "Create a configured cross-currency swap input handle", "QQQQQQQQQQQQQQQQ", "currencies,domesticLeg,domesticIndex,foreignLeg,foreignIndex,resetConvention,notionalMode,domesticRateIndex,domesticRateFixingHour,domesticRateFixingMinute,foreignRateIndex,foreignRateFixingHour,foreignRateFixingMinute,[domesticNotional],[foreignNotional]", argHelp, false); + } +}; +static XlRegister_CrossCurrencySwapConfig_New_ The_CrossCurrencySwapConfig_New_XlRegisterer; diff --git a/dal-excel/auto/MG_CrossCurrencySwap_Config_New_public.htm b/dal-excel/auto/MG_CrossCurrencySwap_Config_New_public.htm new file mode 100644 index 000000000..9e7ebabb5 --- /dev/null +++ b/dal-excel/auto/MG_CrossCurrencySwap_Config_New_public.htm @@ -0,0 +1,41 @@ + + + + +CrossCurrencySwap_Config_New + + + + + +

CrossCurrencySwap_Config_New (in Excel: CROSSCURRENCYSWAP.CONFIG.NEW)

+Create a cross-currency swap instrument from a configuration handle + +

Inputs:

+ + + + + + + + + +
NameTypeOptional?
tradeDatedateThe trade or settlement date
startdateThe swap start date
maturitydateThe swap maturity date
marketRatenumberThe market quoted spread
confighandle of type StorableCrossCurrencySwapConfigThe cross-currency swap configuration
+ + + + + +

Outputs:

+ + + + +
NameType
instrumenthandle of type StorableCrossCurrencySwapThe configured cross-currency swap instrument
+ + + + + + diff --git a/dal-excel/auto/MG_CrossCurrencySwap_Config_New_public.inc b/dal-excel/auto/MG_CrossCurrencySwap_Config_New_public.inc new file mode 100644 index 000000000..26ca40c80 --- /dev/null +++ b/dal-excel/auto/MG_CrossCurrencySwap_Config_New_public.inc @@ -0,0 +1,52 @@ + + +extern "C" __declspec(dllexport) OPER_* xl_CrossCurrencySwap_Config_New + (const OPER_* xl_tradeDate, const OPER_* xl_start, const OPER_* xl_maturity, const OPER_* xl_marketRate, const OPER_* xl_config) +{ + Excel::InitializeSessionIfNeeded(); +ENV_SEED_TYPE(ObjectAccess_); + const char* argName = 0; + try + { + Logging::Write("CrossCurrencySwap_Config_New"); + argName = "tradeDate (input #1)"; + const Date_ tradeDate = Excel::ToDate(xl_tradeDate); + argName = "start (input #2)"; + const Date_ start = Excel::ToDate(xl_start); + argName = "maturity (input #3)"; + const Date_ maturity = Excel::ToDate(xl_maturity); + argName = "marketRate (input #4)"; + const double marketRate = Excel::ToDouble(xl_marketRate); + argName = "config (input #5)"; + const Handle_ config = Excel::ToHandle(_env, xl_config); + argName = 0; + Handle_ instrument; + CrossCurrencySwap_Config_New(tradeDate, start, maturity, marketRate, config, &instrument); + Excel::Retval_ retval; + retval.Load(_env, instrument); + return retval.ToXloper(); + } + catch (std::exception& e) + { + return Excel::Error(e.what(), argName); + } + catch (...) + { + return Excel::Error("Unknown error", argName); + } +} + +struct XlRegister_CrossCurrencySwap_Config_New_ +{ + XlRegister_CrossCurrencySwap_Config_New_() + { + Vector_ argHelp; + argHelp.push_back("The trade or settlement date"); + argHelp.push_back("The swap start date"); + argHelp.push_back("The swap maturity date"); + argHelp.push_back("The market quoted spread"); + argHelp.push_back("The cross-currency swap configuration"); + Excel::Register("Base", "xl_CrossCurrencySwap_Config_New", "CROSSCURRENCYSWAP.CONFIG.NEW", "Create a cross-currency swap instrument from a configuration handle", "QQQQQQ", "tradeDate,start,maturity,marketRate,config", argHelp, false); + } +}; +static XlRegister_CrossCurrencySwap_Config_New_ The_CrossCurrencySwap_Config_New_XlRegisterer; diff --git a/dal-excel/auto/MG_JointXccyCalibrationResult_Get_BasisCurve_public.htm b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_BasisCurve_public.htm new file mode 100644 index 000000000..11ffabd23 --- /dev/null +++ b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_BasisCurve_public.htm @@ -0,0 +1,37 @@ + + + + +JointXccyCalibrationResult_Get_BasisCurve + + + + + +

JointXccyCalibrationResult_Get_BasisCurve (in Excel: JOINTXCCYCALIBRATIONRESULT.GET.BASISCURVE)

+Extract the solved cross-currency basis curve from a joint XCCY calibration result + +

Inputs:

+ + + + + +
NameTypeOptional?
resulthandle of type StorableJointXccyCalibrationResultThe joint calibration result
+ + + + + +

Outputs:

+ + + + +
NameType
curvehandle of type StorableDiscountCurveThe solved basis discount curve
+ + + + + + diff --git a/dal-excel/auto/MG_JointXccyCalibrationResult_Get_BasisCurve_public.inc b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_BasisCurve_public.inc new file mode 100644 index 000000000..5f4843e53 --- /dev/null +++ b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_BasisCurve_public.inc @@ -0,0 +1,40 @@ + + +extern "C" __declspec(dllexport) OPER_* xl_JointXccyCalibrationResult_Get_BasisCurve + (const OPER_* xl_result) +{ + Excel::InitializeSessionIfNeeded(); +ENV_SEED_TYPE(ObjectAccess_); + const char* argName = 0; + try + { + Logging::Write("JointXccyCalibrationResult_Get_BasisCurve"); + argName = "result (input #1)"; + const Handle_ result = Excel::ToHandle(_env, xl_result); + argName = 0; + Handle_ curve; + JointXccyCalibrationResult_Get_BasisCurve(result, &curve); + Excel::Retval_ retval; + retval.Load(_env, curve); + return retval.ToXloper(); + } + catch (std::exception& e) + { + return Excel::Error(e.what(), argName); + } + catch (...) + { + return Excel::Error("Unknown error", argName); + } +} + +struct XlRegister_JointXccyCalibrationResult_Get_BasisCurve_ +{ + XlRegister_JointXccyCalibrationResult_Get_BasisCurve_() + { + Vector_ argHelp; + argHelp.push_back("The joint calibration result"); + Excel::Register("Base", "xl_JointXccyCalibrationResult_Get_BasisCurve", "JOINTXCCYCALIBRATIONRESULT.GET.BASISCURVE", "Extract the solved cross-currency basis curve from a joint XCCY calibration result", "QQ", "result", argHelp, false); + } +}; +static XlRegister_JointXccyCalibrationResult_Get_BasisCurve_ The_JointXccyCalibrationResult_Get_BasisCurve_XlRegisterer; diff --git a/dal-excel/auto/MG_JointXccyCalibrationResult_Get_DomesticBlock_public.htm b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_DomesticBlock_public.htm new file mode 100644 index 000000000..95fca5d84 --- /dev/null +++ b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_DomesticBlock_public.htm @@ -0,0 +1,37 @@ + + + + +JointXccyCalibrationResult_Get_DomesticBlock + + + + + +

JointXccyCalibrationResult_Get_DomesticBlock (in Excel: JOINTXCCYCALIBRATIONRESULT.GET.DOMESTICBLOCK)

+Extract the solved domestic curve block from a joint XCCY calibration result + +

Inputs:

+ + + + + +
NameTypeOptional?
resulthandle of type StorableJointXccyCalibrationResultThe joint calibration result
+ + + + + +

Outputs:

+ + + + +
NameType
blockhandle of type StorableCurveBlockThe solved domestic curve block
+ + + + + + diff --git a/dal-excel/auto/MG_JointXccyCalibrationResult_Get_DomesticBlock_public.inc b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_DomesticBlock_public.inc new file mode 100644 index 000000000..47f2ec224 --- /dev/null +++ b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_DomesticBlock_public.inc @@ -0,0 +1,40 @@ + + +extern "C" __declspec(dllexport) OPER_* xl_JointXccyCalibrationResult_Get_DomesticBlock + (const OPER_* xl_result) +{ + Excel::InitializeSessionIfNeeded(); +ENV_SEED_TYPE(ObjectAccess_); + const char* argName = 0; + try + { + Logging::Write("JointXccyCalibrationResult_Get_DomesticBlock"); + argName = "result (input #1)"; + const Handle_ result = Excel::ToHandle(_env, xl_result); + argName = 0; + Handle_ block; + JointXccyCalibrationResult_Get_DomesticBlock(result, &block); + Excel::Retval_ retval; + retval.Load(_env, block); + return retval.ToXloper(); + } + catch (std::exception& e) + { + return Excel::Error(e.what(), argName); + } + catch (...) + { + return Excel::Error("Unknown error", argName); + } +} + +struct XlRegister_JointXccyCalibrationResult_Get_DomesticBlock_ +{ + XlRegister_JointXccyCalibrationResult_Get_DomesticBlock_() + { + Vector_ argHelp; + argHelp.push_back("The joint calibration result"); + Excel::Register("Base", "xl_JointXccyCalibrationResult_Get_DomesticBlock", "JOINTXCCYCALIBRATIONRESULT.GET.DOMESTICBLOCK", "Extract the solved domestic curve block from a joint XCCY calibration result", "QQ", "result", argHelp, false); + } +}; +static XlRegister_JointXccyCalibrationResult_Get_DomesticBlock_ The_JointXccyCalibrationResult_Get_DomesticBlock_XlRegisterer; diff --git a/dal-excel/auto/MG_JointXccyCalibrationResult_Get_ForeignBlock_public.htm b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_ForeignBlock_public.htm new file mode 100644 index 000000000..6d78c496a --- /dev/null +++ b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_ForeignBlock_public.htm @@ -0,0 +1,37 @@ + + + + +JointXccyCalibrationResult_Get_ForeignBlock + + + + + +

JointXccyCalibrationResult_Get_ForeignBlock (in Excel: JOINTXCCYCALIBRATIONRESULT.GET.FOREIGNBLOCK)

+Extract the solved foreign curve block from a joint XCCY calibration result + +

Inputs:

+ + + + + +
NameTypeOptional?
resulthandle of type StorableJointXccyCalibrationResultThe joint calibration result
+ + + + + +

Outputs:

+ + + + +
NameType
blockhandle of type StorableCurveBlockThe solved foreign curve block
+ + + + + + diff --git a/dal-excel/auto/MG_JointXccyCalibrationResult_Get_ForeignBlock_public.inc b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_ForeignBlock_public.inc new file mode 100644 index 000000000..cbfa8cb8b --- /dev/null +++ b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_ForeignBlock_public.inc @@ -0,0 +1,40 @@ + + +extern "C" __declspec(dllexport) OPER_* xl_JointXccyCalibrationResult_Get_ForeignBlock + (const OPER_* xl_result) +{ + Excel::InitializeSessionIfNeeded(); +ENV_SEED_TYPE(ObjectAccess_); + const char* argName = 0; + try + { + Logging::Write("JointXccyCalibrationResult_Get_ForeignBlock"); + argName = "result (input #1)"; + const Handle_ result = Excel::ToHandle(_env, xl_result); + argName = 0; + Handle_ block; + JointXccyCalibrationResult_Get_ForeignBlock(result, &block); + Excel::Retval_ retval; + retval.Load(_env, block); + return retval.ToXloper(); + } + catch (std::exception& e) + { + return Excel::Error(e.what(), argName); + } + catch (...) + { + return Excel::Error("Unknown error", argName); + } +} + +struct XlRegister_JointXccyCalibrationResult_Get_ForeignBlock_ +{ + XlRegister_JointXccyCalibrationResult_Get_ForeignBlock_() + { + Vector_ argHelp; + argHelp.push_back("The joint calibration result"); + Excel::Register("Base", "xl_JointXccyCalibrationResult_Get_ForeignBlock", "JOINTXCCYCALIBRATIONRESULT.GET.FOREIGNBLOCK", "Extract the solved foreign curve block from a joint XCCY calibration result", "QQ", "result", argHelp, false); + } +}; +static XlRegister_JointXccyCalibrationResult_Get_ForeignBlock_ The_JointXccyCalibrationResult_Get_ForeignBlock_XlRegisterer; diff --git a/dal-excel/auto/MG_JointXccyCalibrationResult_Get_public.htm b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_public.htm new file mode 100644 index 000000000..b26329085 --- /dev/null +++ b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_public.htm @@ -0,0 +1,38 @@ + + + + +JointXccyCalibrationResult_Get + + + + + +

JointXccyCalibrationResult_Get (in Excel: JOINTXCCYCALIBRATIONRESULT.GET)

+Extract a matrix-valued view from a joint XCCY calibration result. Use the dedicated getter functions for curve handles. + +

Inputs:

+ + + + + + +
NameTypeOptional?
resulthandle of type StorableJointXccyCalibrationResultThe joint calibration result
attributestringAttribute: fxForwards, marketRates, modelRates, residuals, jacobian, parameterRanges, or residualRanges. Handle views domesticBlock, foreignBlock, and basisCurve use their dedicated getter functions.
+ + + + + +

Outputs:

+ + + + +
NameType
valuematrix of cellsThe requested joint result view
+ + + + + + diff --git a/dal-excel/auto/MG_JointXccyCalibrationResult_Get_public.inc b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_public.inc new file mode 100644 index 000000000..5a813c183 --- /dev/null +++ b/dal-excel/auto/MG_JointXccyCalibrationResult_Get_public.inc @@ -0,0 +1,43 @@ + + +extern "C" __declspec(dllexport) OPER_* xl_JointXccyCalibrationResult_Get + (const OPER_* xl_result, const OPER_* xl_attribute) +{ + Excel::InitializeSessionIfNeeded(); +ENV_SEED_TYPE(ObjectAccess_); + const char* argName = 0; + try + { + Logging::Write("JointXccyCalibrationResult_Get"); + argName = "result (input #1)"; + const Handle_ result = Excel::ToHandle(_env, xl_result); + argName = "attribute (input #2)"; + const String_ attribute = Excel::ToString(xl_attribute); + argName = 0; + Matrix_ value; + JointXccyCalibrationResult_Get(result, attribute, &value); + Excel::Retval_ retval; + retval.Load(value); + return retval.ToXloper(); + } + catch (std::exception& e) + { + return Excel::Error(e.what(), argName); + } + catch (...) + { + return Excel::Error("Unknown error", argName); + } +} + +struct XlRegister_JointXccyCalibrationResult_Get_ +{ + XlRegister_JointXccyCalibrationResult_Get_() + { + Vector_ argHelp; + argHelp.push_back("The joint calibration result"); + argHelp.push_back("Attribute: fxForwards, marketRates, modelRates, residuals, jacobian, parameterRanges, or residualRanges. Handle views domesticBlock, foreignBlock, and basisCurve use their dedicated getter functions."); + Excel::Register("Base", "xl_JointXccyCalibrationResult_Get", "JOINTXCCYCALIBRATIONRESULT.GET", "Extract a matrix-valued view from a joint XCCY calibration result. Use the dedicated getter functions for curve handles.", "QQQ", "result,attribute", argHelp, false); + } +}; +static XlRegister_JointXccyCalibrationResult_Get_ The_JointXccyCalibrationResult_Get_XlRegisterer; diff --git a/dal-excel/auto/MG_MarketFixingSnapshot_New_public.htm b/dal-excel/auto/MG_MarketFixingSnapshot_New_public.htm new file mode 100644 index 000000000..5870292ab --- /dev/null +++ b/dal-excel/auto/MG_MarketFixingSnapshot_New_public.htm @@ -0,0 +1,39 @@ + + + + +MarketFixingSnapshot_New + + + + + +

MarketFixingSnapshot_New (in Excel: MARKETFIXINGSNAPSHOT.NEW)

+Create an immutable market fixing snapshot from parallel input arrays + +

Inputs:

+ + + + + + + +
NameTypeOptional?
indexNamesvector of stringsIndex names for the fixing observations
fixingTimesvector of cellsFixing timestamps as Excel date-time cells
valuesvector of numbersPositive finite fixing values
+ + + + + +

Outputs:

+ + + + +
NameType
snapshothandle of type StorableMarketFixingSnapshotThe immutable market fixing snapshot
+ + + + + + diff --git a/dal-excel/auto/MG_MarketFixingSnapshot_New_public.inc b/dal-excel/auto/MG_MarketFixingSnapshot_New_public.inc new file mode 100644 index 000000000..2838b04b6 --- /dev/null +++ b/dal-excel/auto/MG_MarketFixingSnapshot_New_public.inc @@ -0,0 +1,46 @@ + + +extern "C" __declspec(dllexport) OPER_* xl_MarketFixingSnapshot_New + (const OPER_* xl_indexNames, const OPER_* xl_fixingTimes, const OPER_* xl_values) +{ + Excel::InitializeSessionIfNeeded(); +ENV_SEED_TYPE(ObjectAccess_); + const char* argName = 0; + try + { + Logging::Write("MarketFixingSnapshot_New"); + argName = "indexNames (input #1)"; + const Vector_ indexNames = Excel::ToStringVector(xl_indexNames); + argName = "fixingTimes (input #2)"; + const Vector_ fixingTimes = Excel::ToCellVector(xl_fixingTimes); + argName = "values (input #3)"; + const Vector_ values = Excel::ToDoubleVector(xl_values); + argName = 0; + Handle_ snapshot; + MarketFixingSnapshot_New(indexNames, fixingTimes, values, &snapshot); + Excel::Retval_ retval; + retval.Load(_env, snapshot); + return retval.ToXloper(); + } + catch (std::exception& e) + { + return Excel::Error(e.what(), argName); + } + catch (...) + { + return Excel::Error("Unknown error", argName); + } +} + +struct XlRegister_MarketFixingSnapshot_New_ +{ + XlRegister_MarketFixingSnapshot_New_() + { + Vector_ argHelp; + argHelp.push_back("Index names for the fixing observations"); + argHelp.push_back("Fixing timestamps as Excel date-time cells"); + argHelp.push_back("Positive finite fixing values"); + Excel::Register("Base", "xl_MarketFixingSnapshot_New", "MARKETFIXINGSNAPSHOT.NEW", "Create an immutable market fixing snapshot from parallel input arrays", "QQQQ", "indexNames,fixingTimes,values", argHelp, false); + } +}; +static XlRegister_MarketFixingSnapshot_New_ The_MarketFixingSnapshot_New_XlRegisterer; diff --git a/dal-excel/auto/MG_XccyResetConvention_New_public.htm b/dal-excel/auto/MG_XccyResetConvention_New_public.htm new file mode 100644 index 000000000..2f98c7391 --- /dev/null +++ b/dal-excel/auto/MG_XccyResetConvention_New_public.htm @@ -0,0 +1,41 @@ + + + + +XccyResetConvention_New + + + + + +

XccyResetConvention_New (in Excel: XCCYRESETCONVENTION.NEW)

+Create an FX reset convention handle for resettable cross-currency swaps + +

Inputs:

+ + + + + + + + + +
NameTypeOptional?
fixingLagintegerNumber of business days before the reset date used for the FX fixing
fixingHolidaysstringHoliday center used to adjust FX fixing dates (empty string means no holidays)
fixingConventionstringBusiness-day convention used to adjust FX fixing dates
fixingHourintegerFX fixing hour in the range 0 to 23
fixingMinuteintegerFX fixing minute in the range 0 to 59
+ + + + + +

Outputs:

+ + + + +
NameType
resetConventionhandle of type StorableFxResetConventionThe FX reset convention
+ + + + + + diff --git a/dal-excel/auto/MG_XccyResetConvention_New_public.inc b/dal-excel/auto/MG_XccyResetConvention_New_public.inc new file mode 100644 index 000000000..a019e17f8 --- /dev/null +++ b/dal-excel/auto/MG_XccyResetConvention_New_public.inc @@ -0,0 +1,52 @@ + + +extern "C" __declspec(dllexport) OPER_* xl_XccyResetConvention_New + (const OPER_* xl_fixingLag, const OPER_* xl_fixingHolidays, const OPER_* xl_fixingConvention, const OPER_* xl_fixingHour, const OPER_* xl_fixingMinute) +{ + Excel::InitializeSessionIfNeeded(); +ENV_SEED_TYPE(ObjectAccess_); + const char* argName = 0; + try + { + Logging::Write("XccyResetConvention_New"); + argName = "fixingLag (input #1)"; + const int fixingLag = Excel::ToInt(xl_fixingLag); + argName = "fixingHolidays (input #2)"; + const String_ fixingHolidays = Excel::ToString(xl_fixingHolidays); + argName = "fixingConvention (input #3)"; + const String_ fixingConvention = Excel::ToString(xl_fixingConvention); + argName = "fixingHour (input #4)"; + const int fixingHour = Excel::ToInt(xl_fixingHour); + argName = "fixingMinute (input #5)"; + const int fixingMinute = Excel::ToInt(xl_fixingMinute); + argName = 0; + Handle_ resetConvention; + XccyResetConvention_New(fixingLag, fixingHolidays, fixingConvention, fixingHour, fixingMinute, &resetConvention); + Excel::Retval_ retval; + retval.Load(_env, resetConvention); + return retval.ToXloper(); + } + catch (std::exception& e) + { + return Excel::Error(e.what(), argName); + } + catch (...) + { + return Excel::Error("Unknown error", argName); + } +} + +struct XlRegister_XccyResetConvention_New_ +{ + XlRegister_XccyResetConvention_New_() + { + Vector_ argHelp; + argHelp.push_back("Number of business days before the reset date used for the FX fixing"); + argHelp.push_back("Holiday center used to adjust FX fixing dates (empty string means no holidays)"); + argHelp.push_back("Business-day convention used to adjust FX fixing dates"); + argHelp.push_back("FX fixing hour in the range 0 to 23"); + argHelp.push_back("FX fixing minute in the range 0 to 59"); + Excel::Register("Base", "xl_XccyResetConvention_New", "XCCYRESETCONVENTION.NEW", "Create an FX reset convention handle for resettable cross-currency swaps", "QQQQQQ", "fixingLag,fixingHolidays,fixingConvention,fixingHour,fixingMinute", argHelp, false); + } +}; +static XlRegister_XccyResetConvention_New_ The_XccyResetConvention_New_XlRegisterer; diff --git a/dal-excel/src/__curve_storable.hpp b/dal-excel/src/__curve_storable.hpp index 5aaad3feb..c1b230910 100644 --- a/dal-excel/src/__curve_storable.hpp +++ b/dal-excel/src/__curve_storable.hpp @@ -7,16 +7,17 @@ #pragma once #include "__platform.hpp" -#include -#include -#include -#include -#include -#include -#include -#include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include namespace Dal { @@ -56,6 +57,18 @@ namespace Dal { void Write(Archive::Store_&) const override {} }; + struct StorableFxResetConvention_ : public Storable_ { + FxResetConvention_ val_; + explicit StorableFxResetConvention_(const FxResetConvention_& v) : Storable_("FxResetConvention", String_()), val_(v) {} + void Write(Archive::Store_&) const override {} + }; + + struct StorableMarketFixingSnapshot_ : public Storable_ { + Handle_ val_; + explicit StorableMarketFixingSnapshot_(const Handle_& v) : Storable_("MarketFixingSnapshot", String_()), val_(v) {} + void Write(Archive::Store_&) const override {} + }; + struct StorableYCInstrument_ : public Storable_ { Handle_ val_; explicit StorableYCInstrument_(const Handle_& v) : Storable_("YCInstrument", String_()), val_(v) {} @@ -68,6 +81,12 @@ namespace Dal { void Write(Archive::Store_&) const override {} }; + struct StorableCrossCurrencySwapConfig_ : public Storable_ { + CrossCurrencySwapConfig_ val_; + explicit StorableCrossCurrencySwapConfig_(const CrossCurrencySwapConfig_& v) : Storable_("CrossCurrencySwapConfig", String_()), val_(v) {} + void Write(Archive::Store_&) const override {} + }; + struct StorableDiscountCurve_ : public Storable_ { Handle_ val_; explicit StorableDiscountCurve_(const Handle_& v) : Storable_("DiscountCurve", String_()), val_(v) {} @@ -94,7 +113,8 @@ namespace Dal { struct StorableCrossCurrencyCalibrationSpec_ : public Storable_ { CrossCurrencyCalibrationSpec_ val_; - explicit StorableCrossCurrencyCalibrationSpec_(const CrossCurrencyCalibrationSpec_& v) : Storable_("CrossCurrencyCalibrationSpec", String_()), val_(v) {} + explicit StorableCrossCurrencyCalibrationSpec_(const CrossCurrencyCalibrationSpec_& v) + : Storable_("CrossCurrencyCalibrationSpec", String_()), val_(v) {} void Write(Archive::Store_&) const override {} }; @@ -103,12 +123,23 @@ namespace Dal { struct StorableCrossCurrencyCalibrationResult_ : public Storable_ { CrossCurrencyCalibrationResult_ val_; Handle_ basisCurve_; - explicit StorableCrossCurrencyCalibrationResult_(const CrossCurrencyCalibrationResult_& v, - const Handle_& basis) + explicit StorableCrossCurrencyCalibrationResult_(const CrossCurrencyCalibrationResult_& v, const Handle_& basis) : Storable_("CrossCurrencyCalibrationResult", String_()), val_(v), basisCurve_(basis) {} void Write(Archive::Store_&) const override {} }; + struct StorableJointXccyCalibrationResult_ : public Storable_ { + JointXccyCalibrationResult_ val_; + Handle_ domesticBlock_; + Handle_ foreignBlock_; + Handle_ basisCurve_; + + explicit StorableJointXccyCalibrationResult_(const JointXccyCalibrationResult_& v) + : Storable_("JointXccyCalibrationResult", String_()), val_(v), domesticBlock_(JointXccyResultDomesticBlock(val_)), + foreignBlock_(JointXccyResultForeignBlock(val_)), basisCurve_(JointXccyResultBasisCurve(val_)) {} + void Write(Archive::Store_&) const override {} + }; + // Lay a double vector down as an Nx1 cell column, used by the calibration // result Get accessors to return rate vectors to Excel. inline Matrix_ AsCellColumn(const Vector_<>& v) { diff --git a/dal-excel/src/__curveinstrument.cpp b/dal-excel/src/__curveinstrument.cpp index 18661de94..28255fe8f 100644 --- a/dal-excel/src/__curveinstrument.cpp +++ b/dal-excel/src/__curveinstrument.cpp @@ -8,6 +8,7 @@ #include "__curve_storable.hpp" #include +// clang-format off /*IF-------------------------------------------------------------------------- public Deposit_New Create a deposit instrument for curve calibration @@ -171,6 +172,66 @@ instrument is handle StorableCrossCurrencySwap The cross-currency swap instrument -IF-------------------------------------------------------------------------*/ +/*IF-------------------------------------------------------------------------- +public CrossCurrencySwapConfig_New + Create a configured cross-currency swap input handle +&inputs +currencies is handle StorableCurrencyPair + The currency pair (domestic, foreign) +domesticLeg is handle StorableRateLegConvention + The domestic leg convention +domesticIndex is handle StorableRateIndexConvention + The domestic rate index convention +foreignLeg is handle StorableRateLegConvention + The foreign leg convention +foreignIndex is handle StorableRateIndexConvention + The foreign rate index convention +resetConvention is handle StorableFxResetConvention + The FX reset convention (created with XCCYRESETCONVENTION.NEW) +notionalMode is string + Notional behavior: FIXED, RESETTABLE, or MARK_TO_MARKET +domesticRateIndex is string + Domestic rate fixing index name +domesticRateFixingHour is integer + Domestic rate fixing hour in the range 0 to 23 +domesticRateFixingMinute is integer + Domestic rate fixing minute in the range 0 to 59 +foreignRateIndex is string + Foreign rate fixing index name +foreignRateFixingHour is integer + Foreign rate fixing hour in the range 0 to 23 +foreignRateFixingMinute is integer + Foreign rate fixing minute in the range 0 to 59 +&optional +domesticNotional is number (100.0) + Domestic notional +foreignNotional is number (100.0) + Foreign notional +&outputs +config is handle StorableCrossCurrencySwapConfig + The configured cross-currency swap input +-IF-------------------------------------------------------------------------*/ + +/*IF-------------------------------------------------------------------------- +public CrossCurrencySwap_Config_New + Create a cross-currency swap instrument from a configuration handle +&inputs +tradeDate is date + The trade or settlement date +start is date + The swap start date +maturity is date + The swap maturity date +marketRate is number + The market quoted spread +config is handle StorableCrossCurrencySwapConfig + The cross-currency swap configuration +&outputs +instrument is handle StorableCrossCurrencySwap + The configured cross-currency swap instrument +-IF-------------------------------------------------------------------------*/ + +// clang-format on namespace Dal { namespace { void Deposit_New(const Date_& tradeDate, @@ -218,8 +279,7 @@ namespace Dal { REQUIRE(fixedLeg, "Invalid fixed leg convention handle"); REQUIRE(floatIndex, "Invalid float index convention handle"); REQUIRE(floatLeg, "Invalid float leg convention handle"); - auto result = Dal::SwapNew(tradeDate, start, maturity, marketRate, - fixedLeg->val_, floatIndex->val_, floatLeg->val_); + auto result = Dal::SwapNew(tradeDate, start, maturity, marketRate, fixedLeg->val_, floatIndex->val_, floatLeg->val_); instrument->reset(new StorableYCInstrument_(result)); } @@ -234,8 +294,7 @@ namespace Dal { REQUIRE(fixedLeg, "Invalid fixed leg convention handle"); REQUIRE(overnightIndex, "Invalid overnight index convention handle"); REQUIRE(floatLeg, "Invalid float leg convention handle"); - auto result = Dal::OISSwapNew(tradeDate, start, maturity, marketRate, - fixedLeg->val_, overnightIndex->val_, floatLeg->val_); + auto result = Dal::OISSwapNew(tradeDate, start, maturity, marketRate, fixedLeg->val_, overnightIndex->val_, floatLeg->val_); instrument->reset(new StorableYCInstrument_(result)); } @@ -252,9 +311,7 @@ namespace Dal { REQUIRE(spreadLeg, "Invalid spread leg convention handle"); REQUIRE(refIndex, "Invalid ref index convention handle"); REQUIRE(refLeg, "Invalid ref leg convention handle"); - auto result = Dal::BasisSwapNew(tradeDate, start, maturity, marketRate, - spreadIndex->val_, spreadLeg->val_, - refIndex->val_, refLeg->val_); + auto result = Dal::BasisSwapNew(tradeDate, start, maturity, marketRate, spreadIndex->val_, spreadLeg->val_, refIndex->val_, refLeg->val_); instrument->reset(new StorableYCInstrument_(result)); } @@ -275,14 +332,63 @@ namespace Dal { REQUIRE(domesticIndex, "Invalid domestic index convention handle"); REQUIRE(foreignLeg, "Invalid foreign leg convention handle"); REQUIRE(foreignIndex, "Invalid foreign index convention handle"); - auto result = Dal::CrossCurrencySwapNew(tradeDate, start, maturity, marketRate, - currencies->val_, - domesticNotional, foreignNotional, - domesticLeg->val_, domesticIndex->val_, - foreignLeg->val_, foreignIndex->val_); + auto result = Dal::CrossCurrencySwapNew(tradeDate, start, maturity, marketRate, currencies->val_, domesticNotional, foreignNotional, + domesticLeg->val_, domesticIndex->val_, foreignLeg->val_, foreignIndex->val_); instrument->reset(new StorableCrossCurrencySwap_(result)); } + } // namespace + + void CrossCurrencySwapConfig_New(const Handle_& currencies, + const Handle_& domesticLeg, + const Handle_& domesticIndex, + const Handle_& foreignLeg, + const Handle_& foreignIndex, + const Handle_& resetConvention, + const String_& notionalMode, + const String_& domesticRateIndex, + int domesticRateFixingHour, + int domesticRateFixingMinute, + const String_& foreignRateIndex, + int foreignRateFixingHour, + int foreignRateFixingMinute, + double domesticNotional, + double foreignNotional, + Handle_* config) { + REQUIRE(currencies, "Invalid currency pair handle"); + REQUIRE(domesticLeg, "Invalid domestic leg convention handle"); + REQUIRE(domesticIndex, "Invalid domestic index convention handle"); + REQUIRE(foreignLeg, "Invalid foreign leg convention handle"); + REQUIRE(foreignIndex, "Invalid foreign index convention handle"); + REQUIRE(resetConvention, "Invalid FX reset convention handle"); + + CrossCurrencySwapConfigBuilder_ builder; + builder.pair_ = currencies->val_; + builder.domesticNotional_ = domesticNotional; + builder.foreignNotional_ = foreignNotional; + builder.convention_.domesticLeg_ = domesticLeg->val_; + builder.convention_.domesticIndex_ = domesticIndex->val_; + builder.convention_.foreignLeg_ = foreignLeg->val_; + builder.convention_.foreignIndex_ = foreignIndex->val_; + builder.convention_.initialNotionalExchange_ = true; + builder.convention_.finalNotionalExchange_ = true; + builder.convention_.spreadOnForeignLeg_ = true; + builder.notionalMode_ = XccyNotionalMode_(notionalMode); + builder.fxReset_ = resetConvention->val_; + builder.domesticRateFixing_ = {domesticRateIndex, domesticRateFixingHour, domesticRateFixingMinute}; + builder.foreignRateFixing_ = {foreignRateIndex, foreignRateFixingHour, foreignRateFixingMinute}; + config->reset(new StorableCrossCurrencySwapConfig_(builder.Build())); + } + + void CrossCurrencySwap_Config_New(const Date_& tradeDate, + const Date_& start, + const Date_& maturity, + double marketRate, + const Handle_& config, + Handle_* instrument) { + REQUIRE(config, "Invalid cross-currency swap configuration handle"); + instrument->reset(new StorableCrossCurrencySwap_(Dal::CrossCurrencySwapNew(tradeDate, start, maturity, marketRate, config->val_))); } + // clang-format off #ifdef _WIN32 #include #include @@ -291,5 +397,8 @@ namespace Dal { #include #include #include +#include +#include #endif -} + // clang-format on +} // namespace Dal diff --git a/dal-excel/src/__curveprotocol.cpp b/dal-excel/src/__curveprotocol.cpp index 4ee4273f5..0088c2864 100644 --- a/dal-excel/src/__curveprotocol.cpp +++ b/dal-excel/src/__curveprotocol.cpp @@ -6,8 +6,10 @@ #include "__platform.hpp" #include "__curve_storable.hpp" +#include #include +// clang-format off /*IF-------------------------------------------------------------------------- public CollateralType_OIS Create an OIS collateral type handle @@ -93,6 +95,41 @@ pair is handle StorableCurrencyPair The currency pair -IF-------------------------------------------------------------------------*/ +/*IF-------------------------------------------------------------------------- +public XccyResetConvention_New + Create an FX reset convention handle for resettable cross-currency swaps +&inputs +fixingLag is integer + Number of business days before the reset date used for the FX fixing +fixingHolidays is string + Holiday center used to adjust FX fixing dates (empty string means no holidays) +fixingConvention is string + Business-day convention used to adjust FX fixing dates +fixingHour is integer + FX fixing hour in the range 0 to 23 +fixingMinute is integer + FX fixing minute in the range 0 to 59 +&outputs +resetConvention is handle StorableFxResetConvention + The FX reset convention +-IF-------------------------------------------------------------------------*/ + +/*IF-------------------------------------------------------------------------- +public MarketFixingSnapshot_New + Create an immutable market fixing snapshot from parallel input arrays +&inputs +indexNames is string[] + Index names for the fixing observations +fixingTimes is cell[] + Fixing timestamps as Excel date-time cells +values is number[] + Positive finite fixing values +&outputs +snapshot is handle StorableMarketFixingSnapshot + The immutable market fixing snapshot +-IF-------------------------------------------------------------------------*/ + +// clang-format on namespace Dal { namespace { void CollateralType_OIS(Handle_* collateral) { @@ -107,13 +144,10 @@ namespace Dal { period->reset(new StorablePeriodLength_(Dal::PeriodLength_New(iso))); } - void DayBasis_New(const String_& name, Handle_* basis) { - basis->reset(new StorableDayBasis_(Dal::DayBasis_New(name))); - } + void DayBasis_New(const String_& name, Handle_* basis) { basis->reset(new StorableDayBasis_(Dal::DayBasis_New(name))); } void RateLegConvention_New(const String_& freq, const String_& basis, Handle_* convention) { - convention->reset(new StorableRateLegConvention_( - Dal::RateLegConvention_New(Dal::PeriodLength_New(freq), Dal::DayBasis_New(basis)))); + convention->reset(new StorableRateLegConvention_(Dal::RateLegConvention_New(Dal::PeriodLength_New(freq), Dal::DayBasis_New(basis)))); } void RateIndexConvention_New(const String_& forecastTenor, @@ -121,17 +155,54 @@ namespace Dal { const String_& collateral, bool useProjectionCurve, Handle_* convention) { - convention->reset(new StorableRateIndexConvention_( - Dal::RateIndexConvention_New(Dal::PeriodLength_New(forecastTenor), - Dal::DayBasis_New(basis), - CollateralType_(collateral), - useProjectionCurve))); + convention->reset(new StorableRateIndexConvention_(Dal::RateIndexConvention_New( + Dal::PeriodLength_New(forecastTenor), Dal::DayBasis_New(basis), CollateralType_(collateral), useProjectionCurve))); } void CurrencyPair_New(const String_& domestic, const String_& foreign, Handle_* pair) { pair->reset(new StorableCurrencyPair_(Dal::CurrencyPair_New(domestic, foreign))); } + } // namespace + + void XccyResetConvention_New(int fixingLag, + const String_& fixingHolidays, + const String_& fixingConvention, + int fixingHour, + int fixingMinute, + Handle_* resetConvention) { + REQUIRE(fixingLag >= 0, "FX reset fixing lag must be non-negative"); + REQUIRE(fixingHour >= 0 && fixingHour < 24, "FX reset fixing hour must be between 0 and 23"); + REQUIRE(fixingMinute >= 0 && fixingMinute < 60, "FX reset fixing minute must be between 0 and 59"); + resetConvention->reset(new StorableFxResetConvention_( + Dal::FxResetConventionNew(fixingLag, Holidays_(fixingHolidays), BizDayConvention_(fixingConvention), fixingHour, fixingMinute))); + } + + void MarketFixingSnapshot_New(const Vector_& indexNames, + const Vector_& fixingTimes, + const Vector_<>& values, + Handle_* snapshot) { + REQUIRE(indexNames.size() == fixingTimes.size() && indexNames.size() == values.size(), + "Market fixing snapshot requires indexNames, fixingTimes, and values to have equal length"); + MarketFixingSnapshot_::values_t normalized; + for (int i = 0; i < indexNames.size(); ++i) { + DateTime_ fixingTime; + if (Cell::IsDouble(fixingTimes[i])) { + const double serial = Cell::ToDouble(fixingTimes[i]); + REQUIRE(std::isfinite(serial), "Market fixing time must be a finite Excel serial date"); + const int dateSerial = static_cast(std::floor(serial)); + fixingTime = DateTime_(Date::FromExcel(dateSerial), serial - dateSerial); + } else if (Cell::IsDate(fixingTimes[i])) { + fixingTime = DateTime_(Cell::ToDate(fixingTimes[i])); + } else { + fixingTime = Cell::ToDateTime(fixingTimes[i]); + } + const auto inserted = normalized[indexNames[i]].emplace(fixingTime, values[i]); + REQUIRE(inserted.second, + "Market fixing snapshot contains a duplicate observation for " + indexNames[i] + " at " + DateTime::ToString(fixingTime)); + } + snapshot->reset(new StorableMarketFixingSnapshot_(Dal::MarketFixingSnapshotNew(normalized))); } + // clang-format off #ifdef _WIN32 #include #include @@ -140,5 +211,8 @@ namespace Dal { #include #include #include +#include +#include #endif -} + // clang-format on +} // namespace Dal diff --git a/dal-excel/src/__xccycalibration.cpp b/dal-excel/src/__xccycalibration.cpp index 12f9d0700..04dfab162 100644 --- a/dal-excel/src/__xccycalibration.cpp +++ b/dal-excel/src/__xccycalibration.cpp @@ -6,10 +6,12 @@ #include "__platform.hpp" #include "__curve_storable.hpp" -#include +#include #include +#include #include +// clang-format off /*IF-------------------------------------------------------------------------- public Calibrate_XccyMarket Calibrate a cross-currency basis market from instruments and settings. @@ -64,6 +66,88 @@ value is cell[][] as an Nx1 column; scalar stats (maxAbsResidual/rmsResidual) return as 1x1. -IF-------------------------------------------------------------------------*/ +/*IF-------------------------------------------------------------------------- +public Calibrate_JointXccy + Jointly calibrate one domestic discount curve, one foreign discount curve, and one cross-currency basis curve +&inputs +valuationTime is cell + Valuation timestamp as an Excel date-time cell +currencies is handle StorableCurrencyPair + The domestic and foreign currency pair +collateralCurrency is string + Collateral currency code (currently the domestic currency) +fxSpot is number + Positive domestic-per-foreign FX spot +domesticInstruments is handle[] + Domestic yield-curve calibration instrument handles +domesticKnotDates is date[] + Domestic discount-curve knot dates +foreignInstruments is handle[] + Foreign yield-curve calibration instrument handles +foreignKnotDates is date[] + Foreign discount-curve knot dates +basisInstruments is handle[] + Cross-currency swap instrument handles +basisKnotDates is date[] + Cross-currency basis-curve knot dates +&optional +fixings is handle StorableMarketFixingSnapshot + Immutable fixing snapshot (created with MARKETFIXINGSNAPSHOT.NEW) +settings is cell[][] + &$.Cols() == 2 || $.Empty()\must have two columns (key, value) + Optional two-column (key,value) settings. Keys: domesticCurveName, foreignCurveName, basisCurveName, domesticLiborBasis, foreignLiborBasis, domesticParameterization, foreignParameterization, basisParameterization, domesticLogDfScheme, foreignLogDfScheme, domesticSmoothingWeight, foreignSmoothingWeight, basisSmoothingWeight, tolerance, fitTolerance, initialGuess, maxEvaluations, maxRestarts, solveMode, jacobianMode, computeEffJacobianInverse, computeForwardJacobian +&outputs +result is handle StorableJointXccyCalibrationResult + The joint calibration result +-IF-------------------------------------------------------------------------*/ + +/*IF-------------------------------------------------------------------------- +public JointXccyCalibrationResult_Get_DomesticBlock + Extract the solved domestic curve block from a joint XCCY calibration result +&inputs +result is handle StorableJointXccyCalibrationResult + The joint calibration result +&outputs +block is handle StorableCurveBlock + The solved domestic curve block +-IF-------------------------------------------------------------------------*/ + +/*IF-------------------------------------------------------------------------- +public JointXccyCalibrationResult_Get_ForeignBlock + Extract the solved foreign curve block from a joint XCCY calibration result +&inputs +result is handle StorableJointXccyCalibrationResult + The joint calibration result +&outputs +block is handle StorableCurveBlock + The solved foreign curve block +-IF-------------------------------------------------------------------------*/ + +/*IF-------------------------------------------------------------------------- +public JointXccyCalibrationResult_Get_BasisCurve + Extract the solved cross-currency basis curve from a joint XCCY calibration result +&inputs +result is handle StorableJointXccyCalibrationResult + The joint calibration result +&outputs +curve is handle StorableDiscountCurve + The solved basis discount curve +-IF-------------------------------------------------------------------------*/ + +/*IF-------------------------------------------------------------------------- +public JointXccyCalibrationResult_Get + Extract a matrix-valued view from a joint XCCY calibration result. Use the dedicated getter functions for curve handles. +&inputs +result is handle StorableJointXccyCalibrationResult + The joint calibration result +attribute is string + Attribute: fxForwards, marketRates, modelRates, residuals, jacobian, parameterRanges, or residualRanges. Handle views domesticBlock, foreignBlock, and basisCurve use their dedicated getter functions. +&outputs +value is cell[][] + The requested joint result view +-IF-------------------------------------------------------------------------*/ + +// clang-format on namespace Dal { namespace { void ApplyXccyStringSettings(const String_& key, const Cell_& val, CrossCurrencyCalibrationSpecBuilder_& b) { @@ -109,15 +193,159 @@ namespace Dal { } } + DateTime_ JointValuationTime(const Cell_& value) { + if (Cell::IsDouble(value)) { + const double serial = Cell::ToDouble(value); + REQUIRE(std::isfinite(serial), "Joint XCCY valuation time must be a finite Excel serial date"); + const int dateSerial = static_cast(std::floor(serial)); + return DateTime_(Date::FromExcel(dateSerial), serial - dateSerial); + } + if (Cell::IsDate(value)) + return DateTime_(Cell::ToDate(value)); + return Cell::ToDateTime(value); + } + + Dictionary_ SettingsDictionary(const Matrix_& settings) { + Dictionary_ result; + for (int i = 0; i < settings.Rows(); ++i) { + if (Cell::IsEmpty(settings(i, 0))) + break; + result.Insert(Cell::ToString(settings(i, 0)), settings(i, 1)); + } + return result; + } + + void ApplyJointStringSetting(const String_& key, + const Cell_& value, + JointXccyCalibrationSpecBuilder_& builder, + JointXccyCalibrationOptions_& options) { + if (key == "domesticCurveName") + builder.domestic_.curves_.front().curveName_ = Cell::ToString(value); + else if (key == "foreignCurveName") + builder.foreign_.curves_.front().curveName_ = Cell::ToString(value); + else if (key == "basisCurveName") + builder.basis_.curveName_ = Cell::ToString(value); + else if (key == "domesticLiborBasis") + builder.domestic_.liborBasis_ = DayBasis_(Cell::ToString(value)); + else if (key == "foreignLiborBasis") + builder.foreign_.liborBasis_ = DayBasis_(Cell::ToString(value)); + else if (key == "domesticParameterization") + builder.domestic_.curves_.front().parameterization_ = CurveParameterization_(Cell::ToString(value)); + else if (key == "foreignParameterization") + builder.foreign_.curves_.front().parameterization_ = CurveParameterization_(Cell::ToString(value)); + else if (key == "basisParameterization") + builder.basis_.parameterization_ = CurveParameterization_(Cell::ToString(value)); + else if (key == "domesticLogDfScheme") + builder.domestic_.curves_.front().logDfScheme_ = LogDfScheme_(Cell::ToString(value)); + else if (key == "foreignLogDfScheme") + builder.foreign_.curves_.front().logDfScheme_ = LogDfScheme_(Cell::ToString(value)); + else if (key == "solveMode") + builder.solverOptions_.solveMode_ = CurveSolveMode_(Cell::ToString(value)); + else if (key == "jacobianMode") + options.jacobianMode_ = CurveJacobianMode_(Cell::ToString(value)); + } + + void ApplyJointDoubleSetting(const String_& key, const Cell_& value, JointXccyCalibrationSpecBuilder_& builder) { + if (!Cell::IsDouble(value)) + return; + const double number = Cell::ToDouble(value); + if (key == "domesticSmoothingWeight") + builder.domestic_.curves_.front().smoothingWeight_ = number; + else if (key == "foreignSmoothingWeight") + builder.foreign_.curves_.front().smoothingWeight_ = number; + else if (key == "basisSmoothingWeight") + builder.basis_.smoothingWeight_ = number; + else if (key == "tolerance") + builder.solverOptions_.tolerance_ = number; + else if (key == "fitTolerance") + builder.solverOptions_.fitTolerance_ = number; + else if (key == "initialGuess") + builder.solverOptions_.initialGuess_ = number; + } + + void ApplyJointIntSetting(const String_& key, const Cell_& value, JointXccyCalibrationSpecBuilder_& builder) { + if (!Cell::IsInt(value)) + return; + const int number = Cell::ToInt(value); + if (key == "maxEvaluations") + builder.solverOptions_.maxEvaluations_ = number; + else if (key == "maxRestarts") + builder.solverOptions_.maxRestarts_ = number; + } + + void ApplyJointBoolSetting(const String_& key, const Cell_& value, JointXccyCalibrationOptions_& options) { + if (!Cell::IsBool(value)) + return; + if (key == "computeEffJacobianInverse") + options.computeEffJacobianInverse_ = Cell::ToBool(value); + else if (key == "computeForwardJacobian") + options.computeForwardJacobian_ = Cell::ToBool(value); + } + + void ApplyJointSettings(const Dictionary_& settings, JointXccyCalibrationSpecBuilder_& builder, JointXccyCalibrationOptions_& options) { + for (const auto& setting : settings) { + ApplyJointStringSetting(setting.first, setting.second, builder, options); + ApplyJointDoubleSetting(setting.first, setting.second, builder); + ApplyJointIntSetting(setting.first, setting.second, builder); + ApplyJointBoolSetting(setting.first, setting.second, options); + } + } + + void AddJointCurveInstruments(const Vector_>& wrappers, Vector_>* instruments) { + for (const auto& wrapper : wrappers) { + REQUIRE(wrapper, "Invalid joint yield-curve instrument handle"); + const auto instrument = handle_cast(wrapper); + REQUIRE(instrument, "Joint domestic and foreign instruments must be yield-curve instruments"); + instruments->push_back(instrument->val_); + } + } + + void AddJointBasisInstruments(const Vector_>& wrappers, Vector_>* instruments) { + for (const auto& wrapper : wrappers) { + REQUIRE(wrapper, "Invalid joint basis instrument handle"); + const auto instrument = handle_cast(wrapper); + REQUIRE(instrument, "Joint basis instruments must be cross-currency swaps"); + instruments->push_back(instrument->val_); + } + } + + Matrix_ AsCellMatrix(const Matrix_<>& source) { + Matrix_ result(source.Rows(), source.Cols()); + for (int row = 0; row < source.Rows(); ++row) + for (int col = 0; col < source.Cols(); ++col) + result(row, col) = Cell_(source(row, col)); + return result; + } + + Matrix_ FxForwardsAsCells(const CrossCurrencyFxForwardCurve_& forwards) { + REQUIRE(forwards.dates_.size() == forwards.forwards_.size(), "Joint XCCY FX-forward dates and values have inconsistent lengths"); + Matrix_ result(forwards.dates_.size(), 2); + for (int i = 0; i < forwards.dates_.size(); ++i) { + result(i, 0) = Cell_(forwards.dates_[i]); + result(i, 1) = Cell_(forwards.forwards_[i]); + } + return result; + } + + Matrix_ RangesAsCells(const Vector_& ranges) { + Matrix_ result(ranges.size(), 3); + for (int i = 0; i < ranges.size(); ++i) { + result(i, 0) = Cell_(ranges[i].name_); + result(i, 1) = Cell_(static_cast(ranges[i].offset_)); + result(i, 2) = Cell_(static_cast(ranges[i].size_)); + } + return result; + } + void Calibrate_XccyMarket(const Date_& today, - const String_& domesticCcy, - const String_& foreignCcy, - const Handle_& domesticBlock, - const Handle_& foreignBlock, - const Vector_>& instrumentWrappers, - const Vector_& knotDates, - const Matrix_& settings, - Handle_* result) { + const String_& domesticCcy, + const String_& foreignCcy, + const Handle_& domesticBlock, + const Handle_& foreignBlock, + const Vector_>& instrumentWrappers, + const Vector_& knotDates, + const Matrix_& settings, + Handle_* result) { REQUIRE(domesticBlock, "Invalid domestic curve block handle"); REQUIRE(foreignBlock, "Invalid foreign curve block handle"); @@ -149,8 +377,7 @@ namespace Dal { } const CurrencyPair_ pair = builder.basisPair_; - REQUIRE(builder.fxSpot_ > 0.0, - "Cross-currency calibration requires an fxSpot setting (e.g. fxSpot=1.10)"); + REQUIRE(builder.fxSpot_ > 0.0, "Cross-currency calibration requires an fxSpot setting (e.g. fxSpot=1.10)"); auto spec = builder.Build(); auto calibrated = Dal::CalibrateXccyMarket(spec); @@ -161,14 +388,13 @@ namespace Dal { } void XccyCalibrationResult_Get_BasisCurve(const Handle_& result, - Handle_* curve) { + Handle_* curve) { REQUIRE(result, "Invalid XCCY calibration result handle"); curve->reset(new StorableDiscountCurve_(result->basisCurve_)); } - void XccyCalibrationResult_Get(const Handle_& result, - const String_& attribute, - Matrix_* value) { + void + XccyCalibrationResult_Get(const Handle_& result, const String_& attribute, Matrix_* value) { REQUIRE(result, "Invalid XCCY calibration result handle"); const auto& diag = result->val_.diagnostics_; if (attribute == "marketRates") @@ -182,13 +408,113 @@ namespace Dal { else if (attribute == "rmsResidual") *value = Matrix_(1, 1, Cell_(diag.rmsResidual_)); else - THROW("Unknown XCCY calibration attribute: " + attribute - + " (expected marketRates, modelRates, residuals, maxAbsResidual, or rmsResidual)"); + THROW("Unknown XCCY calibration attribute: " + attribute + + " (expected marketRates, modelRates, residuals, maxAbsResidual, or rmsResidual)"); } + } // namespace + + void Calibrate_JointXccy(const Cell_& valuationTime, + const Handle_& currencies, + const String_& collateralCurrency, + double fxSpot, + const Vector_>& domesticInstruments, + const Vector_& domesticKnotDates, + const Vector_>& foreignInstruments, + const Vector_& foreignKnotDates, + const Vector_>& basisInstruments, + const Vector_& basisKnotDates, + const Handle_& fixings, + const Matrix_& settings, + Handle_* result) { + REQUIRE(currencies, "Invalid currency pair handle"); + + JointXccyCalibrationSpecBuilder_ builder; + builder.valuationTime_ = JointValuationTime(valuationTime); + builder.pair_ = currencies->val_; + builder.collateralCurrency_ = Ccy_(collateralCurrency); + builder.fxSpot_ = fxSpot; + builder.domestic_.ccy_ = currencies->val_.domestic_; + builder.foreign_.ccy_ = currencies->val_.foreign_; + builder.domestic_.curves_.push_back(JointCurveDeclaration_()); + builder.foreign_.curves_.push_back(JointCurveDeclaration_()); + + auto& domestic = builder.domestic_.curves_.front(); + domestic.curveName_ = String_(currencies->val_.domestic_.String()) + "_ois"; + domestic.knotDates_ = domesticKnotDates; + domestic.targetCollateral_ = CollateralType_(CollateralType_::Value_::OIS); + domestic.calibrateDiscountCurve_ = true; + AddJointCurveInstruments(domesticInstruments, &domestic.instruments_); + + auto& foreign = builder.foreign_.curves_.front(); + foreign.curveName_ = String_(currencies->val_.foreign_.String()) + "_ois"; + foreign.knotDates_ = foreignKnotDates; + foreign.targetCollateral_ = CollateralType_(CollateralType_::Value_::OIS); + foreign.calibrateDiscountCurve_ = true; + AddJointCurveInstruments(foreignInstruments, &foreign.instruments_); + + builder.basis_.curveName_ = String_(currencies->val_.domestic_.String()) + "_" + currencies->val_.foreign_.String() + "_basis"; + builder.basis_.knotDates_ = basisKnotDates; + AddJointBasisInstruments(basisInstruments, &builder.basis_.instruments_); + if (fixings) + builder.fixings_ = fixings->val_; + + JointXccyCalibrationOptions_ options; + if (!settings.Empty()) + ApplyJointSettings(SettingsDictionary(settings), builder, options); + + result->reset(new StorableJointXccyCalibrationResult_(Dal::CalibrateJointXccyMarket(builder.Build(), options))); + } + + void JointXccyCalibrationResult_Get_DomesticBlock(const Handle_& result, + Handle_* block) { + REQUIRE(result, "Invalid joint XCCY calibration result handle"); + block->reset(new StorableCurveBlock_(result->domesticBlock_)); + } + + void JointXccyCalibrationResult_Get_ForeignBlock(const Handle_& result, + Handle_* block) { + REQUIRE(result, "Invalid joint XCCY calibration result handle"); + block->reset(new StorableCurveBlock_(result->foreignBlock_)); + } + + void JointXccyCalibrationResult_Get_BasisCurve(const Handle_& result, + Handle_* curve) { + REQUIRE(result, "Invalid joint XCCY calibration result handle"); + curve->reset(new StorableDiscountCurve_(result->basisCurve_)); + } + + void JointXccyCalibrationResult_Get(const Handle_& result, const String_& attribute, Matrix_* value) { + REQUIRE(result, "Invalid joint XCCY calibration result handle"); + if (attribute == "fxForwards") + *value = FxForwardsAsCells(JointXccyResultFxForwards(result->val_)); + else if (attribute == "marketRates") + *value = AsCellColumn(JointXccyResultMarketRates(result->val_)); + else if (attribute == "modelRates") + *value = AsCellColumn(JointXccyResultModelRates(result->val_)); + else if (attribute == "residuals") + *value = AsCellColumn(JointXccyResultResiduals(result->val_)); + else if (attribute == "jacobian") + *value = AsCellMatrix(JointXccyResultJacobian(result->val_)); + else if (attribute == "parameterRanges") + *value = RangesAsCells(JointXccyResultParameterRanges(result->val_)); + else if (attribute == "residualRanges") + *value = RangesAsCells(JointXccyResultResidualRanges(result->val_)); + else + THROW( + "Unknown joint XCCY calibration attribute: " + attribute + + " (accepted views: domesticBlock, foreignBlock, basisCurve, fxForwards, marketRates, modelRates, residuals, jacobian, " + "parameterRanges, residualRanges; use the dedicated DOMESTICBLOCK, FOREIGNBLOCK, and BASISCURVE getter functions for handle views)"); } + // clang-format off #ifdef _WIN32 #include #include #include +#include +#include +#include +#include +#include #endif + // clang-format on } // namespace Dal diff --git a/dal-excel/tests/test_excel_api.cpp b/dal-excel/tests/test_excel_api.cpp index 449513f49..f0b967bef 100644 --- a/dal-excel/tests/test_excel_api.cpp +++ b/dal-excel/tests/test_excel_api.cpp @@ -1,24 +1,178 @@ // // Created by wegam on 2026/5/30. // -// NOTE: dal-excel tests require Windows + Office COM DLLs. -// These tests verify the Excel add-in interface layer. -// -// Test categories (to be implemented): -// - Excel type conversion (string, date, number, vector) -// - Excel function registration -// - Error conversion (C++ exception -> Excel error) -// - Core function smoke tests via Excel interface -// -#if defined(_WIN32) +#if defined(_WIN32) || defined(DAL_EXCEL_API_TESTS_PORTABLE) #include -#include -TEST(ExcelApiTest, TestPlatformDetection) { - // Verify we're on a Windows build with Excel support - ASSERT_TRUE(true); +#include +#include +#include + +namespace Dal { + void XccyResetConvention_New(int fixingLag, + const String_& fixingHolidays, + const String_& fixingConvention, + int fixingHour, + int fixingMinute, + Handle_* resetConvention); + void MarketFixingSnapshot_New(const Vector_& indexNames, + const Vector_& fixingTimes, + const Vector_<>& values, + Handle_* snapshot); + void CrossCurrencySwapConfig_New(const Handle_& currencies, + const Handle_& domesticLeg, + const Handle_& domesticIndex, + const Handle_& foreignLeg, + const Handle_& foreignIndex, + const Handle_& resetConvention, + const String_& notionalMode, + const String_& domesticRateIndex, + int domesticRateFixingHour, + int domesticRateFixingMinute, + const String_& foreignRateIndex, + int foreignRateFixingHour, + int foreignRateFixingMinute, + double domesticNotional, + double foreignNotional, + Handle_* config); + void CrossCurrencySwap_Config_New(const Date_& tradeDate, + const Date_& start, + const Date_& maturity, + double marketRate, + const Handle_& config, + Handle_* instrument); + void Calibrate_JointXccy(const Cell_& valuationTime, + const Handle_& currencies, + const String_& collateralCurrency, + double fxSpot, + const Vector_>& domesticInstruments, + const Vector_& domesticKnotDates, + const Vector_>& foreignInstruments, + const Vector_& foreignKnotDates, + const Vector_>& basisInstruments, + const Vector_& basisKnotDates, + const Handle_& fixings, + const Matrix_& settings, + Handle_* result); + void JointXccyCalibrationResult_Get_DomesticBlock(const Handle_& result, + Handle_* block); + void JointXccyCalibrationResult_Get_ForeignBlock(const Handle_& result, Handle_* block); + void JointXccyCalibrationResult_Get_BasisCurve(const Handle_& result, + Handle_* curve); + void JointXccyCalibrationResult_Get(const Handle_& result, const String_& attribute, Matrix_* value); +} // namespace Dal + +namespace { + using namespace Dal; + + Date_ Today() { return Date_(2025, 1, 16); } + + Handle_ Pair() { return Handle_(new StorableCurrencyPair_(CurrencyPair_New("USD", "EUR"))); } + + Handle_ Leg(const char* basis) { + return Handle_( + new StorableRateLegConvention_(RateLegConvention_New(PeriodLength_New("6M"), DayBasis_New(basis)))); + } + + Handle_ Index(const char* basis) { + return Handle_( + new StorableRateIndexConvention_(RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New(basis), CollateralType_OIS()))); + } + + Handle_ ResettableConfig() { + Handle_ reset; + XccyResetConvention_New(0, "", "Preceding", 11, 0, &reset); + + Handle_ config; + CrossCurrencySwapConfig_New(Pair(), Leg("ACT_365F"), Index("ACT_365F"), Leg("ACT_360"), Index("ACT_365F"), reset, "RESETTABLE", "USD-SOFR-3M", + 11, 0, "EUR-ESTR-3M", 11, 0, 110.0, 100.0, &config); + return config; + } + + Handle_ JointResult() { + const Date_ maturity = Today().AddDays(365); + Vector_> domestic; + domestic.push_back(Handle_(new StorableYCInstrument_(DepositNew( + Today(), Today(), maturity, 0.04, RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_365F"), CollateralType_OIS()))))); + Vector_> foreign; + foreign.push_back(Handle_(new StorableYCInstrument_(DepositNew( + Today(), Today(), maturity, 0.03, RateIndexConvention_New(PeriodLength_New("3M"), DayBasis_New("ACT_365F"), CollateralType_OIS()))))); + + Handle_ xccy; + CrossCurrencySwap_Config_New(Today(), Today(), maturity, 0.001, ResettableConfig(), &xccy); + Vector_> basis{Handle_(xccy)}; + + Handle_ fixings; + MarketFixingSnapshot_New({}, {}, {}, &fixings); + Matrix_ settings(3, 2); + settings(0, 0) = Cell_("initialGuess"); + settings(0, 1) = Cell_(0.01); + settings(1, 0) = Cell_("tolerance"); + settings(1, 1) = Cell_(1.0e-9); + settings(2, 0) = Cell_("maxEvaluations"); + settings(2, 1) = Cell_(400.0); + + Handle_ result; + Calibrate_JointXccy(Cell_(DateTime_(Today(), 0, 0)), Pair(), "USD", 1.10, domestic, {maturity}, foreign, {maturity}, basis, {maturity}, + fixings, settings, &result); + return result; + } +} // namespace + +TEST(ExcelApiTest, TestConfiguredCrossCurrencySwapConstruction) { + const auto config = ResettableConfig(); + ASSERT_TRUE(config); + ASSERT_EQ(config->val_.notionalMode_.Switch(), Dal::XccyNotionalMode_::Value_::RESETTABLE); + ASSERT_EQ(config->val_.fxReset_.fixingHour_, 11); + ASSERT_EQ(config->val_.domesticRateFixing_.indexName_, Dal::String_("USD-SOFR-3M")); + + Dal::Handle_ instrument; + CrossCurrencySwap_Config_New(Today(), Today(), Today().AddDays(365), 0.001, config, &instrument); + ASSERT_TRUE(instrument); + ASSERT_EQ(instrument->val_->Config().notionalMode_.Switch(), Dal::XccyNotionalMode_::Value_::RESETTABLE); +} + +TEST(ExcelApiTest, TestMarketFixingSnapshotRejectsNonParallelInputs) { + Dal::Handle_ snapshot; + ASSERT_THROW(MarketFixingSnapshot_New({"USD-SOFR-3M"}, {}, {0.04}, &snapshot), Dal::Exception_); + ASSERT_THROW(MarketFixingSnapshot_New({"USD-SOFR-3M"}, {Dal::Cell_(Dal::DateTime_(Today(), 11, 0))}, {}, &snapshot), Dal::Exception_); +} + +TEST(ExcelApiTest, TestJointCalibrationAndEveryResultView) { + const auto result = JointResult(); + ASSERT_TRUE(result); + + Dal::Handle_ domestic; + Dal::Handle_ foreign; + Dal::Handle_ basis; + JointXccyCalibrationResult_Get_DomesticBlock(result, &domestic); + JointXccyCalibrationResult_Get_ForeignBlock(result, &foreign); + JointXccyCalibrationResult_Get_BasisCurve(result, &basis); + ASSERT_TRUE(domestic); + ASSERT_TRUE(foreign); + ASSERT_TRUE(basis); + + for (const char* attribute : {"fxForwards", "marketRates", "modelRates", "residuals", "jacobian", "parameterRanges", "residualRanges"}) { + Dal::Matrix_ value; + JointXccyCalibrationResult_Get(result, attribute, &value); + ASSERT_FALSE(value.Empty()) << attribute; + } +} + +TEST(ExcelApiTest, TestUnknownJointResultViewListsEveryAcceptedAttribute) { + const auto result = JointResult(); + try { + Dal::Matrix_ value; + JointXccyCalibrationResult_Get(result, "unknown", &value); + FAIL() << "Expected an unknown joint result attribute to fail"; + } catch (const Dal::Exception_& exception) { + const std::string message(exception.what()); + for (const char* attribute : {"domesticBlock", "foreignBlock", "basisCurve", "fxForwards", "marketRates", "modelRates", "residuals", + "jacobian", "parameterRanges", "residualRanges"}) + ASSERT_NE(message.find(attribute), std::string::npos) << attribute << ": " << message; + } } -#endif // _WIN32 +#endif // defined(_WIN32) || defined(DAL_EXCEL_API_TESTS_PORTABLE) From 8337a63d551b5260ff67024d3e7c0040b4a14377 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 04:31:22 +0800 Subject: [PATCH 16/38] fix(excel): export XCCY smoke test API --- .superpowers/sdd/task-9-report.md | 34 ++++++++++-- dal-excel/CMakeLists.txt | 2 + dal-excel/src/__curveinstrument.cpp | 1 + dal-excel/src/__curveprotocol.cpp | 1 + dal-excel/src/__xccy_test_api.hpp | 81 +++++++++++++++++++++++++++++ dal-excel/src/__xccycalibration.cpp | 1 + dal-excel/tests/test_excel_api.cpp | 58 ++------------------- 7 files changed, 121 insertions(+), 57 deletions(-) create mode 100644 dal-excel/src/__xccy_test_api.hpp diff --git a/.superpowers/sdd/task-9-report.md b/.superpowers/sdd/task-9-report.md index 063370c48..69b49872a 100644 --- a/.superpowers/sdd/task-9-report.md +++ b/.superpowers/sdd/task-9-report.md @@ -3,7 +3,7 @@ ## Status - Starting reviewed head: `8dd73f2e950e7af8c7ce2ec5239543659c3dc548` -- Scope: Excel storables, worksheet implementations, generated registration/help sources, and Excel API smoke tests +- Scope: Excel storables, worksheet implementations, generated registration/help sources, the internal smoke-test linkage seam, and Excel API smoke tests - Result: locally complete on Linux; the exact pushed head still requires the prescribed Windows `dal_excel` and `dal_excel_tests` CI build ## Implemented surface @@ -21,6 +21,17 @@ - Unknown joint result attributes list all ten accepted views and direct handle views to the dedicated getters. - Preserved the existing `CROSSCURRENCYSWAP.NEW` and `CALIBRATE.XCCYMARKET` registrations and kept all legacy generated includes ahead of the additive registrations in their original order. +## Review repair: Windows smoke-test linkage + +The first Task 9 commit exposed the generated worksheet entry points but did not export the directly called C++ smoke-test helpers. On Windows, `dal_excel_tests` links to the XLL import library rather than compiling those implementation sources, so its handwritten declarations could compile but could not link. + +- Added the internal `dal-excel/src/__xccy_test_api.hpp` header declaring only the nine Task 9 helpers used by `test_excel_api.cpp`. +- When `DAL_EXCEL_BUILD_TESTS` is enabled, `dal_excel` compiles those declarations with `__declspec(dllexport)` and `dal_excel_tests` consumes them with `__declspec(dllimport)`. +- With Excel tests disabled, the export definition is not applied, so the internal smoke-test seam is absent from production-only XLL export tables. +- The annotation is empty for the portable Linux harness. +- The implementations and smoke test include the same header; the test's handwritten declarations were removed. +- The fixing-snapshot smoke coverage now also asserts that duplicate `(indexName, fixingTime)` observations are rejected. + ## Generated files Machinist added these 18 Excel sources/help files and changed no `dal-cpp/dal/auto` file: @@ -57,6 +68,14 @@ gmake: *** No rule to make target 'dal_excel_tests'. Stop. After the Machinist declarations were added but before generated outputs were accepted, the drift target failed on exactly the 18 missing Excel files listed above. This was the intended generated-source RED. +The review-repair RED used hidden-default ELF visibility as a local analogue for the MSVC import-library boundary. Before the internal header existed, `readelf` reported the directly called helper as hidden: + +```text +Dal::XccyResetConvention_New(...) FUNC GLOBAL HIDDEN +``` + +After switching the smoke test from handwritten declarations to the intended shared header, its portable compile also failed with `__xccy_test_api.hpp: No such file or directory`, confirming that the declaration boundary had not yet been implemented. + ### GREEN A Linux-portable test-only compile of the Excel implementation sources exercised the same public functions without Office/COM dependencies: @@ -67,7 +86,13 @@ A Linux-portable test-only compile of the Excel implementation sources exercised [ PASSED ] 4 tests. ``` -The smoke suite covers configured resettable swap construction, rejection of non-parallel fixing arrays, an actual small three-block joint calibration plus every result view, and the complete unknown-attribute error list. +The smoke suite covers configured resettable swap construction, rejection of non-parallel and duplicate fixing observations, an actual small three-block joint calibration plus every result view, and the complete unknown-attribute error list. + +With `DAL_EXCEL_TEST_API_EXPORTS` enabled, the same hidden-default probe now reports the helper with default/exported visibility: + +```text +Dal::XccyResetConvention_New(...) FUNC GLOBAL DEFAULT +``` ## Final verification @@ -105,17 +130,20 @@ Additional checks: - `_WIN32` syntax-only compilation passed for all three changed Excel implementation sources, including all generated `.inc` registrations, using a Linux `__declspec` compatibility shim. - `_WIN32` syntax-only compilation passed for `test_excel_api.cpp` through a wrapper that loads Google Test before defining `_WIN32`. -- `clang-format --dry-run --Werror -sort-includes=0` passed for the five manually changed C++ files. +- The hidden-default visibility probe confirmed that all nine internal test helpers are exported only when `DAL_EXCEL_TEST_API_EXPORTS` is defined and remain hidden without it. +- `clang-format --dry-run --Werror -sort-includes=0` passed for the six manually changed C++ files. - `git diff --cached --check` passed for the manually maintained source, test, and report files. As with the pre-existing Machinist outputs, generated `.inc`/`.htm` files retain template-emitted trailing whitespace and terminal blank lines; they were not hand-edited because doing so would create generated drift. - `python3 .github/scripts/check_docs.py` passed for 38 Markdown files. ## Files in task commit - `.superpowers/sdd/task-9-report.md` +- `dal-excel/CMakeLists.txt` - `dal-excel/src/__curve_storable.hpp` - `dal-excel/src/__curveprotocol.cpp` - `dal-excel/src/__curveinstrument.cpp` - `dal-excel/src/__xccycalibration.cpp` +- `dal-excel/src/__xccy_test_api.hpp` - `dal-excel/tests/test_excel_api.cpp` - the 18 generated files listed above diff --git a/dal-excel/CMakeLists.txt b/dal-excel/CMakeLists.txt index 9e9d8d3a6..9d933a8d5 100644 --- a/dal-excel/CMakeLists.txt +++ b/dal-excel/CMakeLists.txt @@ -154,6 +154,7 @@ install(TARGETS dal_excel # Test target: dal_excel_tests (Windows only) # --------------------------------------------------------------------------- if(DAL_EXCEL_BUILD_TESTS) + target_compile_definitions(dal_excel PRIVATE DAL_EXCEL_TEST_API_EXPORTS) enable_testing() if(NOT TARGET GTest::gtest_main) @@ -168,6 +169,7 @@ if(DAL_EXCEL_BUILD_TESTS) if(TEST_FILES) add_executable(dal_excel_tests ${TEST_FILES}) + target_compile_definitions(dal_excel_tests PRIVATE DAL_EXCEL_TEST_API_IMPORTS) if(DAL_EXCEL_USES_INSTALLED_PUBLIC) dal_cpp_apply_msvc_runtime(dal_excel_tests) if(MSVC) diff --git a/dal-excel/src/__curveinstrument.cpp b/dal-excel/src/__curveinstrument.cpp index 28255fe8f..f8f61f9e5 100644 --- a/dal-excel/src/__curveinstrument.cpp +++ b/dal-excel/src/__curveinstrument.cpp @@ -6,6 +6,7 @@ #include "__platform.hpp" #include "__curve_storable.hpp" +#include "__xccy_test_api.hpp" #include // clang-format off diff --git a/dal-excel/src/__curveprotocol.cpp b/dal-excel/src/__curveprotocol.cpp index 0088c2864..e3421cc77 100644 --- a/dal-excel/src/__curveprotocol.cpp +++ b/dal-excel/src/__curveprotocol.cpp @@ -6,6 +6,7 @@ #include "__platform.hpp" #include "__curve_storable.hpp" +#include "__xccy_test_api.hpp" #include #include diff --git a/dal-excel/src/__xccy_test_api.hpp b/dal-excel/src/__xccy_test_api.hpp new file mode 100644 index 000000000..a8bdb89b6 --- /dev/null +++ b/dal-excel/src/__xccy_test_api.hpp @@ -0,0 +1,81 @@ +// +// Created by Codex on 2026/7/14. +// + +#pragma once + +#include "__curve_storable.hpp" + +#if defined(_WIN32) && defined(DAL_EXCEL_TEST_API_EXPORTS) +#define DAL_EXCEL_TEST_API __declspec(dllexport) +#elif defined(_WIN32) && defined(DAL_EXCEL_TEST_API_IMPORTS) +#define DAL_EXCEL_TEST_API __declspec(dllimport) +#else +#define DAL_EXCEL_TEST_API +#endif + +namespace Dal { + DAL_EXCEL_TEST_API void XccyResetConvention_New(int fixingLag, + const String_& fixingHolidays, + const String_& fixingConvention, + int fixingHour, + int fixingMinute, + Handle_* resetConvention); + + DAL_EXCEL_TEST_API void MarketFixingSnapshot_New(const Vector_& indexNames, + const Vector_& fixingTimes, + const Vector_<>& values, + Handle_* snapshot); + + DAL_EXCEL_TEST_API void CrossCurrencySwapConfig_New(const Handle_& currencies, + const Handle_& domesticLeg, + const Handle_& domesticIndex, + const Handle_& foreignLeg, + const Handle_& foreignIndex, + const Handle_& resetConvention, + const String_& notionalMode, + const String_& domesticRateIndex, + int domesticRateFixingHour, + int domesticRateFixingMinute, + const String_& foreignRateIndex, + int foreignRateFixingHour, + int foreignRateFixingMinute, + double domesticNotional, + double foreignNotional, + Handle_* config); + + DAL_EXCEL_TEST_API void CrossCurrencySwap_Config_New(const Date_& tradeDate, + const Date_& start, + const Date_& maturity, + double marketRate, + const Handle_& config, + Handle_* instrument); + + DAL_EXCEL_TEST_API void Calibrate_JointXccy(const Cell_& valuationTime, + const Handle_& currencies, + const String_& collateralCurrency, + double fxSpot, + const Vector_>& domesticInstruments, + const Vector_& domesticKnotDates, + const Vector_>& foreignInstruments, + const Vector_& foreignKnotDates, + const Vector_>& basisInstruments, + const Vector_& basisKnotDates, + const Handle_& fixings, + const Matrix_& settings, + Handle_* result); + + DAL_EXCEL_TEST_API void JointXccyCalibrationResult_Get_DomesticBlock(const Handle_& result, + Handle_* block); + + DAL_EXCEL_TEST_API void JointXccyCalibrationResult_Get_ForeignBlock(const Handle_& result, + Handle_* block); + + DAL_EXCEL_TEST_API void JointXccyCalibrationResult_Get_BasisCurve(const Handle_& result, + Handle_* curve); + + DAL_EXCEL_TEST_API void + JointXccyCalibrationResult_Get(const Handle_& result, const String_& attribute, Matrix_* value); +} // namespace Dal + +#undef DAL_EXCEL_TEST_API diff --git a/dal-excel/src/__xccycalibration.cpp b/dal-excel/src/__xccycalibration.cpp index 04dfab162..50f7734f9 100644 --- a/dal-excel/src/__xccycalibration.cpp +++ b/dal-excel/src/__xccycalibration.cpp @@ -6,6 +6,7 @@ #include "__platform.hpp" #include "__curve_storable.hpp" +#include "__xccy_test_api.hpp" #include #include #include diff --git a/dal-excel/tests/test_excel_api.cpp b/dal-excel/tests/test_excel_api.cpp index f0b967bef..22d503ed1 100644 --- a/dal-excel/tests/test_excel_api.cpp +++ b/dal-excel/tests/test_excel_api.cpp @@ -7,63 +7,10 @@ #include #include +#include #include #include -namespace Dal { - void XccyResetConvention_New(int fixingLag, - const String_& fixingHolidays, - const String_& fixingConvention, - int fixingHour, - int fixingMinute, - Handle_* resetConvention); - void MarketFixingSnapshot_New(const Vector_& indexNames, - const Vector_& fixingTimes, - const Vector_<>& values, - Handle_* snapshot); - void CrossCurrencySwapConfig_New(const Handle_& currencies, - const Handle_& domesticLeg, - const Handle_& domesticIndex, - const Handle_& foreignLeg, - const Handle_& foreignIndex, - const Handle_& resetConvention, - const String_& notionalMode, - const String_& domesticRateIndex, - int domesticRateFixingHour, - int domesticRateFixingMinute, - const String_& foreignRateIndex, - int foreignRateFixingHour, - int foreignRateFixingMinute, - double domesticNotional, - double foreignNotional, - Handle_* config); - void CrossCurrencySwap_Config_New(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const Handle_& config, - Handle_* instrument); - void Calibrate_JointXccy(const Cell_& valuationTime, - const Handle_& currencies, - const String_& collateralCurrency, - double fxSpot, - const Vector_>& domesticInstruments, - const Vector_& domesticKnotDates, - const Vector_>& foreignInstruments, - const Vector_& foreignKnotDates, - const Vector_>& basisInstruments, - const Vector_& basisKnotDates, - const Handle_& fixings, - const Matrix_& settings, - Handle_* result); - void JointXccyCalibrationResult_Get_DomesticBlock(const Handle_& result, - Handle_* block); - void JointXccyCalibrationResult_Get_ForeignBlock(const Handle_& result, Handle_* block); - void JointXccyCalibrationResult_Get_BasisCurve(const Handle_& result, - Handle_* curve); - void JointXccyCalibrationResult_Get(const Handle_& result, const String_& attribute, Matrix_* value); -} // namespace Dal - namespace { using namespace Dal; @@ -138,6 +85,9 @@ TEST(ExcelApiTest, TestMarketFixingSnapshotRejectsNonParallelInputs) { Dal::Handle_ snapshot; ASSERT_THROW(MarketFixingSnapshot_New({"USD-SOFR-3M"}, {}, {0.04}, &snapshot), Dal::Exception_); ASSERT_THROW(MarketFixingSnapshot_New({"USD-SOFR-3M"}, {Dal::Cell_(Dal::DateTime_(Today(), 11, 0))}, {}, &snapshot), Dal::Exception_); + + const Dal::Cell_ fixingTime(Dal::DateTime_(Today(), 11, 0)); + ASSERT_THROW(MarketFixingSnapshot_New({"USD-SOFR-3M", "USD-SOFR-3M"}, {fixingTime, fixingTime}, {0.04, 0.041}, &snapshot), Dal::Exception_); } TEST(ExcelApiTest, TestJointCalibrationAndEveryResultView) { From 57b15ee0f88f5cef728818d662c40b29b8e0d556 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 04:50:38 +0800 Subject: [PATCH 17/38] perf(xccy): skip unused fixed-rate fixing metadata --- dal-cpp/dal/curve/xccypricing.cpp | 4 ++++ dal-cpp/tests/curve/test_xccypricing.cpp | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/dal-cpp/dal/curve/xccypricing.cpp b/dal-cpp/dal/curve/xccypricing.cpp index 8ce98230b..b78299a9d 100644 --- a/dal-cpp/dal/curve/xccypricing.cpp +++ b/dal-cpp/dal/curve/xccypricing.cpp @@ -43,6 +43,8 @@ namespace Dal { } void SetRateFixings(Vector_* periods, const FixingIdentity_& identity) { + if (identity.indexName_.empty()) + return; for (auto& period : *periods) { period.rateIndexName_ = identity.indexName_; period.rateFixingTime_ = RateFixingTime(period.schedule_.fixingDate_, identity); @@ -210,6 +212,8 @@ namespace Dal { const XccyMarketView_& market, const MarketFixingSnapshot_& fixings, const String_& context) { + if (period.rateIndexName_.empty()) + return ActiveForwardRate(period, forecast); return ResolveObservedValue(period.rateIndexName_, period.rateFixingTime_, market.valuationTime_, fixings, context, [&]() { return ActiveForwardRate(period, forecast); }); } diff --git a/dal-cpp/tests/curve/test_xccypricing.cpp b/dal-cpp/tests/curve/test_xccypricing.cpp index 1c3c06704..d7c608fbf 100644 --- a/dal-cpp/tests/curve/test_xccypricing.cpp +++ b/dal-cpp/tests/curve/test_xccypricing.cpp @@ -160,6 +160,29 @@ TEST(XccyPricingTest, TestFixedPlanHasNoResets) { ASSERT_TRUE(plan.resets_.empty()); } +TEST(XccyPricingTest, TestFixedPlanLeavesUnconfiguredRateFixingMetadataEmpty) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED); + config.domesticRateFixing_ = FixingIdentity_(); + config.foreignRateFixing_ = FixingIdentity_(); + + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 1, 4), config); + + ASSERT_FALSE(plan.domesticPeriods_.empty()); + ASSERT_FALSE(plan.foreignPeriods_.empty()); + for (const auto& period : plan.domesticPeriods_) { + ASSERT_TRUE(period.rateIndexName_.empty()); + ASSERT_FALSE(period.rateFixingTime_.IsValid()); + } + for (const auto& period : plan.foreignPeriods_) { + ASSERT_TRUE(period.rateIndexName_.empty()); + ASSERT_FALSE(period.rateFixingTime_.IsValid()); + } + + const PricingMarket_ curves; + const MarketFixingSnapshot_ fixings; + ASSERT_TRUE(std::isfinite(PriceXccyParSpread(plan, curves.View(DateTime_(Date_(2024, 1, 4))), fixings))); +} + TEST(XccyPricingTest, TestPlanPreservesShortFinalStub) { const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 2, 15), MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET)); From e522aab8ce3e084a7d0dce5362c0703e9baa8109 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 04:55:26 +0800 Subject: [PATCH 18/38] perf(xccy): benchmark reset pricing and joint calibration --- dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp | 390 ++++++++++++++++++--- 1 file changed, 335 insertions(+), 55 deletions(-) diff --git a/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp b/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp index ff3363399..10d32d98c 100644 --- a/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp +++ b/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp @@ -11,8 +11,11 @@ #include #include #include +#include +#include #include #include +#include #include #include #include @@ -36,10 +39,26 @@ namespace { struct Fixture_ { Date_ today_; + DateTime_ valuationTime_; Handle_ domesticBlock_; Handle_ foreignBlock_; Vector_ basisKnots_; CrossCurrencyConvention_ convention_; + Handle_ fixings_; + }; + + struct PricingCase_ { + std::string label_; + SwapHandle_ swap_; + RateHandle_ rate_; + std::vector basket_; + CrossCurrencyMarket_ market_; + }; + + struct JointCurrencyFixture_ { + JointCurrencyCurveSpec_ spec_; + Handle_ market_; + RateIndexConvention_ index_; }; Bench::Result_ Normalize(const Bench::Result_& raw, const std::string& name, int64_t divisor) { @@ -60,11 +79,16 @@ namespace { return Handle_(new CurveBlock_(MakeFlatCurve(name, ccy, today, rate))); } - RateIndexConvention_ MakeIndex() { + Handle_ MakePwcBlock(const String_& name, const Ccy_& ccy, const Vector_& knots, const Vector_<>& parameters) { + const Handle_ curve(NewDiscountPWC(name, ccy.String(), PiecewiseConstant_(knots, parameters))); + return Handle_(new CurveBlock_(curve)); + } + + RateIndexConvention_ MakeIndex(bool useProjectionCurve = true) { RateIndexConvention_ result; result.spotLag_ = 0; result.fixingLag_ = 0; - result.useProjectionCurve_ = true; + result.useProjectionCurve_ = useProjectionCurve; result.forecastTenor_ = PeriodLength_("3M"); result.dayBasis_ = DayBasis_("ACT_365F"); result.businessDayConvention_ = BizDayConvention_("Unadjusted"); @@ -89,6 +113,9 @@ namespace { Fixture_ MakeFixture() { Fixture_ result; result.today_ = Date_(2024, 1, 15); + // Midnight keeps same-day coupon/reset fixings forward-looking for the future-start cases, + // matching the legacy benchmark's date-only valuation context. + result.valuationTime_ = DateTime_(result.today_); result.domesticBlock_ = MakeBlock("xccy_perf_usd", "USD", result.today_, 0.02); result.foreignBlock_ = MakeBlock("xccy_perf_eur", "EUR", result.today_, 0.01); result.basisKnots_ = { @@ -102,10 +129,31 @@ namespace { result.convention_.domesticLeg_ = MakeLeg(); result.convention_.foreignIndex_ = MakeIndex(); result.convention_.foreignLeg_ = MakeLeg(); + result.fixings_ = Handle_(new MarketFixingSnapshot_()); + return result; + } + + CrossCurrencySwapConfig_ MakeConfig(const Fixture_& fixture, XccyNotionalMode_ mode) { + CrossCurrencySwapConfig_ result; + result.pair_ = CurrencyPair_(Ccy_("USD"), Ccy_("EUR")); + result.domesticNotional_ = 110.0; + result.foreignNotional_ = 100.0; + result.convention_ = fixture.convention_; + result.notionalMode_ = mode; + result.fxReset_.fixingLag_ = 0; + result.fxReset_.fixingHolidays_ = Holidays::None(); + result.fxReset_.fixingConvention_ = BizDayConvention_("Unadjusted"); + result.fxReset_.fixingHour_ = 11; + result.fxReset_.fixingMinute_ = 0; + result.domesticRateFixing_ = {"USD-XCCY-PERF-3M", 11, 0}; + result.foreignRateFixing_ = {"EUR-XCCY-PERF-3M", 11, 0}; return result; } CrossCurrencyMarket_ MakeMarket(const Fixture_& fixture, double basisRate) { + // Keep the legacy fixed/basis-only benchmark context byte-for-byte comparable with the + // original xccy_perf baseline. Reset-aware cases are future-looking at this date-only + // valuation; only the started MTM case below needs an explicit intraday snapshot. CrossCurrencyMarket_ result(fixture.domesticBlock_, fixture.foreignBlock_, kFxSpot); if (basisRate != 0.0) { const Vector_<> values(fixture.basisKnots_.size(), basisRate); @@ -114,16 +162,22 @@ namespace { return result; } - SwapHandle_ MakeSwap(const Fixture_& fixture, int maturityMonths, double marketRate = 0.0) { + SwapHandle_ MakeFixedSwap(const Fixture_& fixture, int maturityMonths, double marketRate = 0.0) { return SwapHandle_(new CrossCurrencySwap_(fixture.today_, fixture.today_, Date::AddMonths(fixture.today_, maturityMonths), marketRate, CurrencyPair_(Ccy_("USD"), Ccy_("EUR")), 110.0, 100.0, fixture.convention_)); } - std::vector MakePricingBasket(const Fixture_& fixture) { + SwapHandle_ MakeResetSwap(const Fixture_& fixture, int maturityMonths, XccyNotionalMode_ mode, double marketRate = 0.0) { + return SwapHandle_(new CrossCurrencySwap_(fixture.today_, fixture.today_, Date::AddMonths(fixture.today_, maturityMonths), marketRate, + MakeConfig(fixture, mode))); + } + + std::vector MakePricingBasket(const Fixture_& fixture, XccyNotionalMode_ mode) { std::vector result; result.reserve(10); - for (int year = 1; year <= 10; ++year) - result.push_back(MakeSwap(fixture, 12 * year)); + for (int year = 1; year <= 10; ++year) { + result.push_back(mode == XccyNotionalMode_::Value_::FIXED ? MakeFixedSwap(fixture, 12 * year) : MakeResetSwap(fixture, 12 * year, mode)); + } return result; } @@ -135,6 +189,39 @@ namespace { return result; } + PricingCase_ MakePricingCase(const Fixture_& fixture, const std::string& label, XccyNotionalMode_ mode) { + PricingCase_ result{label, mode == XccyNotionalMode_::Value_::FIXED ? MakeFixedSwap(fixture, 120) : MakeResetSwap(fixture, 120, mode), + RateHandle_(), std::vector(), MakeMarket(fixture, 0.0020)}; + result.rate_ = result.swap_->Precompute(); + result.basket_ = PrecomputeAll(MakePricingBasket(fixture, mode)); + return result; + } + + PricingCase_ MakeInProgressMtmCase(const Fixture_& fixture) { + const DateTime_ valuationTime(fixture.today_, 12, 0); + const Date_ start = Date::AddMonths(fixture.today_, -3); + const Date_ maturity = Date::AddMonths(start, 120); + const CrossCurrencySwapConfig_ config = MakeConfig(fixture, XccyNotionalMode_::Value_::MARK_TO_MARKET); + const SwapHandle_ swap(new CrossCurrencySwap_(start, start, maturity, 0.0, config)); + const XccyCashflowPlan_ plan = BuildXccyCashflowPlan(start, maturity, config); + + MarketFixingSnapshot_::values_t values; + for (const auto& request : RequiredHistoricalFixings(plan, valuationTime)) { + if (request.indexName_ == config.domesticRateFixing_.indexName_) + values[request.indexName_][request.fixingTime_] = 0.04; + else if (request.indexName_ == config.foreignRateFixing_.indexName_) + values[request.indexName_][request.fixingTime_] = 0.03; + else + values[request.indexName_][request.fixingTime_] = 1.20; + } + const Handle_ fixings(new MarketFixingSnapshot_(values)); + CrossCurrencyMarket_ market(fixture.domesticBlock_, fixture.foreignBlock_, kFxSpot, valuationTime, Ccy_("USD"), fixings); + const Vector_<> basisValues(fixture.basisKnots_.size(), 0.0020); + market.SetBasisCurve( + Handle_(NewDiscountPWC("xccy_perf_started_basis", "USD", PiecewiseConstant_(fixture.basisKnots_, basisValues)))); + return PricingCase_{"in-progress MTM", swap, swap->Precompute(), std::vector(), market}; + } + CrossCurrencyCalibrationSpec_ MakeCalibrationSpec(const Fixture_& fixture) { const CrossCurrencyMarket_ quoteMarket = MakeMarket(fixture, 0.0020); const Vector_ maturities = {6, 12, 18, 24, 30, 36, 42, 48, 54, 60, 72, 84, 96, 108, 120}; @@ -151,91 +238,260 @@ namespace { result.initialGuess_ = 0.0; result.instruments_.reserve(maturities.size()); for (const int maturity : maturities) { - const SwapHandle_ prototype = MakeSwap(fixture, maturity); + const SwapHandle_ prototype = MakeFixedSwap(fixture, maturity); const double quote = (*prototype->Precompute())(quoteMarket); - result.instruments_.push_back(MakeSwap(fixture, maturity, quote)); + result.instruments_.push_back(MakeFixedSwap(fixture, maturity, quote)); } return result; } - void ValidatePricing(const RateHandle_& fixedRate, const std::vector& basket, const CrossCurrencyMarket_& market) { - const double fixedValue = (*fixedRate)(market); - REQUIRE(std::isfinite(fixedValue) && std::fabs(fixedValue) > 0.0, "Fixed XCCY benchmark requires a finite, non-zero 10Y price"); - REQUIRE(basket.size() == 10, "Fixed XCCY benchmark requires exactly ten basket instruments"); + Handle_ QuotedDeposit(const Date_& today, const Date_& maturity, const RateIndexConvention_& index, const CurveBlock_& market) { + const Handle_ prototype(new Deposit_(today, today, maturity, 0.0, index)); + const double quote = (*prototype->Precompute(Handle_()))(market); + return Handle_(new Deposit_(today, today, maturity, quote, index)); + } + + JointCurrencyFixture_ MakeJointCurrency( + const Date_& today, const Ccy_& ccy, const Vector_& knots, const Vector_& maturities, const Vector_<>& parameters) { + JointCurrencyFixture_ result; + result.index_ = MakeIndex(false); + result.market_ = MakePwcBlock(String_("xccy_perf_true_") + ccy.String(), ccy, knots, parameters); + + JointCurveDeclaration_ declaration; + declaration.curveName_ = String_("xccy_perf_") + ccy.String() + "_ois"; + declaration.knotDates_ = knots; + declaration.targetCollateral_ = CollateralType_(CollateralType_::Value_::OIS); + declaration.calibrateDiscountCurve_ = true; + declaration.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + declaration.smoothingWeight_ = 1.0; + for (const auto& maturity : maturities) + declaration.instruments_.push_back(QuotedDeposit(today, maturity, result.index_, *result.market_)); + + result.spec_.ccy_ = ccy; + result.spec_.liborBasis_ = DayBasis_("ACT_365F"); + result.spec_.curves_ = {declaration}; + return result; + } + + CrossCurrencySwapConfig_ MakeJointXccyConfig(const CurrencyPair_& pair, + const RateIndexConvention_& domesticIndex, + const RateIndexConvention_& foreignIndex, + XccyNotionalMode_ mode) { + CrossCurrencySwapConfig_ result; + result.pair_ = pair; + result.domesticNotional_ = 110.0; + result.foreignNotional_ = 100.0; + result.notionalMode_ = mode; + result.convention_.initialNotionalExchange_ = true; + result.convention_.finalNotionalExchange_ = true; + result.convention_.spreadOnForeignLeg_ = true; + result.convention_.domesticIndex_ = domesticIndex; + result.convention_.foreignIndex_ = foreignIndex; + result.convention_.domesticLeg_ = MakeLeg(); + result.convention_.foreignLeg_ = MakeLeg(); + result.fxReset_.fixingLag_ = 0; + result.fxReset_.fixingHolidays_ = Holidays::None(); + result.fxReset_.fixingConvention_ = BizDayConvention_("Unadjusted"); + result.fxReset_.fixingHour_ = 11; + result.fxReset_.fixingMinute_ = 0; + result.domesticRateFixing_ = {"USD-XCCY-JOINT-PERF", 11, 0}; + result.foreignRateFixing_ = {"EUR-XCCY-JOINT-PERF", 11, 0}; + return result; + } + + JointXccyCalibrationSpec_ MakeJointCalibrationSpec(const Fixture_& fixture) { + const Vector_ knots = { + Date::AddMonths(fixture.today_, 6), Date::AddMonths(fixture.today_, 18), Date::AddMonths(fixture.today_, 36), + Date::AddMonths(fixture.today_, 60), Date::AddMonths(fixture.today_, 120), + }; + const Vector_ ycMaturities = { + Date::AddMonths(fixture.today_, 12), Date::AddMonths(fixture.today_, 24), Date::AddMonths(fixture.today_, 48), + Date::AddMonths(fixture.today_, 72), Date::AddMonths(fixture.today_, 120), + }; + const JointCurrencyFixture_ domestic = + MakeJointCurrency(fixture.today_, Ccy_("USD"), knots, ycMaturities, {0.015, 0.016, 0.017, 0.018, 0.019}); + const JointCurrencyFixture_ foreign = + MakeJointCurrency(fixture.today_, Ccy_("EUR"), knots, ycMaturities, {0.010, 0.011, 0.012, 0.013, 0.014}); + const Vector_<> basisParameters = {0.0010, 0.0014, 0.0018, 0.0022, 0.0026}; + const CurrencyPair_ pair(Ccy_("USD"), Ccy_("EUR")); + CrossCurrencyMarket_ quoteMarket(domestic.market_, foreign.market_, kFxSpot, fixture.valuationTime_, pair.domestic_, fixture.fixings_); + quoteMarket.SetBasisCurve(Handle_( + NewDiscountPWC("xccy_perf_true_joint_basis", pair.domestic_.String(), PiecewiseConstant_(knots, basisParameters)))); + + JointXccyCalibrationSpec_ result; + result.valuationTime_ = fixture.valuationTime_; + result.pair_ = pair; + result.collateralCurrency_ = pair.domestic_; + result.fxSpot_ = kFxSpot; + result.domestic_ = domestic.spec_; + result.foreign_ = foreign.spec_; + result.basis_.curveName_ = "xccy_perf_joint_basis"; + result.basis_.knotDates_ = knots; + result.basis_.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + result.basis_.smoothingWeight_ = 1.0; + result.fixings_ = fixture.fixings_; + result.tolerance_ = 1.0e-8; + result.fitTolerance_ = 1.0e-6; + result.initialGuess_ = 0.005; + result.maxEvaluations_ = 500; + result.maxRestarts_ = 40; + result.solveMode_ = CurveSolveMode_::Value_::EXACT; + + const Vector_ xccyMaturities = {6, 12, 18, 24, 30, 36, 42, 48, 54, 60, 72, 84, 96, 108, 120}; + for (int i = 0; i < static_cast(xccyMaturities.size()); ++i) { + const XccyNotionalMode_ mode = i % 2 == 0 ? XccyNotionalMode_::Value_::RESETTABLE : XccyNotionalMode_::Value_::MARK_TO_MARKET; + const CrossCurrencySwapConfig_ config = MakeJointXccyConfig(pair, domestic.index_, foreign.index_, mode); + const Date_ maturity = Date::AddMonths(fixture.today_, xccyMaturities[i]); + const CrossCurrencySwap_ prototype(fixture.today_, fixture.today_, maturity, 0.0, config); + result.basis_.instruments_.push_back(Handle_( + new CrossCurrencySwap_(fixture.today_, fixture.today_, maturity, (*prototype.Precompute())(quoteMarket), config))); + } + return result; + } + + CrossCurrencyCalibrationOptions_ BasisOptions(CurveJacobianMode_::Value_ mode, bool diagnostics) { + CrossCurrencyCalibrationOptions_ result; + result.jacobianMode_ = CurveJacobianMode_(mode); + result.computeEffJacobianInverse_ = diagnostics; + result.computeForwardJacobian_ = diagnostics; + return result; + } + + JointXccyCalibrationOptions_ JointOptions(CurveJacobianMode_::Value_ mode, bool diagnostics) { + JointXccyCalibrationOptions_ result; + result.jacobianMode_ = CurveJacobianMode_(mode); + result.computeEffJacobianInverse_ = diagnostics; + result.computeForwardJacobian_ = diagnostics; + return result; + } + + void ValidatePricing(const PricingCase_& pricing) { + const double value = (*pricing.rate_)(pricing.market_); + REQUIRE(std::isfinite(value) && std::fabs(value) > 0.0, "XCCY benchmark requires a finite, non-zero 10Y price"); + if (pricing.basket_.empty()) + return; + REQUIRE(pricing.basket_.size() == 10, "XCCY benchmark requires exactly ten basket instruments"); double checksum = 0.0; - for (const auto& rate : basket) { - const double value = (*rate)(market); - REQUIRE(std::isfinite(value), "Fixed XCCY basket requires finite prices"); - checksum += value; + for (const auto& rate : pricing.basket_) { + const double basketValue = (*rate)(pricing.market_); + REQUIRE(std::isfinite(basketValue), "XCCY basket requires finite prices"); + checksum += basketValue; } - REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "Fixed XCCY basket checksum must be finite and non-zero"); + REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "XCCY basket checksum must be finite and non-zero"); } - void ValidateCalibration(const CrossCurrencyCalibrationSpec_& spec) { - const auto result = CalibrateCrossCurrencyMarket(spec); - REQUIRE(result.diagnostics_.maxAbsResidual_ <= spec.tolerance_, + void ValidateBasisCalibration(const CrossCurrencyCalibrationSpec_& spec, const CrossCurrencyCalibrationOptions_& options) { + const auto result = CalibrateCrossCurrencyMarket(spec, options); + REQUIRE(std::isfinite(result.diagnostics_.maxAbsResidual_) && result.diagnostics_.maxAbsResidual_ <= spec.tolerance_, "Basis-only XCCY calibration benchmark must reprice inside its configured tolerance"); REQUIRE(result.diagnostics_.residuals_.size() == spec.instruments_.size(), "Basis-only XCCY calibration benchmark must report every instrument residual"); + REQUIRE(!result.fxForwardCurve_.forwards_.empty() && std::isfinite(result.fxForwardCurve_.forwards_.back()) && + std::fabs(result.fxForwardCurve_.forwards_.back()) > 0.0, + "Basis-only XCCY calibration benchmark requires a finite, non-zero dry-run checksum"); + } + + void ValidateJointCalibration(const JointXccyCalibrationSpec_& spec, const JointXccyCalibrationOptions_& options) { + const auto result = CalibrateJointXccyMarket(spec, options); + const double tolerance = spec.solveMode_ == CurveSolveMode_::Value_::EXACT ? spec.tolerance_ : spec.fitTolerance_; + REQUIRE(result.converged_, "Joint XCCY calibration benchmark must converge"); + REQUIRE(std::isfinite(result.jointMaxAbsResidual_) && result.jointMaxAbsResidual_ <= tolerance, + "Joint XCCY calibration benchmark must reprice inside its configured tolerance"); + for (const auto& diagnostics : result.domesticDiagnostics_) + REQUIRE(diagnostics.maxAbsResidual_ <= tolerance, "Joint XCCY domestic residual exceeds its configured tolerance"); + for (const auto& diagnostics : result.foreignDiagnostics_) + REQUIRE(diagnostics.maxAbsResidual_ <= tolerance, "Joint XCCY foreign residual exceeds its configured tolerance"); + REQUIRE(result.xccyDiagnostics_.maxAbsResidual_ <= tolerance, "Joint XCCY basis residual exceeds its configured tolerance"); + REQUIRE(!result.fxForwardCurve_.forwards_.empty() && std::isfinite(result.fxForwardCurve_.forwards_.back()) && + std::fabs(result.fxForwardCurve_.forwards_.back()) > 0.0 && result.solverEvaluations_ > 0, + "Joint XCCY calibration benchmark requires a finite, non-zero dry-run checksum"); } - void RunPrecompute(const SwapHandle_& fixedSwap) { - RateHandle_ sink = fixedSwap->Precompute(); + void RunPrecompute(const PricingCase_& pricing) { + RateHandle_ sink = pricing.rate_; + const std::string rawName = "XCCY " + pricing.label_ + " 10Y PRECOMPUTE"; const auto raw = Bench::Run( - "XCCY fixed 10Y PRECOMPUTE", + rawName, [&]() { for (int i = 0; i < kPrecomputeBatch; ++i) - sink = fixedSwap->Precompute(); + sink = pricing.swap_->Precompute(); }, kWarmups, kRepeats); Bench::DoNotOptimize(sink.get()); - REQUIRE(sink, "Fixed XCCY PRECOMPUTE sink must retain a rate handle"); - Bench::Print(Normalize(raw, "XCCY fixed 10Y PRECOMPUTE / operation", kPrecomputeBatch)); + REQUIRE(sink, "XCCY PRECOMPUTE sink must retain a rate handle"); + Bench::Print(Normalize(raw, rawName + " / operation", kPrecomputeBatch)); } - void RunPrice(const RateHandle_& fixedRate, const CrossCurrencyMarket_& market) { + void RunPrice(const PricingCase_& pricing) { double checksum = 0.0; + const std::string rawName = "XCCY " + pricing.label_ + " 10Y PRICE"; const auto raw = Bench::Run( - "XCCY fixed 10Y PRICE", + rawName, [&]() { for (int i = 0; i < kPriceBatch; ++i) - checksum += (*fixedRate)(market); + checksum += (*pricing.rate_)(pricing.market_); }, kWarmups, kRepeats); Bench::DoNotOptimize(&checksum); - REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "Fixed XCCY PRICE checksum must be finite and non-zero"); - Bench::Print(Normalize(raw, "XCCY fixed 10Y PRICE / operation", kPriceBatch)); + REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "XCCY PRICE checksum must be finite and non-zero"); + Bench::Print(Normalize(raw, rawName + " / operation", kPriceBatch)); } - void RunBasket(const std::vector& basket, const CrossCurrencyMarket_& market) { + void RunBasket(const PricingCase_& pricing) { + if (pricing.basket_.empty()) + return; double checksum = 0.0; + const std::string rawName = "XCCY " + pricing.label_ + " 10-instrument basket"; const auto raw = Bench::Run( - "XCCY fixed 10-instrument basket", + rawName, [&]() { for (int pass = 0; pass < kBasketPasses; ++pass) - for (const auto& rate : basket) - checksum += (*rate)(market); + for (const auto& rate : pricing.basket_) + checksum += (*rate)(pricing.market_); }, kWarmups, kRepeats); Bench::DoNotOptimize(&checksum); - REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "Fixed XCCY BASKET checksum must be finite and non-zero"); - Bench::Print(Normalize(raw, "XCCY fixed 10-instrument BASKET / pass", kBasketPasses)); - Bench::Print(Normalize(raw, "XCCY fixed 10-instrument PER-INSTRUMENT", kBasketPasses * static_cast(basket.size()))); + REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "XCCY BASKET checksum must be finite and non-zero"); + Bench::Print(Normalize(raw, "XCCY " + pricing.label_ + " 10-instrument BASKET / pass", kBasketPasses)); + Bench::Print( + Normalize(raw, "XCCY " + pricing.label_ + " 10-instrument PER-INSTRUMENT", kBasketPasses * static_cast(pricing.basket_.size()))); + } + + void RunPricing(const PricingCase_& pricing) { + RunPrecompute(pricing); + RunPrice(pricing); + RunBasket(pricing); } - void RunCalibration(const CrossCurrencyCalibrationSpec_& spec) { + void RunBasisCalibration(const char* name, const CrossCurrencyCalibrationSpec_& spec, const CrossCurrencyCalibrationOptions_& options) { double checksum = 0.0; - const auto result = Bench::Run( - "XCCY basis-only CALIBRATION (15 instruments, 5 knots)", + const auto timing = Bench::Run( + name, [&]() { - const auto calibration = CalibrateCrossCurrencyMarket(spec); + const auto calibration = CalibrateCrossCurrencyMarket(spec, options); checksum += calibration.diagnostics_.maxAbsResidual_; checksum += calibration.fxForwardCurve_.forwards_.back(); }, - 1, kRepeats); + kWarmups, kRepeats); Bench::DoNotOptimize(&checksum); REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "Basis-only XCCY CALIBRATION checksum must be finite and non-zero"); - Bench::Print(result); + Bench::Print(timing); + } + + void RunJointCalibration(const char* name, const JointXccyCalibrationSpec_& spec, const JointXccyCalibrationOptions_& options) { + double checksum = 0.0; + const auto timing = Bench::Run( + name, + [&]() { + const auto calibration = CalibrateJointXccyMarket(spec, options); + checksum += calibration.jointMaxAbsResidual_; + checksum += calibration.fxForwardCurve_.forwards_.back(); + checksum += static_cast(calibration.solverEvaluations_); + }, + kWarmups, kRepeats); + Bench::DoNotOptimize(&checksum); + REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "Joint XCCY CALIBRATION checksum must be finite and non-zero"); + Bench::Print(timing); } } // namespace @@ -244,19 +500,43 @@ int main() { const Fixture_ fixture = MakeFixture(); XGLOBAL::SetEvaluationDate(fixture.today_); - const CrossCurrencyMarket_ quoteMarket = MakeMarket(fixture, 0.0020); - const SwapHandle_ fixedSwap = MakeSwap(fixture, 120); - const RateHandle_ fixedRate = fixedSwap->Precompute(); - const std::vector basket = PrecomputeAll(MakePricingBasket(fixture)); - const CrossCurrencyCalibrationSpec_ calibrationSpec = MakeCalibrationSpec(fixture); + const std::vector pricingCases = { + MakePricingCase(fixture, "fixed", XccyNotionalMode_::Value_::FIXED), + MakePricingCase(fixture, "resettable", XccyNotionalMode_::Value_::RESETTABLE), + MakePricingCase(fixture, "MTM", XccyNotionalMode_::Value_::MARK_TO_MARKET), + MakeInProgressMtmCase(fixture), + }; + const CrossCurrencyCalibrationSpec_ basisSpec = MakeCalibrationSpec(fixture); + const CrossCurrencyCalibrationOptions_ basisAnalyticSolve = BasisOptions(CurveJacobianMode_::Value_::ANALYTIC, false); + const CrossCurrencyCalibrationOptions_ basisAnalyticDiagnostics = BasisOptions(CurveJacobianMode_::Value_::ANALYTIC, true); + const CrossCurrencyCalibrationOptions_ basisBumpedDiagnostics = BasisOptions(CurveJacobianMode_::Value_::BUMPED, true); + + const JointXccyCalibrationSpec_ jointSpec = MakeJointCalibrationSpec(fixture); + const JointXccyCalibrationOptions_ jointAnalyticSolve = JointOptions(CurveJacobianMode_::Value_::ANALYTIC, false); + const JointXccyCalibrationOptions_ jointAnalyticDiagnostics = JointOptions(CurveJacobianMode_::Value_::ANALYTIC, true); + const JointXccyCalibrationOptions_ jointBumpedDiagnostics = JointOptions(CurveJacobianMode_::Value_::BUMPED, true); + JointXccyCalibrationSpec_ jointApproximateSpec = jointSpec; + jointApproximateSpec.solveMode_ = CurveSolveMode_::Value_::APPROXIMATE; - ValidatePricing(fixedRate, basket, quoteMarket); - ValidateCalibration(calibrationSpec); + for (const auto& pricing : pricingCases) + ValidatePricing(pricing); + ValidateBasisCalibration(basisSpec, basisAnalyticSolve); + ValidateBasisCalibration(basisSpec, basisAnalyticDiagnostics); + ValidateBasisCalibration(basisSpec, basisBumpedDiagnostics); + ValidateJointCalibration(jointSpec, jointAnalyticSolve); + ValidateJointCalibration(jointSpec, jointAnalyticDiagnostics); + ValidateJointCalibration(jointSpec, jointBumpedDiagnostics); + ValidateJointCalibration(jointApproximateSpec, jointAnalyticDiagnostics); Bench::PrintHeader(); - RunPrecompute(fixedSwap); - RunPrice(fixedRate, quoteMarket); - RunBasket(basket, quoteMarket); - RunCalibration(calibrationSpec); + for (const auto& pricing : pricingCases) + RunPricing(pricing); + RunBasisCalibration("XCCY basis-only CALIBRATION (15 instruments, 5 knots)", basisSpec, basisAnalyticDiagnostics); + RunBasisCalibration("XCCY basis-only ANALYTIC SOLVE (15 instruments, 5 knots)", basisSpec, basisAnalyticSolve); + RunBasisCalibration("XCCY basis-only BUMPED +DIAG (15 instruments, 5 knots)", basisSpec, basisBumpedDiagnostics); + RunJointCalibration("XCCY joint ANALYTIC SOLVE (15 XCCY, 3x5 knots)", jointSpec, jointAnalyticSolve); + RunJointCalibration("XCCY joint ANALYTIC +DIAG (15 XCCY, 3x5 knots)", jointSpec, jointAnalyticDiagnostics); + RunJointCalibration("XCCY joint BUMPED +DIAG (15 XCCY, 3x5 knots)", jointSpec, jointBumpedDiagnostics); + RunJointCalibration("XCCY joint ANALYTIC APPROXIMATE (15 XCCY, 3x5 knots)", jointApproximateSpec, jointAnalyticDiagnostics); return 0; } From 9785e58753ab0e12e29650e7c76fa922350dad6e Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 05:23:27 +0800 Subject: [PATCH 19/38] docs(xccy): document MTM valuation and joint calibration --- CHANGELOG.md | 12 + dal-cpp/examples/CMakeLists.txt | 2 + .../xccy_mtm_calibration/CMakeLists.txt | 20 + .../xccy_mtm_calibration.cpp | 247 +++++++++++ dal-excel/README.md | 39 +- dal-python/README.md | 42 +- docs/README.md | 8 +- docs/methodology/xccy_calibration.md | 411 +++++++----------- docs/methodology/yield_curve_jacobian.md | 37 ++ docs/public-api.md | 72 ++- 10 files changed, 590 insertions(+), 300 deletions(-) create mode 100644 dal-cpp/examples/xccy_mtm_calibration/CMakeLists.txt create mode 100644 dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index d2c5bf41f..c59118844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,18 @@ here as the baseline rather than dated releases: ## 2026-07 +- `curve`: Added reset-aware cross-currency pricing and simultaneous domestic, + foreign, and basis calibration. `XccyNotionalMode_` now defines `FIXED`, + `RESETTABLE`, and `MARK_TO_MARKET`; explicit rate/FX fixing identities and one + immutable timestamped snapshot support already-started swaps; and the joint + solver exposes named parameter/residual ranges plus analytic or bumped + Jacobians through public C++, Python, and Excel. **Breaking:** the two + `CrossCurrencyConvention_` booleans `resettableNotional_` and + `markToMarketNotional_` are replaced by the enum in + `CrossCurrencySwapConfig_`. The legacy fixed-notional convenience constructor + and builder remain compatible. See `docs/methodology/xccy_calibration.md`, + `docs/methodology/yield_curve_jacobian.md`, and `docs/public-api.md`. + - `curve`: Added persistent continuously compounded `ZERO_RATE` curves. Future-node rates map to `logDF = -z * YearFrac(anchor,node)` and reuse all shared log-DF interpolation/extrapolation schemes; the anchor has no free zero-rate parameter. diff --git a/dal-cpp/examples/CMakeLists.txt b/dal-cpp/examples/CMakeLists.txt index 8a4834fd3..a2da371a8 100644 --- a/dal-cpp/examples/CMakeLists.txt +++ b/dal-cpp/examples/CMakeLists.txt @@ -6,6 +6,7 @@ add_subdirectory(euribor3m_curve) add_subdirectory(interpolate_curve) add_subdirectory(joint_multi_curve_calibration) add_subdirectory(xccy_curve_calibration) +add_subdirectory(xccy_mtm_calibration) add_subdirectory(european_mc) add_subdirectory(european_fd) add_subdirectory(snowball) @@ -31,6 +32,7 @@ set(DAL_EXAMPLE_TARGETS interpolate_curve joint_multi_curve_calibration xccy_curve_calibration + xccy_mtm_calibration european_mc european_fd snowball diff --git a/dal-cpp/examples/xccy_mtm_calibration/CMakeLists.txt b/dal-cpp/examples/xccy_mtm_calibration/CMakeLists.txt new file mode 100644 index 000000000..8dbc47155 --- /dev/null +++ b/dal-cpp/examples/xccy_mtm_calibration/CMakeLists.txt @@ -0,0 +1,20 @@ +file(GLOB_RECURSE XCCY_MTM_CALIBRATION_FILES CONFIGURE_DEPENDS "*.hpp" "*.cpp") +add_executable(xccy_mtm_calibration ${XCCY_MTM_CALIBRATION_FILES}) + +target_link_libraries(xccy_mtm_calibration dal_library) + +if(DAL_USE_XAD_AAD) + target_link_libraries(xccy_mtm_calibration XAD::xad) +elseif(DAL_USE_CODIPACK_AAD) + target_link_libraries(xccy_mtm_calibration CoDiPack) +elseif(DAL_USE_ADEPT_AAD) + target_link_libraries(xccy_mtm_calibration adept) +endif() + +if(NOT MSVC) + target_link_libraries(xccy_mtm_calibration pthread) +endif() + +install(TARGETS xccy_mtm_calibration + RUNTIME DESTINATION bin + PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE) diff --git a/dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp b/dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp new file mode 100644 index 000000000..0a2287c77 --- /dev/null +++ b/dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp @@ -0,0 +1,247 @@ +// +// Created by Codex on 2026/7/14. +// + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Dal; + +namespace { + const CollateralType_ OIS(CollateralType_::Value_::OIS); + const PeriodLength_ THREE_MONTHS("3M"); + + struct CurrencyFixture_ { + JointCurrencyCurveSpec_ declaration_; + Handle_ market_; + RateIndexConvention_ discountIndex_; + RateIndexConvention_ forwardIndex_; + Vector_<> parameters_; + }; + + Handle_ Pwc(const String_& name, const Ccy_& ccy, const Vector_& knots, const Vector_<>& values) { + return Handle_(NewDiscountPWC(name, ccy.String(), PiecewiseConstant_(knots, values))); + } + + RateIndexConvention_ Index(bool projection) { + RateIndexConvention_ result; + result.useProjectionCurve_ = projection; + result.forecastTenor_ = THREE_MONTHS; + result.dayBasis_ = DayBasis_("ACT_365F"); + result.collateral_ = OIS; + result.fixingHolidays_ = Holidays::None(); + result.accrualHolidays_ = Holidays::None(); + return result; + } + + Handle_ QuotedDeposit(const Date_& today, const Date_& maturity, const RateIndexConvention_& index, const CurveBlock_& market) { + const Handle_ prototype(new Deposit_(today, today, maturity, 0.0, index)); + const double quote = (*prototype->Precompute(Handle_()))(market); + return Handle_(new Deposit_(today, today, maturity, quote, index)); + } + + CurrencyFixture_ MakeCurrency(const Date_& today, + const Ccy_& ccy, + const Vector_& knots, + const Vector_& maturities, + const Vector_<>& discountParameters, + const Vector_<>& forwardParameters) { + CurrencyFixture_ result; + result.discountIndex_ = Index(false); + result.forwardIndex_ = Index(true); + result.market_ = Handle_(new CurveBlock_( + String_(ccy.String()) + "_truth", ccy.String(), {{OIS, Pwc(String_(ccy.String()) + "_truth_ois", ccy, knots, discountParameters)}}, + {{THREE_MONTHS, Pwc(String_(ccy.String()) + "_truth_3m", ccy, knots, forwardParameters)}}, DayBasis_("ACT_365F"))); + + JointCurveDeclaration_ discount; + discount.curveName_ = String_(ccy.String()) + "_ois"; + discount.knotDates_ = knots; + discount.targetCollateral_ = OIS; + discount.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + JointCurveDeclaration_ forward; + forward.curveName_ = String_(ccy.String()) + "_3m"; + forward.knotDates_ = knots; + forward.targetCollateral_ = OIS; + forward.targetTenor_ = THREE_MONTHS; + forward.calibrateDiscountCurve_ = false; + forward.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + for (const auto& maturity : maturities) { + discount.instruments_.push_back(QuotedDeposit(today, maturity, result.discountIndex_, *result.market_)); + forward.instruments_.push_back(QuotedDeposit(today, maturity, result.forwardIndex_, *result.market_)); + } + result.declaration_.ccy_ = ccy; + result.declaration_.curves_ = {discount, forward}; + result.parameters_ = discountParameters; + result.parameters_.Append(forwardParameters); + return result; + } + + CrossCurrencySwapConfig_ Config(const CurrencyPair_& pair, + const RateIndexConvention_& domesticIndex, + const RateIndexConvention_& foreignIndex, + const FixingIdentity_& domesticRateFixing, + const FixingIdentity_& foreignRateFixing) { + RateLegConvention_ leg; + leg.paymentFrequency_ = THREE_MONTHS; + leg.dayBasis_ = DayBasis_("ACT_365F"); + leg.accrualHolidays_ = Holidays::None(); + leg.paymentHolidays_ = Holidays::None(); + + CrossCurrencySwapConfig_ result; + result.pair_ = pair; + result.domesticNotional_ = 110.0; + result.foreignNotional_ = 100.0; + result.convention_.initialNotionalExchange_ = true; + result.convention_.finalNotionalExchange_ = true; + result.convention_.spreadOnForeignLeg_ = true; + result.convention_.domesticIndex_ = domesticIndex; + result.convention_.domesticLeg_ = leg; + result.convention_.foreignIndex_ = foreignIndex; + result.convention_.foreignLeg_ = leg; + result.notionalMode_ = XccyNotionalMode_::Value_::MARK_TO_MARKET; + result.fxReset_.fixingLag_ = 0; + result.fxReset_.fixingHolidays_ = Holidays::None(); + result.fxReset_.fixingHour_ = 11; + result.fxReset_.fixingMinute_ = 0; + result.domesticRateFixing_ = domesticRateFixing; + result.foreignRateFixing_ = foreignRateFixing; + return result; + } + + XccyBasisCurveDeclaration_ MakeBasisDeclaration(const Date_& today, + const Date_& inProgressStart, + const Vector_& knots, + const Vector_& maturities, + const CrossCurrencySwapConfig_& config, + const CurrencyFixture_& domestic, + const CurrencyFixture_& foreign, + const Handle_& fixings, + const Vector_<>& basisParameters) { + CrossCurrencyMarket_ quoteMarket(domestic.market_, foreign.market_, 1.10, DateTime_(today, 9, 0), config.pair_.domestic_, fixings); + quoteMarket.SetBasisCurve(Pwc("truth_basis", config.pair_.domestic_, knots, basisParameters)); + + XccyBasisCurveDeclaration_ result; + result.curveName_ = "USD_EUR_basis"; + result.knotDates_ = knots; + result.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + for (int i = 0; i < static_cast(maturities.size()); ++i) { + const Date_ start = i == 0 ? inProgressStart : today; + const CrossCurrencySwap_ prototype(start, start, maturities[i], 0.0, config); + result.instruments_.push_back( + Handle_(new CrossCurrencySwap_(start, start, maturities[i], (*prototype.Precompute())(quoteMarket), config))); + } + return result; + } + + Vector_<> CurveParameters(const DiscountCurve_& curve) { + const auto* typed = dynamic_cast*>(&curve); + if (!typed) + return {}; + return typed->FRight(); + } + + Vector_<> RecoveredParameters(const JointXccyCalibrationResult_& result) { + Vector_<> parameters = CurveParameters(result.domesticCurveBlock_->Discount(OIS)); + parameters.Append(CurveParameters(result.domesticCurveBlock_->Forward(THREE_MONTHS, OIS))); + parameters.Append(CurveParameters(result.foreignCurveBlock_->Discount(OIS))); + parameters.Append(CurveParameters(result.foreignCurveBlock_->Forward(THREE_MONTHS, OIS))); + parameters.Append(CurveParameters(*result.basisCurve_)); + return parameters; + } + + double MaxDifference(const Vector_<>& actual, const Vector_<>& expected, int offset, int size) { + double result = 0.0; + for (int i = offset; i < offset + size; ++i) + result = std::max(result, std::abs(actual[i] - expected[i])); + return result; + } + + JointXccyCalibrationSpec_ JointSpec(const DateTime_& valuationTime, + const CurrencyPair_& pair, + const JointCurrencyCurveSpec_& domestic, + const JointCurrencyCurveSpec_& foreign, + const XccyBasisCurveDeclaration_& basis, + const Handle_& fixings) { + JointXccyCalibrationSpec_ result; + result.valuationTime_ = valuationTime; + result.pair_ = pair; + result.collateralCurrency_ = pair.domestic_; + result.fxSpot_ = 1.10; + result.domestic_ = domestic; + result.foreign_ = foreign; + result.basis_ = basis; + result.fixings_ = fixings; + result.tolerance_ = 1.0e-10; + result.initialGuess_ = 0.005; + return result; + } + + bool PrintAndValidate(const JointXccyCalibrationResult_& result, + const Vector_<>& domesticParameters, + const Vector_<>& foreignParameters, + const Vector_<>& basisParameters) { + Vector_<> truth = domesticParameters; + truth.Append(foreignParameters); + truth.Append(basisParameters); + const Vector_<> recovered = RecoveredParameters(result); + double maxParameterError = 0.0; + std::cout << std::scientific << std::setprecision(3) << "converged=" << std::boolalpha << result.converged_ + << " maxResidual=" << result.jointMaxAbsResidual_ << " jacobian=" << result.jacobianAtSolution_.Rows() << "x" + << result.jacobianAtSolution_.Cols() << '\n'; + for (int i = 0; i < static_cast(result.parameterRanges_.size()); ++i) { + const auto& parameterRange = result.parameterRanges_[i]; + const auto& residualRange = result.residualRanges_[i]; + const double blockError = MaxDifference(recovered, truth, parameterRange.offset_, parameterRange.size_); + maxParameterError = std::max(maxParameterError, blockError); + std::cout << parameterRange.name_ << " params=[" << parameterRange.offset_ << "," << parameterRange.offset_ + parameterRange.size_ + << ") residuals=[" << residualRange.offset_ << "," << residualRange.offset_ + residualRange.size_ + << ") recoveryError=" << blockError << '\n'; + } + return result.converged_ && result.jointMaxAbsResidual_ < 1.0e-8 && maxParameterError < 1.0e-8 && !result.jacobianAtSolution_.Empty() && + !result.parameterRanges_.empty(); + } +} // namespace + +int main() { + const Date_ today(2025, 1, 16); + const DateTime_ valuationTime(today, 9, 0); + const CurrencyPair_ pair(Ccy_("USD"), Ccy_("EUR")); + const FixingIdentity_ domesticRateFixing{"USD-JOINT-3M", 11, 0}; + const FixingIdentity_ foreignRateFixing{"EUR-JOINT-3M", 11, 0}; + const Date_ inProgressStart(2024, 10, 15); + const DateTime_ historicalFixing(Date::AddMonths(inProgressStart, 3), 11, 0); + MarketFixingSnapshot_::values_t observations; + observations[domesticRateFixing.indexName_][historicalFixing] = 0.040; + observations[foreignRateFixing.indexName_][historicalFixing] = 0.030; + observations[FxIndexName(pair)][historicalFixing] = 1.20; + const Handle_ fixings(new MarketFixingSnapshot_(observations)); + const Vector_ knots = {Date::AddMonths(today, 6), Date::AddMonths(today, 18), Date::AddMonths(today, 30), Date::AddMonths(today, 42), + Date::AddMonths(today, 54)}; + const Vector_ maturities = {Date::AddMonths(inProgressStart, 15), Date::AddMonths(today, 24), Date::AddMonths(today, 36), + Date::AddMonths(today, 48), Date::AddMonths(today, 60)}; + const CurrencyFixture_ domestic = + MakeCurrency(today, pair.domestic_, knots, maturities, {0.015, 0.016, 0.017, 0.018, 0.019}, {0.024, 0.025, 0.026, 0.027, 0.028}); + const CurrencyFixture_ foreign = + MakeCurrency(today, pair.foreign_, knots, maturities, {0.010, 0.011, 0.012, 0.013, 0.014}, {0.019, 0.020, 0.021, 0.022, 0.023}); + const CrossCurrencySwapConfig_ mtmConfig = Config(pair, domestic.forwardIndex_, foreign.forwardIndex_, domesticRateFixing, foreignRateFixing); + const Vector_<> basisParameters = {0.0010, 0.0014, 0.0018, 0.0022, 0.0026}; + const XccyBasisCurveDeclaration_ basis = + MakeBasisDeclaration(today, inProgressStart, knots, maturities, mtmConfig, domestic, foreign, fixings, basisParameters); + const JointXccyCalibrationSpec_ spec = JointSpec(valuationTime, pair, domestic.declaration_, foreign.declaration_, basis, fixings); + const JointXccyCalibrationResult_ result = CalibrateJointXccyMarket(spec); + return PrintAndValidate(result, domestic.parameters_, foreign.parameters_, basisParameters) ? 0 : 1; +} diff --git a/dal-excel/README.md b/dal-excel/README.md index 8a2b51a9c..2f0bba1be 100644 --- a/dal-excel/README.md +++ b/dal-excel/README.md @@ -31,9 +31,36 @@ worksheet calls: ``` Curve workflows use convention/instrument constructors followed by -`CALIBRATE.SINGLECURVE` or `CALIBRATE.XCCYMARKET`; result accessors return either -diagnostics or a curve handle. The [public API guide](../docs/public-api.md#excel) -lists the primary worksheet families. +`CALIBRATE.SINGLECURVE`, `CALIBRATE.XCCYMARKET`, or +`CALIBRATE.JOINTXCCY`; result accessors return diagnostics, matrices, ranges, or +curve handles. The [public API guide](../docs/public-api.md#excel) lists the +primary worksheet families. + +## Resettable and Joint XCCY Functions + +`XCCYRESETCONVENTION.NEW` creates the business-day and timestamp convention for +FX resets. `CROSSCURRENCYSWAPCONFIG.NEW` combines it with the currency pair, leg +and index conventions, `FIXED` / `RESETTABLE` / `MARK_TO_MARKET`, and explicit +domestic and foreign rate-fixing identities. Pass that handle to +`CROSSCURRENCYSWAP.CONFIG.NEW` to create the quoted instrument. + +`MARKETFIXINGSNAPSHOT.NEW` takes parallel index-name, fixing-time, and value +ranges and returns one immutable snapshot handle. The arrays must have equal +length, timestamps must be valid, and observations must be positive and finite. +The snapshot can contain both rate and canonical FX names such as +`FX[EUR/USD]`. + +`CALIBRATE.JOINTXCCY` performs one domestic/foreign/basis solve. Its result +supports dedicated handle getters: + +- `JOINTXCCYCALIBRATIONRESULT.GET.DOMESTICBLOCK` +- `JOINTXCCYCALIBRATIONRESULT.GET.FOREIGNBLOCK` +- `JOINTXCCYCALIBRATIONRESULT.GET.BASISCURVE` + +`JOINTXCCYCALIBRATIONRESULT.GET` returns matrix views selected by +`fxForwards`, `marketRates`, `modelRates`, `residuals`, `jacobian`, +`parameterRanges`, or `residualRanges`. The generated HTML under `auto/` is the +exact argument and settings-key reference. ## Layout and Generated Registration @@ -56,7 +83,9 @@ Do not hand-edit `auto/*.inc` or `auto/*.htm`. ## Runtime State Excel keeps storable objects in a host repository and passes them between cells -as handles. Evaluation date and fixings are process-wide DAL state. Worksheet -failures are returned as error text annotated with the failing argument. +as handles. The evaluation date and legacy fixing store are process-wide DAL +state; `MARKETFIXINGSNAPSHOT.NEW` instead creates an immutable repository-held +snapshot. Worksheet failures are returned as error text annotated with the failing +argument. DAL is distributed under the repository [MIT license](../LICENSE). diff --git a/dal-python/README.md b/dal-python/README.md index 5ae7c107a..484848aa9 100644 --- a/dal-python/README.md +++ b/dal-python/README.md @@ -8,7 +8,7 @@ Python bindings for the Derivatives Algorithms Library (DAL) — a high-performa - **Monte Carlo simulation** with pseudo-random and Sobol sequence generators - **AAD Greeks** — compute pathwise sensitivities (delta, vega, rho, etc.) in a single simulation - **Script engine** — define exotic payoffs using a domain-specific language -- **Curve calibration** — single-curve, multi-curve, and cross-currency calibration with forward, log-discount, and continuously compounded zero-rate curves plus AAD analytic Jacobians +- **Curve calibration** — single-curve, multi-curve, staged XCCY, and joint domestic/foreign/basis calibration with resettable and MTM instruments plus AAD analytic Jacobians - **Type-safe wrappers** for `Date_`, `Matrix_`, `Cell_`, and vector types ## Prerequisites @@ -364,9 +364,9 @@ curve-construction and calibration workflows: - **Instrument builders** — `Deposit_New`, `FRA_New`, `Future_New`, `Swap_New`, `OISSwap_New`, `BasisSwap_New`, `CrossCurrencySwap_New` - **Curve factories** — `DiscountPWLF_New`, `DiscountZeroRate_New` -- **Calibration entry points** — `CalibrateSingleCurve`, `CalibrateMultiCurveBundle`, `CalibrateXccyMarket` -- **Enums** — `CurveParameterization` (`PIECEWISE_LINEAR_FWD`, `PIECEWISE_CONSTANT_FWD`, `ZERO_RATE`, `LOG_DISCOUNT`), `CurveSolveMode` (`EXACT`, `APPROXIMATE`), `CurveJacobianMode` (`ANALYTIC`, `BUMPED`), `LogDfScheme` (`LOG_LINEAR`, `LOG_CUBIC_NATURAL`, `MIXED`) -- **Spec builder** — `CurveCalibrationSpecBuilder_` for assembling `CurveCalibrationSpec_` / `MultiCurveCalibrationSpec_` +- **Calibration entry points** — `CalibrateSingleCurve`, `CalibrateMultiCurveBundle`, `CalibrateXccyMarket`, `CalibrateJointXccyMarket` +- **Enums** — `CurveParameterization` (`PIECEWISE_LINEAR_FWD`, `PIECEWISE_CONSTANT_FWD`, `ZERO_RATE`, `LOG_DISCOUNT`), `CurveSolveMode` (`EXACT`, `APPROXIMATE`), `CurveJacobianMode` (`ANALYTIC`, `BUMPED`), `LogDfScheme` (`LOG_LINEAR`, `LOG_CUBIC_NATURAL`, `MIXED`), `XccyNotionalMode` (`FIXED`, `RESETTABLE`, `MARK_TO_MARKET`) +- **Spec builders** — `CurveCalibrationSpecBuilder_`, `CrossCurrencyCalibrationSpecBuilder_`, and `JointXccyCalibrationSpecBuilder_` The `dal.calibrate_curve(...)` helper in `api.py` wraps the common single-curve path with Python-friendly defaults. The underlying C++ methodology is documented in [docs/methodology/yield_curve.md](../docs/methodology/yield_curve.md) and [docs/methodology/yield_curve_jacobian.md](../docs/methodology/yield_curve_jacobian.md). @@ -419,9 +419,37 @@ result = dal.calibrate_curve( ) ``` -Python exposes single and staged multi-curve calibration, but not the core simultaneous -joint-calibration API. A base curve is multiplied into the calibrated component; it is -not a replacement for the pricing discount curve required by a forward-curve stage. +Python exposes single, staged multi-curve, staged XCCY basis, and simultaneous +joint XCCY calibration. A base curve is multiplied into the calibrated component; +it is not a replacement for the pricing discount curve required by a forward-curve stage. + +### Resettable and Joint XCCY Calibration + +Use `CrossCurrencySwapConfigBuilder_` to set the currency pair, notionals, leg +conventions, `notional_mode`, `fx_reset`, and explicit `domestic_rate_fixing` / +`foreign_rate_fixing` identities. `MarketFixingSnapshot_New` takes a nested +dictionary whose keys are index names and whose values map `DateTime_` objects to +observations. One immutable snapshot can hold domestic rate, foreign rate, and FX +fixings for an already-started swap: + +```python +snapshot = dal.MarketFixingSnapshot_New({ + "USD-JOINT-3M": {historical_fixing: 0.040}, + "EUR-JOINT-3M": {historical_fixing: 0.030}, + "FX[EUR/USD]": {historical_fixing: 1.20}, +}) +``` + +`JointCurrencyCurveSpec_` holds the ordered domestic or foreign +`JointCurveDeclaration_` objects. `XccyBasisCurveDeclaration_` holds configured +XCCY instruments and basis knots. Assemble those groups with +`JointXccyCalibrationSpecBuilder_`, then call +`CalibrateJointXccyMarket(builder.build())`. The result exposes the domestic and +foreign curve blocks, `fx_forward_curve`, basis curve, retained snapshot, group +diagnostics, full market/model/residual vectors, analytic Jacobian, effective +inverse, and named `parameter_ranges` / `residual_ranges`. Pass +`JointXccyCalibrationOptions_` to select `ANALYTIC` or `BUMPED` and to disable +either diagnostic matrix. ## Troubleshooting diff --git a/docs/README.md b/docs/README.md index d6f77344b..77310af99 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,10 +60,10 @@ Deep dives into the quantitative methods and algorithms implemented in DAL: - Application to yield curve calibration via smoothness penalties - **[xccy_calibration.md](methodology/xccy_calibration.md)** — Cross-Currency Calibration - - Cross-currency market and basis curve framework - - Cross-currency swap pricing and conventions - - Multi-instrument term structure calibration - - Integration with the underdetermined solver + - Fixed, resettable-notional, and mark-to-market cross-currency pricing + - Timestamped immutable rate and FX fixing snapshots + - Staged basis calibration and simultaneous domestic/foreign/basis calibration + - Joint parameter/residual ranges and analytic or bumped Jacobians - **[interpolation.md](methodology/interpolation.md)** — Interpolation - Linear, log-linear, cubic-spline, and mixed one-dimensional interpolators diff --git a/docs/methodology/xccy_calibration.md b/docs/methodology/xccy_calibration.md index b53a12f6f..e727c1503 100644 --- a/docs/methodology/xccy_calibration.md +++ b/docs/methodology/xccy_calibration.md @@ -1,291 +1,174 @@ -# Cross-Currency Calibration +# Cross-Currency Pricing and Calibration -This note describes the financial mathematics of cross-currency (XCCY) basis -calibration: the no-arbitrage relations that link two currencies, the basis curve -that absorbs their deviation, and how it is fitted to market quotes. The focus is -on the model, not its code. +DAL prices fixed-notional, resettable-notional, and mark-to-market (MTM) +cross-currency swaps, and calibrates their basis either after the two currency +markets are known or simultaneously with both currency curve blocks. This note +defines the current pricing, fixing, collateral, and calibration contracts. -## The Problem +## Market and FX-Forward Convention -Two single-currency curves — domestic and foreign — are each calibrated to their -own domestic instruments and discount future cashflows correctly *within* their -currency. But when cashflows are exchanged *across* currencies, the two curves and -the FX spot rate alone do not reprice traded cross-currency products: there is a -**cross-currency basis** that the market charges for funding one currency against -another. Calibration introduces a **basis curve** that, layered onto the domestic -discounting, restores consistency with quoted cross-currency swaps. +For a currency pair `(domestic, foreign)`, FX spot $S$ is quoted in domestic +currency per unit of foreign currency. With domestic and foreign discount +factors $P_d(t,T)$ and $P_f(t,T)$ and a domestic-currency basis discount factor +$P_b(t,T)$, the FX forward is -Inputs: - -- pre-calibrated domestic and foreign curves (discount + tenor forecast), -- the FX spot rate $S$ (units of domestic per unit of foreign), -- a set of cross-currency swap (XCS) market quotes (par basis spreads). - -Output: a piecewise-constant basis curve $b(\cdot)$ that reprices the swaps, chosen -smooth via regularisation. - -## Covered Interest Parity and the Basis +$$ +F(t,T)=S\frac{P_f(t,T)}{P_d(t,T)P_b(t,T)}. +$$ -Absent any basis, the no-arbitrage FX forward for delivery at $T$ is given by -**covered interest parity** — borrow domestic, convert at spot, invest foreign: +The current implementation supports domestic-currency collateral only. The +domestic and foreign discount curves used by an XCCY instrument come from the +collateral selectors on its two rate-index conventions. Projection-enabled +indices route to the matching tenor forward curves; non-projection indices use +their discount curves for forecasting. + +## Cashflow and Notional Modes + +Let $N_f$ be the constant foreign notional, $N_{d,0}$ the configured domestic +notional, and $X_i$ the observed or forward FX fixing for the reset effective +date of domestic coupon period $i$. DAL creates FX resets from the second +domestic period onward. The three `XccyNotionalMode_` values are: + +- `FIXED`: $N_{d,i}=N_{d,0}$ for every domestic period. +- `RESETTABLE`: $N_{d,0}$ applies to the first period and + $N_{d,i}=N_fX_i$ for $i\ge1$. +- `MARK_TO_MARKET`: notionals follow the same formula as `RESETTABLE`, and each + reset effective date exchanges the domestic notional change + $\Delta N_{d,i}=N_{d,i}-N_{d,i-1}$. Under DAL's domestic-leg PV sign + convention, the adjustment is $-\Delta N_{d,i}P_d(t,T_i)$. + +Coupon PVs use the period notional, accrual fraction, projected or observed +rate, and the payment-date discount factor. Foreign coupons and notional +exchanges are converted to domestic PV with $S P_f(t,T)/P_b(t,T)$. Initial +notional exchanges are included only when the leg start date is on or after the +valuation date. Final exchanges are included when maturity is on or after the +valuation date. + +A cashflow whose payment date is strictly before the valuation date is omitted. +A cashflow on the valuation date remains in the PV, regardless of the valuation +time. The same date rule applies to MTM notional adjustments: an adjustment is +omitted only when its effective date is strictly before the valuation date. + +The model quote is the par spread. If the spread is on the foreign leg, $$ -F(t_0,T) = S \,\frac{P_f(t_0,T)}{P_d(t_0,T)}, +s=\frac{\mathrm{PV}_d-\mathrm{PV}_f}{A_f}; $$ -where $P_d, P_f$ are domestic and foreign discount factors. Empirically the -observed forward deviates from this. The deviation is absorbed by a **basis -discount factor** $P_b$ applied multiplicatively to the domestic leg: +if it is on the domestic leg, $$ -F(t_0,T) = S \,\frac{P_f(t_0,T)}{P_d(t_0,T)\,P_b(t_0,T)}. +s=\frac{\mathrm{PV}_f-\mathrm{PV}_d}{A_d}. $$ -$P_b$ is generated by a piecewise-constant instantaneous basis rate, exactly as a -spread curve in the [single-currency framework](yield_curve.md): -$P_b(t_0,T) = \exp(-\tfrac{1}{365}\int_{t_0}^T b(t)\,dt)$. When $b \equiv 0$, -$P_b \equiv 1$ and parity is recovered. The basis curve is the object calibration -solves for. +Here $A_d$ and $A_f$ are the remaining positive spread annuities in domestic +PV units. Calibration rejects an instrument with no remaining annuity on its +quoted leg. -## Pricing a Cross-Currency Swap +## Reset and Fixing Timing -An XCS exchanges floating payments in two currencies (typically with initial and -final notional exchange). Each leg's present value is computed in its own currency -from its own forward and discount curves, then the foreign leg is converted to -domestic units. The conversion uses the **basis-adjusted** FX forwards above, so -the basis curve enters the foreign-leg PV. +`FxResetConvention_` defines a non-negative business-day fixing lag, fixing +holiday calendar, business-day convention, and fixing hour/minute. The reset +for domestic period $i\ge1$ is effective on that period's accrual start; its FX +fixing date is the lagged and adjusted effective date. `FixingIdentity_` gives +the index name and hour/minute for each domestic and foreign floating-rate +fixing. `FxIndexName(pair)` gives the FX fixing index name. -The traded quote is the **par basis spread** — the spread (conventionally on the -foreign leg) that makes the two legs' present values equal. With the spread on the -foreign leg, equating the converted PVs gives +At valuation time $v$: -$$ -\text{spread} = \frac{\mathrm{PV}_{\text{domestic}} - \mathrm{PV}_{\text{foreign}}^{\text{base}}}{A_{\text{foreign}\to\text{domestic}}}, -$$ +- a fixing time before $v$ must exist in the supplied snapshot; +- a fixing exactly at $v$ uses the snapshot value when present, otherwise the + active forward value; and +- a fixing after $v$ uses the active forward value. + +Only a historical fixing attached to a non-settled cashflow or reset is +required. Missing required observations fail with the index, timestamp, and +pricing context. + +## Immutable Market-Fixing Snapshot -where $\mathrm{PV}_{\text{foreign}}^{\text{base}}$ is the foreign leg PV excluding -the spread and $A_{\text{foreign}\to\text{domestic}}$ is the foreign-leg annuity -converted to domestic currency. This model par spread is a function of the basis -curve and is what calibration matches to the market. +`MarketFixingSnapshot_` is an immutable nested map +`index name -> DateTime_ -> value`. The same snapshot can contain rate and FX +observations and is retained by the calibration result. Supplying a snapshot +makes it authoritative, including when it is explicitly empty. When a snapshot +is omitted, staged and joint XCCY calibration first collect all historical +requests across all instruments and copy those values from the process-wide +fixing store once. Later global mutations cannot change the captured solve. -## Calibration as Underdetermined Search +## Staged Basis Calibration -Let $x = (b_1,\dots,b_K)$ be the piecewise-constant basis rates at the knot dates. -For each quoted swap $j$ define the residual +`CalibrateCrossCurrencyMarket` takes pre-calibrated domestic and foreign +`CurveBlock_` objects and solves only for the basis-curve parameters. For XCCY +instrument $j$ and basis parameters $b$, $$ -r_j(x) = \text{model spread}_j(x) - \text{market spread}_j . +r_j(b)=s_j^{\text{model}}(b)-s_j^{\text{market}}. $$ -Building the basis curve from $x$, repricing every swap, and returning the vector -$r(x)$ defines the residual map handed to the -[underdetermined solver](underdetermined_search.md). As in single-currency -calibration the system may have more knots than instruments, so a smoothness -weight matrix (tridiagonal, penalising differences between adjacent basis rates) -selects a smooth basis curve. - -Two solve modes are available, exactly as for the generic solver: - -- **Exact** — find a basis curve where every scaled residual is within tolerance, - using smoothness to choose among the many exact fits; the effective Jacobian - inverse is retained for basis risk. -- **Approximate** — minimise the residual norm subject to regularisation, used when - the quotes are not exactly consistent. - -## Calibration Pipeline - -```text -domestic curve block ─┐ -foreign curve block ─┤ -FX spot S ─┤ residual r_j(x) = model_spread_j(x) − market_spread_j -XCS market quotes ─┤ where x = basis rates at knot dates -knot dates ─┘ │ build P_b from x, reprice all swaps - ▼ - underdetermined solver (exact or approximate) - │ smoothness weights over knot dates - ▼ - calibrated basis curve b(t) → P_b(t₀,T) - │ - ▼ - basis-consistent FX forwards F(t₀,T) and XCS revaluation -``` - -## Result and Its Use - -Calibration yields the basis curve and, with it, a market object that prices -basis-consistent FX forwards and revalues cross-currency trades. Repricing -diagnostics (per-instrument residuals, RMS and maximum absolute error) quantify the -fit, and the retained effective Jacobian inverse maps quote bumps to basis-rate -changes for sensitivity analysis without re-solving. - -## Examples - -The snippets below are condensed from -`dal-cpp/examples/xccy_curve_calibration/xccy_curve_calibration.cpp` and adapt -its real call sequence. They show the public surface declared in -`dal-cpp/dal/curve/xccycalibration.hpp` and `dal-cpp/dal/curve/xccyinstrument.hpp`. -Class and enum names, factory functions, struct fields, and include paths match -the current source; see the citations in each example. - -### Two-currency market, collateral assumption, and basis calibration - -The example builds two single-currency curve blocks (domestic USD, foreign EUR), -quotes cross-currency swaps from a synthetic basis-bearing market to obtain -realistic par spreads, then re-calibrates a basis curve from those spreads. -Collateral/discounting is OIS in both currencies; the -`fxForwardCollateral_` field on the spec pins which collateral curve drives the -FX-forward computation. - -```cpp -#include -#include -#include -#include -#include -#include - -using namespace Dal; - -const Date_ today(2024, 1, 15); - -// Flat domestic (USD) and foreign (EUR) curve blocks. In production these are the -// pre-calibrated OIS + tenor-forecast blocks from the single-currency side. -auto usdBlock = Handle_( - new CurveBlock_(Handle_( - NewDiscountPWLF("usd_ois", "USD", MakeFlat("USD", today, 0.02)))); -auto eurBlock = Handle_( - new CurveBlock_(Handle_( - NewDiscountPWLF("eur_ois", "EUR", MakeFlat("EUR", today, 0.01)))); - -// Index and leg conventions: 12M tenor, ACT/365F, OIS-collateralised in both legs. -auto index = [] { - RateIndexConvention_ r; - r.useProjectionCurve_ = true; - r.forecastTenor_ = PeriodLength_("12M"); - r.dayBasis_ = DayBasis_("ACT_365F"); - r.collateral_ = CollateralType_(CollateralType_::Value_::OIS); - return r; -}(); -auto leg = [] { - RateLegConvention_ r; - r.paymentFrequency_ = PeriodLength_("12M"); - r.dayBasis_ = DayBasis_("ACT_365F"); - return r; -}(); - -// Cross-currency swap conventions: full notional exchange, par spread on the -// foreign (EUR) leg. -CrossCurrencyConvention_ convention; -convention.initialNotionalExchange_ = true; -convention.finalNotionalExchange_ = true; -convention.spreadOnForeignLeg_ = true; -convention.domesticIndex_ = index; -convention.domesticLeg_ = leg; -convention.foreignIndex_ = index; -convention.foreignLeg_ = leg; - -// One swap per maturity, each carrying its market par basis spread. -auto makeSwap = [&](double marketSpread, int maturityMonths) { - return Handle_(new CrossCurrencySwap_(today, - today, - Date::AddMonths(today, maturityMonths), - marketSpread, - CurrencyPair_(Ccy_("USD"), Ccy_("EUR")), - 110.0, // domestic notional - 100.0, // foreign notional - convention)); -}; - -CrossCurrencyCalibrationSpec_ spec; -spec.today_ = today; -spec.basisPair_ = CurrencyPair_(Ccy_("USD"), Ccy_("EUR")); -spec.domesticCurveBlock_ = usdBlock; -spec.foreignCurveBlock_ = eurBlock; -spec.fxSpot_ = 1.10; // USD per EUR -// fxForwardCollateral_ defaults to CollateralType_::Value_::OIS (see `CrossCurrencyCalibrationSpec_` in `dal-cpp/dal/curve/xccycalibration.hpp`). -spec.knotDates_ = {Date::AddMonths(today, 6), Date::AddMonths(today, 12), - Date::AddMonths(today, 24), Date::AddMonths(today, 60), - Date::AddMonths(today, 120)}; -spec.solveMode_ = CurveSolveMode_::Value_::EXACT; -for (const int m : {6, 12, 18, 24, 30, 36, 42, 48, 54, 60, 72, 84, 96, 108, 120}) - spec.instruments_.push_back(makeSwap(/*marketSpread=*/0.0, m)); // spreads filled in below - -const CrossCurrencyCalibrationResult_ result = CalibrateCrossCurrencyMarket(spec); -``` - -### Extracting the implied FX forward and basis discount factor - -The result carries a calibrated `CrossCurrencyMarket_` plus a -`CrossCurrencyFxForwardCurve_` snapshot at the knot dates. Use the market -accessors for arbitrary-date queries; use the snapshot for the calibrated -pillar grid. - -```cpp -// Implied FX forward (USD per EUR) for delivery at each knot date. -for (const Date_& d : spec.knotDates_) - const double fwd = result.market_.FxForward(d); - -// Same forward via the calibrated overloads that take explicit from/to/collateral. -const double fwdTodayTo1y = result.market_.FxForward(today, - Date::AddMonths(today, 12), - CollateralType_(CollateralType_::Value_::OIS)); - -// The basis discount factor P_b(today, T) — equals 1 when the basis is zero. -const double pb12m = result.market_.BasisDiscountFactor(today, Date::AddMonths(today, 12)); - -// The calibrated pillar grid (basis-consistent FX forwards at the knots). -// result.fxForwardCurve_.dates_ == spec.knotDates_ -// result.fxForwardCurve_.forwards_ == FxForward(d) at each knot -``` - -### Quoting market spreads from a synthetic basis-bearing market - -`xccy_curve_calibration.cpp` derives its market spreads by repricing prototype -zero-spread swaps against a market that already carries a basis, then feeding -those spreads back into a fresh calibration. This pattern is useful for -self-consistent tests: - -```cpp -// Build a quote market with a known flat basis to derive market-implied spreads. -CrossCurrencyMarket_ quoteMarket(usdBlock, eurBlock, 1.10); -quoteMarket.SetBasisCurve(Handle_( - NewDiscountPWLF("usd_eur_basis", "USD", MakeFlat("USD", today, 0.0020)))); - -// Prototype swaps at zero spread; their model par spread is the market quote. -Vector_<> marketSpreads; -for (const int m : {6, 12, 18, 24, /*...*/ 120}) { - const auto proto = makeSwap(0.0, m); - marketSpreads.push_back((*proto->Precompute())(quoteMarket)); -} -// ...then push makeSwap(marketSpreads[i], m) into spec.instruments_ as above. -``` - -API citations: - -- `CrossCurrencyCalibrationSpec_` fields (incl. `fxForwardCollateral_`, `solveMode_`) and - `CalibrateCrossCurrencyMarket` — `dal-cpp/dal/curve/xccycalibration.hpp`. -- `CrossCurrencyMarket_` constructor, `SetBasisCurve`, `FxForward`, `BasisDiscountFactor` — - `dal-cpp/dal/curve/xccycalibration.hpp`. -- `CrossCurrencySwap_` constructor and `Precompute` — `dal-cpp/dal/curve/xccyinstrument.hpp`. -- `CrossCurrencyConvention_`, `RateIndexConvention_`, `RateLegConvention_` — - `dal-cpp/dal/protocol/rateconvention.hpp`. -- `CurrencyPair_(domestic, foreign)` — `dal-cpp/dal/curve/xccyinstrument.hpp`. -- `CalibrateCrossCurrencyMarket` result fields `market_`, `basisCurves_`, `fxForwardCurve_`, - `diagnostics_` — `dal-cpp/dal/curve/xccycalibration.hpp`. - -### Diagnostic and revaluation surface - -`result.diagnostics_` (`CrossCurrencyCalibrationDiagnostics_`, -`dal-cpp/dal/curve/xccycalibration.hpp`) exposes per-instrument -`marketRates_`, `modelRates_`, `residuals_`, plus `rmsResidual_`, -`maxAbsResidual_`, and `usedApproximateFit_`. The `effJacobianInverse_` maps -basis-spread bumps to basis-rate changes for sensitivity work without re-solving, -exactly as on the single-currency side. +The basis curve is piecewise-constant forward in this path. Exact mode solves +the tolerance-scaled system and may retain the effective inverse Jacobian; +approximate mode minimizes the fit subject to smoothness regularization. The +result contains the basis-bearing `CrossCurrencyMarket_`, FX forwards at the +basis knots, fit diagnostics, and optional matrices. + +## Joint Domestic, Foreign, and Basis Calibration + +`CalibrateJointXccyMarket` solves one residual system over three ordered groups: + +1. every domestic `JointCurveDeclaration_`, in declaration order; +2. every foreign declaration, in declaration order; and +3. the `XccyBasisCurveDeclaration_`. + +Within each declaration, parameter columns follow its representation: + +| Parameterization | Columns per declaration | +|------------------|-------------------------| +| `PIECEWISE_CONSTANT_FWD` | right-hand forwards in knot order | +| `PIECEWISE_LINEAR_FWD` | left/right forward pair at each knot | +| `LOG_DISCOUNT` | future-knot log discount factors | +| `ZERO_RATE` | future-knot continuously compounded zero rates | + +Residual rows use the same group order: domestic instrument groups, foreign +instrument groups, then XCCY quotes. `parameterRanges_` and `residualRanges_` +name every contiguous slice and give its zero-based `offset_` and `size_`. +`jacobianAtSolution_` therefore has shape `total residuals x total parameters`. +The smoother is block diagonal by declaration; cross-block Jacobian entries +come from pricing and curve routing, not regularization. + +The result retains both solved currency blocks, the basis curve, FX forwards, +the fixing snapshot, group and joint diagnostics, market/model/residual vectors, +ranges, and optional forward/effective-inverse Jacobian matrices. + +## Analytic and Bumped Jacobians + +Both staged and joint calibration accept `ANALYTIC` and `BUMPED`. In exact mode, +analytic calibration can populate the forward Jacobian at the solution; either +mode can populate the solver's effective inverse when requested. Approximate +mode does not expose either matrix. + +Joint XCCY analytic calibration is fail-fast. Every domestic and foreign +declaration must satisfy the joint curve AAD gates, including `ACT_365F`, a +supported curve representation and templated instrument route, and consistent +discount/forward slot usage. Every XCCY plan mode (`FIXED`, `RESETTABLE`, and +`MARK_TO_MARKET`) is supported when its plan and fixing identities are valid. +If any analytic gate fails, the exception identifies the ineligible currency, +declaration, instrument, or reset. Select `BUMPED` explicitly to run the same +valid residual system without the analytic eligibility requirement. + +## End-to-End Example + +`dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp` builds known +domestic, foreign, and basis curves, derives self-consistent quotes, supplies one +immutable snapshot containing historical domestic-rate, foreign-rate, and FX +observations for an already-started MTM swap, and recovers all three parameter +blocks in one call. It prints the convergence residual, Jacobian shape, block +ranges, and parameter-recovery errors. ## See Also -- [Yield curve construction](yield_curve.md) — the single-currency framework the - basis curve layers onto. -- [Underdetermined search](underdetermined_search.md) — the solver used for the - basis calibration. -- [AAD methodology](aad.md) — supplies sensitivities used in calibration and risk. +- [Yield-curve construction](yield_curve.md) — curve declarations and single or + multi-curve calibration. +- [Yield-curve Jacobian](yield_curve_jacobian.md) — Jacobian layouts and + inverse-Jacobian risk. +- [Underdetermined search](underdetermined_search.md) — exact and approximate + solver behavior. diff --git a/docs/methodology/yield_curve_jacobian.md b/docs/methodology/yield_curve_jacobian.md index fb405a8ff..05742790e 100644 --- a/docs/methodology/yield_curve_jacobian.md +++ b/docs/methodology/yield_curve_jacobian.md @@ -339,6 +339,43 @@ smoother. The exact structural zeros the AAD sweep produces sit at parameters no residual touches by any route — the block-diagonal smoother simply ensures the smoothing pass does not smear those zeros into small non-zero entries. +## Joint XCCY Jacobian Layout + +`CalibrateJointXccyMarket` extends the same stacked-curve machinery with a final +cross-currency basis block. Parameter columns are contiguous in this order: +domestic declarations, foreign declarations, then the basis declaration. +Residual rows are domestic instrument groups, foreign instrument groups, then +XCCY swap quotes. `JointXccyCalibrationResult_::parameterRanges_` and +`residualRanges_` publish the exact name, offset, and size of each block, so a +consumer does not have to reconstruct the layout from the input spec. + +In exact analytic mode, `jacobianAtSolution_` is the unscaled dense matrix + +$$ +J_{ik}=\frac{\partial\,(\text{model quote}_i-\text{market quote}_i)} + {\partial x_k} +$$ + +with shape `totalResiduals x totalParameters`. It includes cross-block entries +from domestic/foreign discount and projection routing, FX forwards, basis +discounting, resettable notionals, MTM exchanges, and historical fixing choices. +The immutable fixing snapshot is passive data: historical observations have no +parameter adjoints, while future or unsupplied at-valuation observations remain +active forward values. + +Unlike generic joint multi-curve calibration, requested XCCY `ANALYTIC` mode is +fail-fast. An unsupported declaration, day basis, instrument route, or malformed +XCCY plan raises an eligibility error naming the offending group. `BUMPED` +remains available for every otherwise-valid spec. In exact bumped mode, +`jacobianAtSolution_` is empty while `effJacobianInverse_` may still be retained. +Approximate mode exposes neither matrix. The two matrix computations can also be +disabled independently with `JointXccyCalibrationOptions_`. + +The regression suite compares the full analytic stack against two-sided central +differences and verifies that the published parameter and residual ranges +partition every column and row. The runnable `xccy_mtm_calibration` example +prints the solved matrix shape and all domestic, foreign, and basis ranges. + ## The Inverse Jacobian and Bucketed IR Risk Once the curve is calibrated with `solveMode_ = EXACT`, the diagnostics carry diff --git a/docs/public-api.md b/docs/public-api.md index d55040a05..09d700574 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -44,11 +44,11 @@ on other toolchains. | `` | `NewBSModelData`, `NewDupireModelData` | | `` | `ValueByMonteCarlo` | | `` | Pseudo/Sobol constructors and uniform/normal matrix fills | -| `` | Day-basis, tenor, collateral, rate-leg/index, and currency-pair builders | -| `` | Deposit, FRA, future, swap, OIS, basis-swap, and cross-currency-swap builders | +| `` | Day-basis, tenor, collateral, rate-leg/index, currency-pair, FX-reset, and fixing-snapshot builders | +| `` | Deposit, FRA, future, swap, OIS, basis-swap, and fixed/resettable/MTM cross-currency-swap builders | | `` | Piecewise-linear-forward, zero-rate, and curve-block builders | | `` | `CurveCalibrationSpecBuilder_`, `CalibrateSingleCurve`, `CalibrateMultiCurveBundle` | -| `` | Cross-currency spec builder and `CalibrateXccyMarket` | +| `` | Staged and joint XCCY spec builders, calibration, and joint-result accessors | | `` | Linear one-dimensional interpolation builder | | `` | Repository find, erase, and size helpers for a configured host environment | @@ -142,14 +142,26 @@ The facade separates construction from solving: 5. Read `CalibrationResult_::curve_` and its diagnostics. For staged calibration, assemble `MultiCurveCalibrationSpec_` and call -`CalibrateMultiCurveBundle`. Cross-currency calibration has the analogous -`CrossCurrencyCalibrationSpecBuilder_` / `CalibrateXccyMarket` path. The full -methodology is in [yield-curve construction](methodology/yield_curve.md). +`CalibrateMultiCurveBundle`. Cross-currency calibration has two paths: + +- `CrossCurrencyCalibrationSpecBuilder_` / `CalibrateXccyMarket` calibrates a + basis curve over supplied domestic and foreign blocks. +- `JointXccyCalibrationSpecBuilder_` / `CalibrateJointXccyMarket` solves the + domestic declarations, foreign declarations, and basis declaration together. + +`CrossCurrencySwapConfigBuilder_` selects `XccyNotionalMode_::{FIXED, +RESETTABLE,MARK_TO_MARKET}`, explicit domestic/foreign `FixingIdentity_` values, +and an `FxResetConvention_`. `MarketFixingSnapshotNew` creates an immutable +rate-and-FX observation set for in-progress swaps. Joint results expose the +three solved curve handles plus FX forwards, market/model/residual vectors, the +forward Jacobian, and named parameter/residual ranges through +`JointXccyResult*` accessors. See +[cross-currency pricing and calibration](methodology/xccy_calibration.md). Set `parameterization_ = CurveParameterization_::Value_::ZERO_RATE` to calibrate future zero-rate nodes. `initialGuess_` and `initialGuessPerNode_` are decimal continuously -compounded rates for this representation. Single, staged, and core joint calibration -support ZERO_RATE; only single and staged calibration are exposed by the public facade. +compounded rates for this representation. Single, staged, generic joint, and +joint XCCY calibration support ZERO_RATE. ## Python @@ -169,7 +181,8 @@ import dal | Valuation | `MonteCarlo_Value` | | Random generation | `PseudoRSG_New`, `SobolRSG_New`, `*_Get_Uniform`, `*_Get_Normal` | | Calendar operations | `Holidays_`, `Is_BizDay`, `NextBizDay`, `PrevBizDay`, `Adjust` | -| Curves | `DiscountZeroRate_New`, convention/instrument builders, `CurveCalibrationSpecBuilder_`, `CalibrateSingleCurve`, `CalibrateMultiCurveBundle`, `CalibrateXccyMarket` | +| Curves | `DiscountZeroRate_New`, convention/instrument builders, `CurveCalibrationSpecBuilder_`, `CalibrateSingleCurve`, `CalibrateMultiCurveBundle`, `CalibrateXccyMarket`, `CalibrateJointXccyMarket` | +| XCCY reset data | `FixingIdentity_`, `FxResetConvention_`, `MarketFixingSnapshot_New`, `CrossCurrencySwapConfigBuilder_`, `XccyNotionalMode` | | Convenience calibration | `calibrate_curve` from `dal/api.py` | The basic valuation shape is: @@ -247,8 +260,16 @@ curve = dal.DiscountZeroRate_New( ``` The returned `DiscountZeroRate_` exposes read-only `anchor_date`, `node_dates`, -`zero_rates`, `day_count`, and `log_df_scheme` properties. Python exposes staged but not -core joint calibration. +`zero_rates`, `day_count`, and `log_df_scheme` properties. + +Python exposes the joint XCCY declarations, builder, options, calibration entry +point, and result surface with both trailing-underscore and snake-case aliases. +`JointXccyCalibrationResult_` provides `domestic_curve_block`, +`foreign_curve_block`, `basis_curve`, `fx_forward_curve`, `fixings`, group +diagnostics, `market_rates`, `model_rates`, `residuals`, +`jacobian_at_solution`, `eff_jacobian_inverse`, `parameter_ranges`, and +`residual_ranges`. `CalibrateJointXccyMarket(spec, options)` selects analytic or +bumped Jacobians and optional matrix construction. See [dal-python/README.md](../dal-python/README.md) for package-focused examples. @@ -282,10 +303,11 @@ Primary worksheet families are: | Purpose | Worksheet functions | |---------|---------------------| | Conventions | `PERIODLENGTH.NEW`, `DAYBASIS.NEW`, `RATELEGCONVENTION.NEW`, `RATEINDEXCONVENTION.NEW`, `COLLATERALTYPE.*` | -| Instruments | `DEPOSIT.NEW`, `FRA.NEW`, `FUTURE.NEW`, `SWAP.NEW`, `OISSWAP.NEW`, `BASISSWAP.NEW`, `CROSSCURRENCYSWAP.NEW` | +| XCCY reset data | `XCCYRESETCONVENTION.NEW`, `MARKETFIXINGSNAPSHOT.NEW` | +| Instruments | `DEPOSIT.NEW`, `FRA.NEW`, `FUTURE.NEW`, `SWAP.NEW`, `OISSWAP.NEW`, `BASISSWAP.NEW`, `CROSSCURRENCYSWAP.NEW`, `CROSSCURRENCYSWAPCONFIG.NEW`, `CROSSCURRENCYSWAP.CONFIG.NEW` | | Direct curves | `DISCOUNTPWLF.NEW`, `DISCOUNTZERORATE.NEW`, `CURVEBLOCK.NEW.SIMPLE` | -| Calibration | `CALIBRATE.SINGLECURVE`, `CALIBRATE.XCCYMARKET` | -| Results | `CALIBRATIONRESULT.GET`, `CALIBRATIONRESULT.GET.CURVE`, `XCCYCALIBRATIONRESULT.*` | +| Calibration | `CALIBRATE.SINGLECURVE`, `CALIBRATE.XCCYMARKET`, `CALIBRATE.JOINTXCCY` | +| Results | `CALIBRATIONRESULT.GET`, `CALIBRATIONRESULT.GET.CURVE`, `XCCYCALIBRATIONRESULT.*`, `JOINTXCCYCALIBRATIONRESULT.GET*` | | Repository | `REPOSITORY.FIND`, `REPOSITORY.ERASE`, `REPOSITORY.SIZE` | `DISCOUNTZERORATE.NEW` takes name, currency, anchor, future dates, and continuously @@ -294,9 +316,17 @@ compounded decimal zero rates, with optional day count, log-DF scheme, and base include curve name, target, solve mode, parameterization (`ZERO_RATE` included), log-DF scheme, smoothing/tolerances, scalar initial guess, and evaluation budgets. Its optional `baseCurve` input is the curve multiplied under the calibrated curve; it is distinct from -the `discountCurve` used to price a forward-curve calibration. Excel does not expose the -core joint-calibration API. Generated function help under `dal-excel/auto/*.htm` is the -argument-level catalog used by Excel registration. +the `discountCurve` used to price a forward-curve calibration. + +`CALIBRATE.JOINTXCCY` accepts one domestic discount-instrument/knot group, one +foreign discount-instrument/knot group, configured XCCY instruments, basis +knots, an optional immutable snapshot handle, and two-column settings. Dedicated +result functions return the domestic block, foreign block, and basis curve +handles. +`JOINTXCCYCALIBRATIONRESULT.GET` returns `fxForwards`, `marketRates`, +`modelRates`, `residuals`, `jacobian`, `parameterRanges`, or `residualRanges`. +Generated function help under `dal-excel/auto/*.htm` is the argument-level +catalog used by Excel registration. See [dal-excel/README.md](../dal-excel/README.md) for build and add-in guidance. @@ -306,10 +336,12 @@ See [dal-excel/README.md](../dal-excel/README.md) for build and add-in guidance. - Python maps native exceptions to Python exceptions; invalid binding shapes and indices use `ValueError`/`IndexError` where appropriate. - Excel returns worksheet error text annotated with the failing argument. -- Evaluation date and fixings are process-wide state. Evaluation-date mutation +- Evaluation date and the legacy fixing store are process-wide state. Evaluation-date mutation is serialized with native valuation; evaluation-date reads use the store lock - and remain available during valuation. Fixings reads use the store mutex, but - callers must externally serialize fixings mutation with other fixings access. + and remain available during valuation. Reset-aware XCCY APIs consume an + immutable `MarketFixingSnapshot_`; an omitted snapshot is copied once from the + global store before calibration starts. Callers must still externally + serialize direct mutation of the legacy fixing store. - AAD tapes are thread-local and must not be shared across recording frames. For ownership details, see [architecture](architecture.md). From 3f7168b98b6985f26afd60354081c525b27f9e99 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 05:56:45 +0800 Subject: [PATCH 20/38] fix(xccy): close final surface review findings --- .superpowers/sdd/task-9-report.md | 152 ------------------ dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp | 2 +- dal-cpp/dal/auto/MG_BizDayConvention_enum.inc | 12 +- dal-cpp/dal/auto/java/BizDayConvention.java | 2 +- dal-cpp/dal/time/schedules.hpp | 4 +- dal-cpp/tests/time/test_schedules.cpp | 5 + dal-excel/src/__xccycalibration.cpp | 58 ++++++- dal-python/src/bindings/calendar.cpp | 1 + dal-python/tests/test_xccy_resettable.py | 12 ++ 9 files changed, 78 insertions(+), 170 deletions(-) delete mode 100644 .superpowers/sdd/task-9-report.md diff --git a/.superpowers/sdd/task-9-report.md b/.superpowers/sdd/task-9-report.md deleted file mode 100644 index 69b49872a..000000000 --- a/.superpowers/sdd/task-9-report.md +++ /dev/null @@ -1,152 +0,0 @@ -# Task 9 Report: Excel Resettable and Joint XCCY Surface - -## Status - -- Starting reviewed head: `8dd73f2e950e7af8c7ce2ec5239543659c3dc548` -- Scope: Excel storables, worksheet implementations, generated registration/help sources, the internal smoke-test linkage seam, and Excel API smoke tests -- Result: locally complete on Linux; the exact pushed head still requires the prescribed Windows `dal_excel` and `dal_excel_tests` CI build - -## Implemented surface - -- Added `StorableFxResetConvention_`, `StorableMarketFixingSnapshot_`, `StorableCrossCurrencySwapConfig_`, and `StorableJointXccyCalibrationResult_`. -- The joint result wrapper retains the solved domestic block, foreign block, and basis curve handles so the dedicated Excel accessors cannot outlive their result dependencies. -- Added `XCCYRESETCONVENTION.NEW` with fixing lag, holiday center, business-day convention, and fixing-time validation. -- Added `MARKETFIXINGSNAPSHOT.NEW` over parallel index-name, fixing-time, and value arrays. It validates equal lengths and duplicate observations before constructing the core immutable snapshot; core validation continues to enforce non-empty names, valid timestamps, and positive finite values. -- Added the prerequisite `CROSSCURRENCYSWAPCONFIG.NEW` handle factory, which captures nested convention handles, reset mode and identities, and notionals. -- Added `CROSSCURRENCYSWAP.CONFIG.NEW(tradeDate, start, maturity, marketRate, config)` for configured/resettable swap construction. -- Added `CALIBRATE.JOINTXCCY` for one domestic discount declaration, one foreign discount declaration, and one basis declaration. It accepts a valuation timestamp, currency-pair handle, collateral currency, FX spot, the three instrument/knot groups, an optional fixing-snapshot handle, and optional two-column settings. -- The settings dictionary maps curve names, LIBOR bases, parameterizations, log-DF schemes, per-block smoothing, solver controls, solve/Jacobian modes, and matrix-output flags. -- Added dedicated result-handle getters for `domesticBlock`, `foreignBlock`, and `basisCurve`. -- Added the generic matrix getter for `fxForwards`, `marketRates`, `modelRates`, `residuals`, `jacobian`, `parameterRanges`, and `residualRanges`. -- Unknown joint result attributes list all ten accepted views and direct handle views to the dedicated getters. -- Preserved the existing `CROSSCURRENCYSWAP.NEW` and `CALIBRATE.XCCYMARKET` registrations and kept all legacy generated includes ahead of the additive registrations in their original order. - -## Review repair: Windows smoke-test linkage - -The first Task 9 commit exposed the generated worksheet entry points but did not export the directly called C++ smoke-test helpers. On Windows, `dal_excel_tests` links to the XLL import library rather than compiling those implementation sources, so its handwritten declarations could compile but could not link. - -- Added the internal `dal-excel/src/__xccy_test_api.hpp` header declaring only the nine Task 9 helpers used by `test_excel_api.cpp`. -- When `DAL_EXCEL_BUILD_TESTS` is enabled, `dal_excel` compiles those declarations with `__declspec(dllexport)` and `dal_excel_tests` consumes them with `__declspec(dllimport)`. -- With Excel tests disabled, the export definition is not applied, so the internal smoke-test seam is absent from production-only XLL export tables. -- The annotation is empty for the portable Linux harness. -- The implementations and smoke test include the same header; the test's handwritten declarations were removed. -- The fixing-snapshot smoke coverage now also asserts that duplicate `(indexName, fixingTime)` observations are rejected. - -## Generated files - -Machinist added these 18 Excel sources/help files and changed no `dal-cpp/dal/auto` file: - -- `dal-excel/auto/MG_XccyResetConvention_New_public.inc` -- `dal-excel/auto/MG_XccyResetConvention_New_public.htm` -- `dal-excel/auto/MG_MarketFixingSnapshot_New_public.inc` -- `dal-excel/auto/MG_MarketFixingSnapshot_New_public.htm` -- `dal-excel/auto/MG_CrossCurrencySwapConfig_New_public.inc` -- `dal-excel/auto/MG_CrossCurrencySwapConfig_New_public.htm` -- `dal-excel/auto/MG_CrossCurrencySwap_Config_New_public.inc` -- `dal-excel/auto/MG_CrossCurrencySwap_Config_New_public.htm` -- `dal-excel/auto/MG_Calibrate_JointXccy_public.inc` -- `dal-excel/auto/MG_Calibrate_JointXccy_public.htm` -- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_DomesticBlock_public.inc` -- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_DomesticBlock_public.htm` -- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_ForeignBlock_public.inc` -- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_ForeignBlock_public.htm` -- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_BasisCurve_public.inc` -- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_BasisCurve_public.htm` -- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_public.inc` -- `dal-excel/auto/MG_JointXccyCalibrationResult_Get_public.htm` - -## TDD evidence - -### RED - -The repository's Linux CMake configuration intentionally skips `dal-excel`, so the first requested target build established the platform boundary: - -```text -cmake --build build --target dal_excel_tests -j 4 -gmake: *** No rule to make target 'dal_excel_tests'. Stop. -``` - -After the Machinist declarations were added but before generated outputs were accepted, the drift target failed on exactly the 18 missing Excel files listed above. This was the intended generated-source RED. - -The review-repair RED used hidden-default ELF visibility as a local analogue for the MSVC import-library boundary. Before the internal header existed, `readelf` reported the directly called helper as hidden: - -```text -Dal::XccyResetConvention_New(...) FUNC GLOBAL HIDDEN -``` - -After switching the smoke test from handwritten declarations to the intended shared header, its portable compile also failed with `__xccy_test_api.hpp: No such file or directory`, confirming that the declaration boundary had not yet been implemented. - -### GREEN - -A Linux-portable test-only compile of the Excel implementation sources exercised the same public functions without Office/COM dependencies: - -```text -/tmp/dal_excel_api_tests '--gtest_filter=ExcelApiTest.*' -[==========] Running 4 tests from 1 test suite. -[ PASSED ] 4 tests. -``` - -The smoke suite covers configured resettable swap construction, rejection of non-parallel and duplicate fixing observations, an actual small three-block joint calibration plus every result view, and the complete unknown-attribute error list. - -With `DAL_EXCEL_TEST_API_EXPORTS` enabled, the same hidden-default probe now reports the helper with default/exported visibility: - -```text -Dal::XccyResetConvention_New(...) FUNC GLOBAL DEFAULT -``` - -## Final verification - -Generation and generated-source drift: - -```text -cmake --build build --target dal_generate -j 4 -[100%] Built target dal_generate - -cmake --build build --target dal_check_generated -j 4 -[100%] Built target dal_check_generated -``` - -The generation log reported `Wrote 0 files` for DAL core on the final pass; `git diff --name-only -- dal-cpp/dal/auto` is empty. - -Underlying public API regressions: - -```text -build/dal-public/dal_public_tests \ - '--gtest_filter=CurveProtocolTest.*:CurveInstrumentTest.*:XccyCalibrationTest.*' -[==========] 33 tests from 3 test suites ran. -[ PASSED ] 33 tests. -``` - -Underlying core XCCY regressions: - -```text -build/dal-cpp/dal_cpp_tests \ - '--gtest_filter=XccyBasisJacobianTest.*:XccyJointCalibrationTest.*:XccyJointJacobianTest.*:XccyMarketTest.*:XccyPricingTest.*' -[==========] 64 tests from 5 test suites ran. -[ PASSED ] 64 tests. -``` - -Additional checks: - -- `_WIN32` syntax-only compilation passed for all three changed Excel implementation sources, including all generated `.inc` registrations, using a Linux `__declspec` compatibility shim. -- `_WIN32` syntax-only compilation passed for `test_excel_api.cpp` through a wrapper that loads Google Test before defining `_WIN32`. -- The hidden-default visibility probe confirmed that all nine internal test helpers are exported only when `DAL_EXCEL_TEST_API_EXPORTS` is defined and remain hidden without it. -- `clang-format --dry-run --Werror -sort-includes=0` passed for the six manually changed C++ files. -- `git diff --cached --check` passed for the manually maintained source, test, and report files. As with the pre-existing Machinist outputs, generated `.inc`/`.htm` files retain template-emitted trailing whitespace and terminal blank lines; they were not hand-edited because doing so would create generated drift. -- `python3 .github/scripts/check_docs.py` passed for 38 Markdown files. - -## Files in task commit - -- `.superpowers/sdd/task-9-report.md` -- `dal-excel/CMakeLists.txt` -- `dal-excel/src/__curve_storable.hpp` -- `dal-excel/src/__curveprotocol.cpp` -- `dal-excel/src/__curveinstrument.cpp` -- `dal-excel/src/__xccycalibration.cpp` -- `dal-excel/src/__xccy_test_api.hpp` -- `dal-excel/tests/test_excel_api.cpp` -- the 18 generated files listed above - -## Platform boundary and concerns - -The Linux host cannot build the real Excel add-in or `dal_excel_tests` because `dal-excel/CMakeLists.txt` omits those targets off Windows. The local portable compile, generated-registration syntax checks, and core/public regressions are green, but the prescribed exact-head Windows CI must still build `dal_excel` and run `dal_excel_tests`; no local Windows success is claimed. diff --git a/dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp b/dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp index eee55ba29..d724d09bb 100644 --- a/dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp +++ b/dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp @@ -8,8 +8,8 @@ class BizDayConvention_ _NOT_SET=-1, Unadjusted, Following, - Preceding, ModifiedFollowing, + Preceding, _N_VALUES } val_; diff --git a/dal-cpp/dal/auto/MG_BizDayConvention_enum.inc b/dal-cpp/dal/auto/MG_BizDayConvention_enum.inc index 85c4c925f..4a208ef60 100644 --- a/dal-cpp/dal/auto/MG_BizDayConvention_enum.inc +++ b/dal-cpp/dal/auto/MG_BizDayConvention_enum.inc @@ -10,8 +10,8 @@ Vector_ BizDayConventionListAll() { if (TheBizDayConventionList().empty()) { TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::Unadjusted); TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::Following); - TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::Preceding); TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::ModifiedFollowing); + TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::Preceding); } return TheBizDayConventionList(); } @@ -27,10 +27,10 @@ const char* BizDayConvention_::String() const { return "Unadjusted"; case Value_::Following: return "Following"; - case Value_::Preceding: - return "Preceding"; case Value_::ModifiedFollowing: return "ModifiedFollowing"; + case Value_::Preceding: + return "Preceding"; }} @@ -51,11 +51,11 @@ struct ReadStringBizDayConvention_ else if (String::Equivalent(src, "FOLLOWING")) *val = BizDayConvention_::Value_::Following; - else if (String::Equivalent(src, "PRECEDING")) - *val = BizDayConvention_::Value_::Preceding; - else if (String::Equivalent(src, "MODIFIEDFOLLOWING")) *val = BizDayConvention_::Value_::ModifiedFollowing; + + else if (String::Equivalent(src, "PRECEDING")) + *val = BizDayConvention_::Value_::Preceding; else ret_val = false; return ret_val; diff --git a/dal-cpp/dal/auto/java/BizDayConvention.java b/dal-cpp/dal/auto/java/BizDayConvention.java index 5fa81b163..bdba72811 100644 --- a/dal-cpp/dal/auto/java/BizDayConvention.java +++ b/dal-cpp/dal/auto/java/BizDayConvention.java @@ -7,8 +7,8 @@ public enum Value { UNADJUSTED, FOLLOWING, - PRECEDING, MODIFIEDFOLLOWING, + PRECEDING, N_VALUES } } diff --git a/dal-cpp/dal/time/schedules.hpp b/dal-cpp/dal/time/schedules.hpp index 1e0ac1a1f..45730da52 100644 --- a/dal-cpp/dal/time/schedules.hpp +++ b/dal-cpp/dal/time/schedules.hpp @@ -29,12 +29,12 @@ alternative Unadjusted Do not modify the date according to the holidays alternative Following Choose the first business day after the given holiday -alternative Preceding - Choose the first business day before the given holiday alternative ModifiedFollowing Choose the first business day after the given holiday unless it belongs to a different month, in which case choose the first business day before the holiday +alternative Preceding + Choose the first business day before the given holiday -IF---------------------------------------------------------*/ diff --git a/dal-cpp/tests/time/test_schedules.cpp b/dal-cpp/tests/time/test_schedules.cpp index e3a160f70..15931f79d 100644 --- a/dal-cpp/tests/time/test_schedules.cpp +++ b/dal-cpp/tests/time/test_schedules.cpp @@ -84,6 +84,11 @@ TEST(SchedulesTest, TestAdjustSupportsPreceding) { Date_(2024, 11, 29)); } +TEST(SchedulesTest, TestBizDayConventionPreservesExistingOrdinals) { + ASSERT_EQ(static_cast(BizDayConvention_::Value_::ModifiedFollowing), 2); + ASSERT_EQ(static_cast(BizDayConvention_::Value_::Preceding), 3); +} + TEST(SchedulesTest, TestMakeSchedulePeriodsBuildsFixingPaymentAndDayCountContext) { const Date_ start(2024, 1, 31); const Date_ maturity(2024, 7, 31); diff --git a/dal-excel/src/__xccycalibration.cpp b/dal-excel/src/__xccycalibration.cpp index 50f7734f9..56350d5a8 100644 --- a/dal-excel/src/__xccycalibration.cpp +++ b/dal-excel/src/__xccycalibration.cpp @@ -216,34 +216,76 @@ namespace Dal { return result; } - void ApplyJointStringSetting(const String_& key, - const Cell_& value, - JointXccyCalibrationSpecBuilder_& builder, - JointXccyCalibrationOptions_& options) { + bool ApplyJointCurveNameSetting(const String_& key, const Cell_& value, JointXccyCalibrationSpecBuilder_& builder) { if (key == "domesticCurveName") builder.domestic_.curves_.front().curveName_ = Cell::ToString(value); else if (key == "foreignCurveName") builder.foreign_.curves_.front().curveName_ = Cell::ToString(value); else if (key == "basisCurveName") builder.basis_.curveName_ = Cell::ToString(value); - else if (key == "domesticLiborBasis") + else + return false; + return true; + } + + bool ApplyJointLiborBasisSetting(const String_& key, const Cell_& value, JointXccyCalibrationSpecBuilder_& builder) { + if (key == "domesticLiborBasis") builder.domestic_.liborBasis_ = DayBasis_(Cell::ToString(value)); else if (key == "foreignLiborBasis") builder.foreign_.liborBasis_ = DayBasis_(Cell::ToString(value)); - else if (key == "domesticParameterization") + else + return false; + return true; + } + + bool ApplyJointParameterizationSetting(const String_& key, const Cell_& value, JointXccyCalibrationSpecBuilder_& builder) { + if (key == "domesticParameterization") builder.domestic_.curves_.front().parameterization_ = CurveParameterization_(Cell::ToString(value)); else if (key == "foreignParameterization") builder.foreign_.curves_.front().parameterization_ = CurveParameterization_(Cell::ToString(value)); else if (key == "basisParameterization") builder.basis_.parameterization_ = CurveParameterization_(Cell::ToString(value)); - else if (key == "domesticLogDfScheme") + else + return false; + return true; + } + + bool ApplyJointLogDfSetting(const String_& key, const Cell_& value, JointXccyCalibrationSpecBuilder_& builder) { + if (key == "domesticLogDfScheme") builder.domestic_.curves_.front().logDfScheme_ = LogDfScheme_(Cell::ToString(value)); else if (key == "foreignLogDfScheme") builder.foreign_.curves_.front().logDfScheme_ = LogDfScheme_(Cell::ToString(value)); - else if (key == "solveMode") + else + return false; + return true; + } + + bool ApplyJointModeSetting(const String_& key, + const Cell_& value, + JointXccyCalibrationSpecBuilder_& builder, + JointXccyCalibrationOptions_& options) { + if (key == "solveMode") builder.solverOptions_.solveMode_ = CurveSolveMode_(Cell::ToString(value)); else if (key == "jacobianMode") options.jacobianMode_ = CurveJacobianMode_(Cell::ToString(value)); + else + return false; + return true; + } + + void ApplyJointStringSetting(const String_& key, + const Cell_& value, + JointXccyCalibrationSpecBuilder_& builder, + JointXccyCalibrationOptions_& options) { + if (ApplyJointCurveNameSetting(key, value, builder)) + return; + if (ApplyJointLiborBasisSetting(key, value, builder)) + return; + if (ApplyJointParameterizationSetting(key, value, builder)) + return; + if (ApplyJointLogDfSetting(key, value, builder)) + return; + ApplyJointModeSetting(key, value, builder, options); } void ApplyJointDoubleSetting(const String_& key, const Cell_& value, JointXccyCalibrationSpecBuilder_& builder) { diff --git a/dal-python/src/bindings/calendar.cpp b/dal-python/src/bindings/calendar.cpp index d301a5295..4c66b7b67 100644 --- a/dal-python/src/bindings/calendar.cpp +++ b/dal-python/src/bindings/calendar.cpp @@ -20,6 +20,7 @@ void init_bindings_calendar(py::module_& m) { .value("UNADJUSTED", BizDayConvention_::Value_::Unadjusted) .value("FOLLOWING", BizDayConvention_::Value_::Following) .value("MODIFIED_FOLLOWING", BizDayConvention_::Value_::ModifiedFollowing) + .value("PRECEDING", BizDayConvention_::Value_::Preceding) .export_values(); py::class_(m, "Holidays_") diff --git a/dal-python/tests/test_xccy_resettable.py b/dal-python/tests/test_xccy_resettable.py index 364ac79b5..f607bfe4a 100644 --- a/dal-python/tests/test_xccy_resettable.py +++ b/dal-python/tests/test_xccy_resettable.py @@ -84,6 +84,18 @@ def test_resettable_config_and_config_overload_preserve_field_aliases(): assert instrument is not None # nosec B101 +def test_reset_convention_round_trips_preceding_adjustment(): + """PRECEDING survives XCCY reset construction and rolls a weekend backward.""" + reset = dal.FxResetConvention_New( + 0, dal.Holidays_(""), dal.BizDayConvention_.PRECEDING, 10, 30 + ) + + assert reset.fixingConvention_ == dal.BizDayConvention_.PRECEDING # nosec B101 + assert dal.Adjust( + dal.Holidays_(""), dal.Date_(2024, 12, 1), reset.fixingConvention_ + ) == dal.Date_(2024, 11, 29) # nosec B101 + + def test_market_fixing_snapshot_accepts_nested_mapping(): """Nested Python mappings copy rate and FX fixing histories.""" rate_fixing_time = dal.DateTime_(_today().AddDays(-92), 11, 0) From 86fe5738b9cdd65461c3684d18f247832152544a Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 06:10:54 +0800 Subject: [PATCH 21/38] fix(xccy): correct started MTM cashflow handling --- dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp | 17 +- .../dal/curve/jointcalibration_internal.hpp | 45 ++-- dal-cpp/dal/curve/xccybasiscalibration.cpp | 2 + dal-cpp/dal/curve/xccyjointcalibration.cpp | 121 ++++++----- dal-cpp/dal/curve/xccypricing.cpp | 91 ++++++-- .../curve/test_joint_analytic_jacobian.cpp | 126 +++++++---- .../tests/curve/test_xccyjointcalibration.cpp | 25 +++ dal-cpp/tests/curve/test_xccymarket.cpp | 6 + dal-cpp/tests/curve/test_xccypricing.cpp | 199 +++++++++++++++++- docs/methodology/xccy_calibration.md | 3 +- 10 files changed, 496 insertions(+), 139 deletions(-) diff --git a/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp b/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp index 10d32d98c..0a2da3000 100644 --- a/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp +++ b/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp @@ -308,10 +308,10 @@ namespace { Date::AddMonths(fixture.today_, 12), Date::AddMonths(fixture.today_, 24), Date::AddMonths(fixture.today_, 48), Date::AddMonths(fixture.today_, 72), Date::AddMonths(fixture.today_, 120), }; - const JointCurrencyFixture_ domestic = - MakeJointCurrency(fixture.today_, Ccy_("USD"), knots, ycMaturities, {0.015, 0.016, 0.017, 0.018, 0.019}); - const JointCurrencyFixture_ foreign = - MakeJointCurrency(fixture.today_, Ccy_("EUR"), knots, ycMaturities, {0.010, 0.011, 0.012, 0.013, 0.014}); + const Vector_<> domesticParameters = {0.015, 0.016, 0.017, 0.018, 0.019}; + const Vector_<> foreignParameters = {0.010, 0.011, 0.012, 0.013, 0.014}; + const JointCurrencyFixture_ domestic = MakeJointCurrency(fixture.today_, Ccy_("USD"), knots, ycMaturities, domesticParameters); + const JointCurrencyFixture_ foreign = MakeJointCurrency(fixture.today_, Ccy_("EUR"), knots, ycMaturities, foreignParameters); const Vector_<> basisParameters = {0.0010, 0.0014, 0.0018, 0.0022, 0.0026}; const CurrencyPair_ pair(Ccy_("USD"), Ccy_("EUR")); CrossCurrencyMarket_ quoteMarket(domestic.market_, foreign.market_, kFxSpot, fixture.valuationTime_, pair.domestic_, fixture.fixings_); @@ -337,6 +337,15 @@ namespace { result.maxRestarts_ = 40; result.solveMode_ = CurveSolveMode_::Value_::EXACT; + const auto shifted = [](Vector_<> parameters) { + for (auto& parameter : parameters) + parameter += 0.001; + return parameters; + }; + result.domestic_.curves_[0].initialGuessPerNode_ = shifted(domesticParameters); + result.foreign_.curves_[0].initialGuessPerNode_ = shifted(foreignParameters); + result.basis_.initialGuessPerNode_ = shifted(basisParameters); + const Vector_ xccyMaturities = {6, 12, 18, 24, 30, 36, 42, 48, 54, 60, 72, 84, 96, 108, 120}; for (int i = 0; i < static_cast(xccyMaturities.size()); ++i) { const XccyNotionalMode_ mode = i % 2 == 0 ? XccyNotionalMode_::Value_::RESETTABLE : XccyNotionalMode_::Value_::MARK_TO_MARKET; diff --git a/dal-cpp/dal/curve/jointcalibration_internal.hpp b/dal-cpp/dal/curve/jointcalibration_internal.hpp index 41567776a..3f96ebe96 100644 --- a/dal-cpp/dal/curve/jointcalibration_internal.hpp +++ b/dal-cpp/dal/curve/jointcalibration_internal.hpp @@ -66,6 +66,11 @@ namespace Dal::JointCalibrationInternal { return String_(); } + inline void ValidateInstrumentHandles(const JointCurveDeclaration_& declaration, const String_& slotName) { + for (int i = 0; i < static_cast(declaration.instruments_.size()); ++i) + REQUIRE(declaration.instruments_[i], slotName + " has an empty instrument at index " + String::FromInt(i)); + } + inline std::pair, std::set> ValidateDeclarationIdentities(const CurveCollectionSpec_& spec) { REQUIRE(spec.curves_, spec.context_ + " requires curve declarations"); REQUIRE(!spec.curves_->empty(), spec.context_ + " requires at least one curve declaration"); @@ -103,6 +108,7 @@ namespace Dal::JointCalibrationInternal { const JointCurveDeclaration_& declaration = (*spec.curves_)[i]; const String_ slotName = SlotName(spec, i); REQUIRE(!declaration.instruments_.empty(), slotName + " requires at least one instrument"); + ValidateInstrumentHandles(declaration, slotName); REQUIRE(!declaration.knotDates_.empty(), slotName + " requires at least one knot date"); for (int k = 1; k < static_cast(declaration.knotDates_.size()); ++k) REQUIRE(declaration.knotDates_[k] > declaration.knotDates_[k - 1], slotName + " knot dates must be strictly increasing"); @@ -329,6 +335,27 @@ namespace Dal::JointCalibrationInternal { return String_(); } + inline String_ XccyResetPlanAnalyticIneligibilityReason(const XccyCashflowPlan_& plan) { + for (int i = 0; i < static_cast(plan.resets_.size()); ++i) { + if (plan.resets_[i].domesticPeriodIndex_ != i + 1 || + plan.resets_[i].domesticPeriodIndex_ >= static_cast(plan.domesticPeriods_.size())) + return "reset event " + String::FromInt(i) + " must map consecutively from the second domestic period"; + } + return String_(); + } + + inline String_ XccyNotionalModeAnalyticIneligibilityReason(const XccyCashflowPlan_& plan) { + switch (plan.config_.notionalMode_.Switch()) { + case XccyNotionalMode_::Value_::FIXED: + return String_(); + case XccyNotionalMode_::Value_::RESETTABLE: + case XccyNotionalMode_::Value_::MARK_TO_MARKET: + return XccyResetPlanAnalyticIneligibilityReason(plan); + default: + return String_("typed pricing does not support notional mode ") + plan.config_.notionalMode_.String(); + } + } + inline String_ XccyPlanAnalyticIneligibilityReason(const CurveCollectionSpec_& domestic, const std::vector& domesticSlots, const CurveCollectionSpec_& foreign, @@ -340,21 +367,9 @@ namespace Dal::JointCalibrationInternal { return "foreign typed curve block currency does not match the plan pair"; if (plan.domesticPeriods_.empty() || plan.foreignPeriods_.empty()) return "typed pricing requires coupon periods on both legs"; - - switch (plan.config_.notionalMode_.Switch()) { - case XccyNotionalMode_::Value_::FIXED: - break; - case XccyNotionalMode_::Value_::RESETTABLE: - case XccyNotionalMode_::Value_::MARK_TO_MARKET: - for (int i = 0; i < static_cast(plan.resets_.size()); ++i) { - if (plan.resets_[i].domesticPeriodIndex_ != i + 1 || - plan.resets_[i].domesticPeriodIndex_ >= static_cast(plan.domesticPeriods_.size())) - return "reset event " + String::FromInt(i) + " must map consecutively from the second domestic period"; - } - break; - default: - return String_("typed pricing does not support notional mode ") + plan.config_.notionalMode_.String(); - } + const String_ modeReason = XccyNotionalModeAnalyticIneligibilityReason(plan); + if (!modeReason.empty()) + return modeReason; String_ reason = TypedIndexAnalyticIneligibilityReason(domestic, domesticSlots, plan.config_.convention_.domesticIndex_, "domestic"); if (!reason.empty()) diff --git a/dal-cpp/dal/curve/xccybasiscalibration.cpp b/dal-cpp/dal/curve/xccybasiscalibration.cpp index efc6f1532..96baad19e 100644 --- a/dal-cpp/dal/curve/xccybasiscalibration.cpp +++ b/dal-cpp/dal/curve/xccybasiscalibration.cpp @@ -78,6 +78,8 @@ namespace Dal { "Cross-currency calibration basis pair foreign currency must match the foreign curve block"); REQUIRE(collateralCurrency == spec.basisPair_.domestic_, "Cross-currency calibration supports domestic-currency collateral only"); REQUIRE(!spec.instruments_.empty(), "Cross-currency calibration requires at least one instrument"); + for (int i = 0; i < static_cast(spec.instruments_.size()); ++i) + REQUIRE(spec.instruments_[i], "Cross-currency calibration has an empty XCCY instrument at index " + String::FromInt(i)); REQUIRE(!spec.knotDates_.empty(), "Cross-currency calibration requires at least one basis knot date"); REQUIRE(spec.smoothingWeight_ > 0.0, "Cross-currency calibration smoothing weight must be positive"); REQUIRE(spec.tolerance_ > 0.0, "Cross-currency calibration tolerance must be positive"); diff --git a/dal-cpp/dal/curve/xccyjointcalibration.cpp b/dal-cpp/dal/curve/xccyjointcalibration.cpp index 756e0352b..c59fb4f8e 100644 --- a/dal-cpp/dal/curve/xccyjointcalibration.cpp +++ b/dal-cpp/dal/curve/xccyjointcalibration.cpp @@ -362,6 +362,71 @@ namespace Dal { return result; } + void AppendCurrencyDiagnostics(const JointCurrencyCurveSpec_& currency, + const std::vector& slots, + const CurveBlock_& block, + bool approximate, + Vector_* diagnostics) { + for (const auto& slot : slots) + diagnostics->push_back(JointCalibrationInternal::BuildCurveDiagnostics(currency.curves_[slot.curveIndex_], slot, block, approximate)); + } + + void PopulateRates(const JointXccyCalibrationSpec_& spec, + const JointLayout_& layout, + const Vector_<>& residuals, + Vector_<>* marketRates, + Vector_<>* modelRates) { + *marketRates = Vector_<>(layout.totalResiduals_); + *modelRates = Vector_<>(layout.totalResiduals_); + for (const auto& slot : layout.domesticSlots_) + for (int i = 0; i < slot.nInstruments_; ++i) + (*marketRates)[slot.residualOffset_ + i] = slot.marketRates_[i]; + for (const auto& slot : layout.foreignSlots_) + for (int i = 0; i < slot.nInstruments_; ++i) + (*marketRates)[slot.residualOffset_ + i] = slot.marketRates_[i]; + for (int i = 0; i < static_cast(spec.basis_.instruments_.size()); ++i) + (*marketRates)[layout.basisSlot_.residualOffset_ + i] = spec.basis_.instruments_[i]->MarketRate(); + for (int i = 0; i < layout.totalResiduals_; ++i) + (*modelRates)[i] = (*marketRates)[i] + residuals[i]; + } + + void PopulateXccyDiagnostics(const JointXccyCalibrationSpec_& spec, + const JointLayout_& layout, + const JointXccyCalibrationResult_& result, + bool approximate, + CrossCurrencyCalibrationDiagnostics_* diagnostics) { + diagnostics->usedApproximateFit_ = approximate; + for (int i = 0; i < static_cast(spec.basis_.instruments_.size()); ++i) { + const int row = layout.basisSlot_.residualOffset_ + i; + diagnostics->instrumentNames_.push_back(spec.basis_.instruments_[i]->Name()); + diagnostics->marketRates_.push_back(result.marketRates_[row]); + diagnostics->modelRates_.push_back(result.modelRates_[row]); + diagnostics->residuals_.push_back(result.residuals_[row]); + } + const ResidualStats_ stats = ResidualStats(diagnostics->residuals_); + diagnostics->maxAbsResidual_ = stats.maxAbsResidual_; + diagnostics->rmsResidual_ = stats.rmsResidual_; + } + + void PopulateRanges(const JointXccyCalibrationSpec_& spec, const JointLayout_& layout, JointXccyCalibrationResult_* result) { + AppendRanges("domestic", layout.domesticCollection_, layout.domesticSlots_, &result->parameterRanges_, &result->residualRanges_); + AppendRanges("foreign", layout.foreignCollection_, layout.foreignSlots_, &result->parameterRanges_, &result->residualRanges_); + result->parameterRanges_.push_back( + {String_("basis:") + spec.basis_.curveName_, layout.basisSlot_.paramOffset_, layout.basisSlot_.nParams_}); + result->residualRanges_.push_back( + {String_("xccy:") + spec.basis_.curveName_, layout.basisSlot_.residualOffset_, layout.basisSlot_.nInstruments_}); + } + + void PopulateSummaryAndMatrices(const JointXccyResidualFunction_& function, SolveResult_* solve, JointXccyCalibrationResult_* result) { + const ResidualStats_ stats = ResidualStats(result->residuals_); + result->jointMaxAbsResidual_ = stats.maxAbsResidual_; + result->jointRmsResidual_ = stats.rmsResidual_; + result->solverEvaluations_ = function.EvaluationCount(); + if (solve->hasEffectiveInverse_) + result->effJacobianInverse_ = std::move(solve->effectiveInverse_); + result->jacobianAtSolution_ = std::move(solve->forwardJacobian_); + } + JointXccyCalibrationResult_ AssembleResult(const JointXccyCalibrationSpec_& spec, const JointLayout_& layout, const JointXccyResidualFunction_& function, @@ -380,54 +445,14 @@ namespace Dal { .release()); result.fxForwardCurve_ = BuildFxForwards(spec, result.domesticCurveBlock_, result.foreignCurveBlock_, result.basisCurve_, fixings); result.residuals_ = function.F(solve->parameters_); - - for (const auto& slot : layout.domesticSlots_) - result.domesticDiagnostics_.push_back(JointCalibrationInternal::BuildCurveDiagnostics( - spec.domestic_.curves_[slot.curveIndex_], slot, *result.domesticCurveBlock_, solve->approximate_)); - for (const auto& slot : layout.foreignSlots_) - result.foreignDiagnostics_.push_back(JointCalibrationInternal::BuildCurveDiagnostics( - spec.foreign_.curves_[slot.curveIndex_], slot, *result.foreignCurveBlock_, solve->approximate_)); - - result.marketRates_ = Vector_<>(layout.totalResiduals_); - result.modelRates_ = Vector_<>(layout.totalResiduals_); - for (const auto& slot : layout.domesticSlots_) { - for (int i = 0; i < slot.nInstruments_; ++i) - result.marketRates_[slot.residualOffset_ + i] = slot.marketRates_[i]; - } - for (const auto& slot : layout.foreignSlots_) { - for (int i = 0; i < slot.nInstruments_; ++i) - result.marketRates_[slot.residualOffset_ + i] = slot.marketRates_[i]; - } - for (int i = 0; i < static_cast(spec.basis_.instruments_.size()); ++i) - result.marketRates_[layout.basisSlot_.residualOffset_ + i] = spec.basis_.instruments_[i]->MarketRate(); - for (int i = 0; i < layout.totalResiduals_; ++i) - result.modelRates_[i] = result.marketRates_[i] + result.residuals_[i]; - - result.xccyDiagnostics_.usedApproximateFit_ = solve->approximate_; - for (int i = 0; i < static_cast(spec.basis_.instruments_.size()); ++i) { - const int row = layout.basisSlot_.residualOffset_ + i; - result.xccyDiagnostics_.instrumentNames_.push_back(spec.basis_.instruments_[i]->Name()); - result.xccyDiagnostics_.marketRates_.push_back(result.marketRates_[row]); - result.xccyDiagnostics_.modelRates_.push_back(result.modelRates_[row]); - result.xccyDiagnostics_.residuals_.push_back(result.residuals_[row]); - } - const ResidualStats_ xccyStats = ResidualStats(result.xccyDiagnostics_.residuals_); - result.xccyDiagnostics_.maxAbsResidual_ = xccyStats.maxAbsResidual_; - result.xccyDiagnostics_.rmsResidual_ = xccyStats.rmsResidual_; - - const ResidualStats_ jointStats = ResidualStats(result.residuals_); - result.jointMaxAbsResidual_ = jointStats.maxAbsResidual_; - result.jointRmsResidual_ = jointStats.rmsResidual_; - result.solverEvaluations_ = function.EvaluationCount(); - if (solve->hasEffectiveInverse_) - result.effJacobianInverse_ = std::move(solve->effectiveInverse_); - result.jacobianAtSolution_ = std::move(solve->forwardJacobian_); - AppendRanges("domestic", layout.domesticCollection_, layout.domesticSlots_, &result.parameterRanges_, &result.residualRanges_); - AppendRanges("foreign", layout.foreignCollection_, layout.foreignSlots_, &result.parameterRanges_, &result.residualRanges_); - result.parameterRanges_.push_back( - {String_("basis:") + spec.basis_.curveName_, layout.basisSlot_.paramOffset_, layout.basisSlot_.nParams_}); - result.residualRanges_.push_back( - {String_("xccy:") + spec.basis_.curveName_, layout.basisSlot_.residualOffset_, layout.basisSlot_.nInstruments_}); + AppendCurrencyDiagnostics(spec.domestic_, layout.domesticSlots_, *result.domesticCurveBlock_, solve->approximate_, + &result.domesticDiagnostics_); + AppendCurrencyDiagnostics(spec.foreign_, layout.foreignSlots_, *result.foreignCurveBlock_, solve->approximate_, + &result.foreignDiagnostics_); + PopulateRates(spec, layout, result.residuals_, &result.marketRates_, &result.modelRates_); + PopulateXccyDiagnostics(spec, layout, result, solve->approximate_, &result.xccyDiagnostics_); + PopulateSummaryAndMatrices(function, solve, &result); + PopulateRanges(spec, layout, &result); result.converged_ = true; return result; } diff --git a/dal-cpp/dal/curve/xccypricing.cpp b/dal-cpp/dal/curve/xccypricing.cpp index b78299a9d..9541ca2cb 100644 --- a/dal-cpp/dal/curve/xccypricing.cpp +++ b/dal-cpp/dal/curve/xccypricing.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -59,18 +60,61 @@ namespace Dal { return lhs.indexName_ == rhs.indexName_ && lhs.fixingTime_ == rhs.fixingTime_; } - void AddHistoricalRateRequests(const Vector_& periods, const DateTime_& valuationTime, Vector_* requests) { + void AddHistoricalRateRequests(const Vector_& periods, + const DateTime_& valuationTime, + const String_& context, + Vector_* requests) { for (const auto& period : periods) { - if (period.schedule_.paymentDate_ < valuationTime.Date() || period.rateIndexName_.empty() || - !(period.rateFixingTime_ < valuationTime)) + if (period.schedule_.paymentDate_ < valuationTime.Date()) + continue; + if (period.rateIndexName_.empty()) { + REQUIRE(!(period.schedule_.fixingDate_ < valuationTime.Date()), + context + " has no canonical fixing identity for an unpaid historical fixing"); + continue; + } + if (!(period.rateFixingTime_ < valuationTime)) continue; requests->push_back({period.rateIndexName_, period.rateFixingTime_}); } } + + void MarkRemainingCouponNotionals(const XccyCashflowPlan_& plan, const DateTime_& valuationTime, std::vector* required) { + for (int i = 0; i < static_cast(plan.domesticPeriods_.size()); ++i) + (*required)[i] = plan.domesticPeriods_[i].schedule_.paymentDate_ >= valuationTime.Date(); + } + + void MarkFinalExchangeNotional(const XccyCashflowPlan_& plan, const DateTime_& valuationTime, std::vector* required) { + if (plan.config_.convention_.finalNotionalExchange_ && plan.maturity_ >= valuationTime.Date() && !required->empty()) + required->back() = true; + } + + void MarkUnsettledMtmNotionals(const XccyCashflowPlan_& plan, const DateTime_& valuationTime, std::vector* required) { + for (const auto& reset : plan.resets_) { + REQUIRE(reset.domesticPeriodIndex_ > 0 && reset.domesticPeriodIndex_ < static_cast(required->size()), + "XCCY reset event references an invalid domestic period"); + if (reset.effectiveDate_ >= valuationTime.Date()) { + (*required)[reset.domesticPeriodIndex_] = true; + (*required)[reset.domesticPeriodIndex_ - 1] = true; + } + } + } + + std::vector RequiredDomesticNotionalPeriods(const XccyCashflowPlan_& plan, const DateTime_& valuationTime) { + std::vector result(plan.domesticPeriods_.size(), false); + if (plan.config_.notionalMode_ == XccyNotionalMode_::Value_::FIXED) + return result; + MarkRemainingCouponNotionals(plan, valuationTime, &result); + MarkFinalExchangeNotional(plan, valuationTime, &result); + if (plan.config_.notionalMode_ == XccyNotionalMode_::Value_::MARK_TO_MARKET) + MarkUnsettledMtmNotionals(plan, valuationTime, &result); + return result; + } + template T_ DiscountFromValuation(const Tape::DiscountCurve_& curve, const DateTime_& valuationTime, const Date_& date) { - if (date == valuationTime.Date()) - return T_(1.0); - return curve(valuationTime.Date(), date); + const T_ result = date == valuationTime.Date() ? T_(1.0) : curve(valuationTime.Date(), date); + const double value = Dal::AAD::Value(result); + REQUIRE(std::isfinite(value) && value > 0.0, "XCCY pricing requires positive finite discount factors"); + return result; } template @@ -98,8 +142,6 @@ namespace Dal { const T_ domesticDf = DiscountFromValuation(domesticDiscount, market.valuationTime_, fixingTime.Date()); const T_ foreignDf = DiscountFromValuation(foreignDiscount, market.valuationTime_, fixingTime.Date()); const T_ basisDf = market.basis_ ? DiscountFromValuation(*market.basis_, market.valuationTime_, fixingTime.Date()) : T_(1.0); - REQUIRE(Dal::AAD::Value(domesticDf) > 0.0 && Dal::AAD::Value(foreignDf) > 0.0 && Dal::AAD::Value(basisDf) > 0.0, - "XCCY FX forward requires positive discount factors"); return market.fxSpot_ * foreignDf / (domesticDf * basisDf); } @@ -108,7 +150,8 @@ namespace Dal { REQUIRE(market.pair_ == plan.config_.pair_, "XCCY pricing market pair does not match the cashflow plan"); REQUIRE(market.collateralCurrency_ == plan.config_.pair_.domestic_, "XCCY pricing supports domestic-currency collateral only"); REQUIRE(market.domestic_ && market.foreign_, "XCCY pricing requires domestic and foreign curve blocks"); - REQUIRE(Dal::AAD::Value(market.fxSpot_) > 0.0, "XCCY pricing requires a positive FX spot"); + const double fxSpot = Dal::AAD::Value(market.fxSpot_); + REQUIRE(std::isfinite(fxSpot) && fxSpot > 0.0, "XCCY pricing requires a positive finite FX spot"); REQUIRE(!market.basis_ || market.basis_->ccy_ == plan.config_.pair_.domestic_, "XCCY basis curve currency must match the domestic currency"); } @@ -143,14 +186,14 @@ namespace Dal { Vector_ RequiredHistoricalFixings(const XccyCashflowPlan_& plan, const DateTime_& valuationTime) { Vector_ result; - AddHistoricalRateRequests(plan.domesticPeriods_, valuationTime, &result); - AddHistoricalRateRequests(plan.foreignPeriods_, valuationTime, &result); + AddHistoricalRateRequests(plan.domesticPeriods_, valuationTime, "XCCY domestic floating coupon", &result); + AddHistoricalRateRequests(plan.foreignPeriods_, valuationTime, "XCCY foreign floating coupon", &result); + const std::vector requiredNotionals = RequiredDomesticNotionalPeriods(plan, valuationTime); for (const auto& reset : plan.resets_) { REQUIRE(reset.domesticPeriodIndex_ >= 0 && reset.domesticPeriodIndex_ < static_cast(plan.domesticPeriods_.size()), "XCCY reset event references an invalid domestic period"); - const auto& targetPeriod = plan.domesticPeriods_[reset.domesticPeriodIndex_]; - if (targetPeriod.schedule_.paymentDate_ < valuationTime.Date() || !(reset.fxFixingTime_ < valuationTime)) + if (!requiredNotionals[reset.domesticPeriodIndex_] || !(reset.fxFixingTime_ < valuationTime)) continue; result.push_back({FxIndexName(plan.config_.pair_), reset.fxFixingTime_}); } @@ -168,18 +211,23 @@ namespace Dal { if (plan.config_.notionalMode_ == XccyNotionalMode_::Value_::FIXED) return result; + const std::vector requiredNotionals = RequiredDomesticNotionalPeriods(plan, market.valuationTime_); + if (plan.config_.notionalMode_ == XccyNotionalMode_::Value_::MARK_TO_MARKET) + result.mtmDeltas_ = Vector_(plan.resets_.size(), T_(0.0)); const String_ fxIndexName = FxIndexName(plan.config_.pair_); for (int i = 0; i < static_cast(plan.resets_.size()); ++i) { const auto& reset = plan.resets_[i]; REQUIRE(reset.domesticPeriodIndex_ == i + 1 && reset.domesticPeriodIndex_ < static_cast(result.domesticNotionals_.size()), "XCCY reset events must map consecutively from the second domestic period"); + if (!requiredNotionals[reset.domesticPeriodIndex_]) + continue; const T_ fx = ResolveObservedValue(fxIndexName, reset.fxFixingTime_, market.valuationTime_, fixings, "XCCY domestic notional reset", [&]() { return ActiveFxForward(plan, market, reset.fxFixingTime_); }); const T_ newNotional = T_(plan.config_.foreignNotional_) * fx; const T_ previousNotional = result.domesticNotionals_[reset.domesticPeriodIndex_ - 1]; result.domesticNotionals_[reset.domesticPeriodIndex_] = newNotional; - if (plan.config_.notionalMode_ == XccyNotionalMode_::Value_::MARK_TO_MARKET) - result.mtmDeltas_.push_back(newNotional - previousNotional); + if (plan.config_.notionalMode_ == XccyNotionalMode_::Value_::MARK_TO_MARKET && reset.effectiveDate_ >= market.valuationTime_.Date()) + result.mtmDeltas_[i] = newNotional - previousNotional; } return result; } @@ -212,8 +260,11 @@ namespace Dal { const XccyMarketView_& market, const MarketFixingSnapshot_& fixings, const String_& context) { - if (period.rateIndexName_.empty()) + if (period.rateIndexName_.empty()) { + REQUIRE(!(period.schedule_.fixingDate_ < market.valuationTime_.Date()), + context + " has no canonical fixing identity for an unpaid historical fixing"); return ActiveForwardRate(period, forecast); + } return ResolveObservedValue(period.rateIndexName_, period.rateFixingTime_, market.valuationTime_, fixings, context, [&]() { return ActiveForwardRate(period, forecast); }); } @@ -222,8 +273,6 @@ namespace Dal { T_ ForeignConversionFactor(const Tape::DiscountCurve_& foreignDiscount, const XccyMarketView_& market, const Date_& paymentDate) { const T_ foreignDf = DiscountFromValuation(foreignDiscount, market.valuationTime_, paymentDate); const T_ basisDf = market.basis_ ? DiscountFromValuation(*market.basis_, market.valuationTime_, paymentDate) : T_(1.0); - REQUIRE(Dal::AAD::Value(foreignDf) > 0.0 && Dal::AAD::Value(basisDf) > 0.0, - "XCCY foreign cashflow conversion requires positive discount factors"); return market.fxSpot_ * foreignDf / basisDf; } @@ -296,11 +345,11 @@ namespace Dal { if (!plan.config_.convention_.initialNotionalExchange_) return result; - const Date_ domesticStart = plan.domesticPeriods_.front().schedule_.accrualStart_; + const Date_ domesticStart = plan.start_; if (domesticStart >= market.valuationTime_.Date()) result.domesticPv_ -= DomesticNotional(plan, resolved, fixedNotional, 0) * DiscountFromValuation(domesticDiscount, market.valuationTime_, domesticStart); - const Date_ foreignStart = plan.foreignPeriods_.front().schedule_.accrualStart_; + const Date_ foreignStart = plan.start_; if (foreignStart >= market.valuationTime_.Date()) result.foreignPv_ -= T_(plan.config_.foreignNotional_) * ForeignConversionFactor(foreignDiscount, market, foreignStart); return result; @@ -319,7 +368,7 @@ namespace Dal { for (int i = 0; i < static_cast(plan.resets_.size()); ++i) { if (plan.resets_[i].effectiveDate_ < market.valuationTime_.Date()) continue; - *domesticPv -= + *domesticPv += resolved.mtmDeltas_[i] * DiscountFromValuation(domesticDiscount, market.valuationTime_, plan.resets_[i].effectiveDate_); } } diff --git a/dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp b/dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp index c301b2c92..361c6ad00 100644 --- a/dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp +++ b/dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp @@ -476,84 +476,118 @@ namespace { return HarvestCurveJacobian(*tape, activeParameters, residuals); } - void AssertJointJacobianMatchesCentralDifferences(const JointMultiCurveCalibrationSpec_& spec, const String_& label) { - SCOPED_TRACE(label.c_str()); - JointMultiCurveCalibrationOptions_ analyticOptions; - analyticOptions.jacobianMode_ = CurveJacobianMode_::Value_::ANALYTIC; - const JointMultiCurveCalibrationResult_ analytic = CalibrateJointMultiCurve(spec, analyticOptions); - ASSERT_TRUE(analytic.converged_); - ASSERT_FALSE(analytic.jacobianAtSolution_.Empty()); + struct ExpectedJointShape_ { + int columns_ = 0; + int discountColumns_ = 0; + int rows_ = 0; + }; - const Vector_<> solved = SolvedJointParameters(spec, analytic); - int expectedColumns = 0; - int discountColumns = 0; - int expectedRows = 0; + ExpectedJointShape_ ExpectedJointShape(const JointMultiCurveCalibrationSpec_& spec) { + ExpectedJointShape_ result; for (const auto& declaration : spec.curves_) { const CurveDefinition_ definition = MakeCurveDefinition(declaration.curveName_, spec.ccy_, declaration.parameterization_, declaration.logDfScheme_, declaration.knotDates_, spec.today_, spec.liborBasis_); const int columns = BuildCurveParameterLayout(definition).parameterCount_; - expectedColumns += columns; + result.columns_ += columns; if (declaration.calibrateDiscountCurve_) - discountColumns += columns; - expectedRows += static_cast(declaration.instruments_.size()); + result.discountColumns_ += columns; + result.rows_ += static_cast(declaration.instruments_.size()); } - ASSERT_EQ(static_cast(solved.size()), expectedColumns); - ASSERT_EQ(analytic.jacobianAtSolution_.Rows(), expectedRows); - ASSERT_EQ(analytic.jacobianAtSolution_.Cols(), expectedColumns); + return result; + } + void AssertCentralDifferences(const JointMultiCurveCalibrationSpec_& spec, + const Vector_<>& solved, + const Matrix_<>& analyticJacobian, + const ExpectedJointShape_& shape) { constexpr double bump = 1.0e-6; - for (int column = 0; column < expectedColumns; ++column) { + for (int column = 0; column < shape.columns_; ++column) { Vector_<> up = solved; Vector_<> down = solved; up[column] += bump; down[column] -= bump; const Vector_<> upResiduals = EvalJointResiduals(spec, up); const Vector_<> downResiduals = EvalJointResiduals(spec, down); - for (int row = 0; row < expectedRows; ++row) { + for (int row = 0; row < shape.rows_; ++row) { const double centralDifference = (upResiduals[row] - downResiduals[row]) / (2.0 * bump); - ASSERT_NEAR(analytic.jacobianAtSolution_(row, column), centralDifference, 2.0e-8) << "row=" << row << ", column=" << column; + ASSERT_NEAR(analyticJacobian(row, column), centralDifference, 2.0e-8) << "row=" << row << ", column=" << column; } } + } + double CrossBlockMax(const JointMultiCurveCalibrationSpec_& spec, const Matrix_<>& jacobian, const ExpectedJointShape_& shape) { const int discountRows = static_cast(spec.curves_.front().instruments_.size()); - double crossBlockMax = 0.0; - for (int row = discountRows; row < expectedRows; ++row) - for (int column = 0; column < discountColumns; ++column) - crossBlockMax = std::max(crossBlockMax, std::fabs(analytic.jacobianAtSolution_(row, column))); - ASSERT_GT(crossBlockMax, 1.0e-8) << "base-layered forward residuals must depend on discount-curve parameters"; + double result = 0.0; + for (int row = discountRows; row < shape.rows_; ++row) + for (int column = 0; column < shape.discountColumns_; ++column) + result = std::max(result, std::fabs(jacobian(row, column))); + return result; + } - JointMultiCurveCalibrationOptions_ bumpedOptions; - bumpedOptions.jacobianMode_ = CurveJacobianMode_::Value_::BUMPED; - const JointMultiCurveCalibrationResult_ bumped = CalibrateJointMultiCurve(spec, bumpedOptions); - ASSERT_TRUE(bumped.converged_); - ASSERT_TRUE(bumped.jacobianAtSolution_.Empty()); - const Vector_<> bumpedSolved = SolvedJointParameters(spec, bumped); - ASSERT_EQ(bumpedSolved.size(), solved.size()); - for (int i = 0; i < static_cast(solved.size()); ++i) - ASSERT_NEAR(solved[i], bumpedSolved[i], 2.0e-5) << "solver column=" << i; + void AssertSolvedParametersMatch(const Vector_<>& analytic, const Vector_<>& bumped) { + ASSERT_EQ(bumped.size(), analytic.size()); + for (int i = 0; i < static_cast(analytic.size()); ++i) + ASSERT_NEAR(analytic[i], bumped[i], 2.0e-5) << "solver column=" << i; + } + void AssertDiagnosticsMatch(const JointMultiCurveCalibrationSpec_& spec, + const JointMultiCurveCalibrationResult_& analytic, + const JointMultiCurveCalibrationResult_& bumped) { ASSERT_EQ(analytic.diagnostics_.size(), bumped.diagnostics_.size()); for (int declarationIndex = 0; declarationIndex < static_cast(spec.curves_.size()); ++declarationIndex) { - const auto& declaration = spec.curves_[declarationIndex]; const auto& analyticResiduals = analytic.diagnostics_[declarationIndex].residuals_; const auto& bumpedResiduals = bumped.diagnostics_[declarationIndex].residuals_; ASSERT_EQ(analyticResiduals.size(), bumpedResiduals.size()); for (int row = 0; row < static_cast(analyticResiduals.size()); ++row) - ASSERT_NEAR(analyticResiduals[row], bumpedResiduals[row], 2.0e-8) - << "declaration=" << declarationIndex << ", residual row=" << row; - - const Handle_& analyticCurve = - declaration.calibrateDiscountCurve_ ? analytic.discountCurves_.at(declaration.targetCollateral_) - : analytic.forwardCurves_.at(declaration.targetTenor_); - const Handle_& bumpedCurve = - declaration.calibrateDiscountCurve_ ? bumped.discountCurves_.at(declaration.targetCollateral_) - : bumped.forwardCurves_.at(declaration.targetTenor_); + ASSERT_NEAR(analyticResiduals[row], bumpedResiduals[row], 2.0e-8) << "declaration=" << declarationIndex << ", residual row=" << row; + } + } + + void AssertCurveNodesMatch(const JointMultiCurveCalibrationSpec_& spec, + const JointMultiCurveCalibrationResult_& analytic, + const JointMultiCurveCalibrationResult_& bumped) { + for (int declarationIndex = 0; declarationIndex < static_cast(spec.curves_.size()); ++declarationIndex) { + const auto& declaration = spec.curves_[declarationIndex]; + const Handle_& analyticCurve = declaration.calibrateDiscountCurve_ + ? analytic.discountCurves_.at(declaration.targetCollateral_) + : analytic.forwardCurves_.at(declaration.targetTenor_); + const Handle_& bumpedCurve = declaration.calibrateDiscountCurve_ + ? bumped.discountCurves_.at(declaration.targetCollateral_) + : bumped.forwardCurves_.at(declaration.targetTenor_); for (int knot = 0; knot < static_cast(declaration.knotDates_.size()); ++knot) - ASSERT_NEAR((*analyticCurve)(spec.today_, declaration.knotDates_[knot]), - (*bumpedCurve)(spec.today_, declaration.knotDates_[knot]), 2.0e-5) + ASSERT_NEAR((*analyticCurve)(spec.today_, declaration.knotDates_[knot]), (*bumpedCurve)(spec.today_, declaration.knotDates_[knot]), + 2.0e-5) << "declaration=" << declarationIndex << ", knot=" << knot; } } + + void AssertJointJacobianMatchesCentralDifferences(const JointMultiCurveCalibrationSpec_& spec, const String_& label) { + SCOPED_TRACE(label.c_str()); + JointMultiCurveCalibrationOptions_ analyticOptions; + analyticOptions.jacobianMode_ = CurveJacobianMode_::Value_::ANALYTIC; + const JointMultiCurveCalibrationResult_ analytic = CalibrateJointMultiCurve(spec, analyticOptions); + ASSERT_TRUE(analytic.converged_); + ASSERT_FALSE(analytic.jacobianAtSolution_.Empty()); + + const Vector_<> solved = SolvedJointParameters(spec, analytic); + const ExpectedJointShape_ shape = ExpectedJointShape(spec); + ASSERT_EQ(static_cast(solved.size()), shape.columns_); + ASSERT_EQ(analytic.jacobianAtSolution_.Rows(), shape.rows_); + ASSERT_EQ(analytic.jacobianAtSolution_.Cols(), shape.columns_); + AssertCentralDifferences(spec, solved, analytic.jacobianAtSolution_, shape); + ASSERT_GT(CrossBlockMax(spec, analytic.jacobianAtSolution_, shape), 1.0e-8) + << "base-layered forward residuals must depend on discount-curve parameters"; + + JointMultiCurveCalibrationOptions_ bumpedOptions; + bumpedOptions.jacobianMode_ = CurveJacobianMode_::Value_::BUMPED; + const JointMultiCurveCalibrationResult_ bumped = CalibrateJointMultiCurve(spec, bumpedOptions); + ASSERT_TRUE(bumped.converged_); + ASSERT_TRUE(bumped.jacobianAtSolution_.Empty()); + const Vector_<> bumpedSolved = SolvedJointParameters(spec, bumped); + AssertSolvedParametersMatch(solved, bumpedSolved); + AssertDiagnosticsMatch(spec, analytic, bumped); + AssertCurveNodesMatch(spec, analytic, bumped); + } } // namespace TEST(JointAnalyticJacobianTest, TestPwlFactoryVsDirectConstruction) { diff --git a/dal-cpp/tests/curve/test_xccyjointcalibration.cpp b/dal-cpp/tests/curve/test_xccyjointcalibration.cpp index 091ad435a..ea7156bb6 100644 --- a/dal-cpp/tests/curve/test_xccyjointcalibration.cpp +++ b/dal-cpp/tests/curve/test_xccyjointcalibration.cpp @@ -191,6 +191,17 @@ namespace { result.spec_.maxEvaluations_ = 500; result.spec_.maxRestarts_ = 40; result.spec_.solveMode_ = CurveSolveMode_::Value_::EXACT; + auto shifted = [](const Vector_<>& parameters) { + Vector_<> result = parameters; + for (double& value : result) + value += 0.001; + return result; + }; + result.spec_.domestic_.curves_[0].initialGuessPerNode_ = shifted(result.domesticDiscountParameters_); + result.spec_.domestic_.curves_[1].initialGuessPerNode_ = shifted(result.domesticForwardParameters_); + result.spec_.foreign_.curves_[0].initialGuessPerNode_ = shifted(result.foreignDiscountParameters_); + result.spec_.foreign_.curves_[1].initialGuessPerNode_ = shifted(result.foreignForwardParameters_); + result.spec_.basis_.initialGuessPerNode_ = shifted(result.basisParameters_); for (int i = 0; i < static_cast(maturities.size()); ++i) { const XccyNotionalMode_ mode = i % 2 == 0 ? XccyNotionalMode_::Value_::RESETTABLE : XccyNotionalMode_::Value_::MARK_TO_MARKET; @@ -372,6 +383,20 @@ TEST(XccyJointCalibrationTest, TestValidationRejectsEmptyInstrumentGroupsWithPai AssertCalibrationFailsWith(fixture.spec_, fixture.spec_.foreign_.curves_.front().curveName_.c_str()); } +TEST(XccyJointCalibrationTest, TestValidationRejectsNullHandlesBeforeInstrumentUse) { + JointFixture_ fixture = MakeJointFixture(); + fixture.spec_.domestic_.curves_.front().instruments_.front() = Handle_(); + AssertCalibrationFailsWith(fixture.spec_, "Domestic slot"); + + fixture = MakeJointFixture(); + fixture.spec_.foreign_.curves_.front().instruments_.front() = Handle_(); + AssertCalibrationFailsWith(fixture.spec_, "Foreign slot"); + + fixture = MakeJointFixture(); + fixture.spec_.basis_.instruments_.front() = Handle_(); + AssertCalibrationFailsWith(fixture.spec_, "empty XCCY instrument at index 0"); +} + TEST(XccyJointCalibrationTest, TestOmittedFixingsCaptureAllHistoricalRequestsOnceAndExplicitSnapshotRemainsAuthoritative) { JointFixture_ fixture = MakeJointFixture(); const Date_ today = fixture.spec_.valuationTime_.Date(); diff --git a/dal-cpp/tests/curve/test_xccymarket.cpp b/dal-cpp/tests/curve/test_xccymarket.cpp index fbf1f1192..cb7fc2abb 100644 --- a/dal-cpp/tests/curve/test_xccymarket.cpp +++ b/dal-cpp/tests/curve/test_xccymarket.cpp @@ -514,6 +514,12 @@ TEST(XccyMarketTest, TestCalibrationRejectsInvalidSpec) { spec.knotDates_ = {Date::AddMonths(today, 12)}; ASSERT_THROW(static_cast(CalibrateCrossCurrencyMarket(spec)), Dal::Exception_); } + { + CrossCurrencyCalibrationSpec_ spec = MakeCalibrationSpec(today, pair); + spec.instruments_ = {Handle_()}; + spec.knotDates_ = {Date::AddMonths(today, 12)}; + ASSERT_THROW(static_cast(CalibrateCrossCurrencyMarket(spec)), Dal::Exception_); + } { CrossCurrencyCalibrationSpec_ spec = MakeCalibrationSpec(today, pair); spec.instruments_ = {Handle_(new CrossCurrencySwap_(MakeSwap(today, 0.0020)))}; diff --git a/dal-cpp/tests/curve/test_xccypricing.cpp b/dal-cpp/tests/curve/test_xccypricing.cpp index d7c608fbf..e72bf4118 100644 --- a/dal-cpp/tests/curve/test_xccypricing.cpp +++ b/dal-cpp/tests/curve/test_xccypricing.cpp @@ -4,8 +4,14 @@ #include +#include +#include + #include +#include +#include #include +#include #include #include @@ -49,6 +55,15 @@ namespace { } return false; } + + template void AssertDalExceptionContains(F_&& action, const std::string& expected) { + try { + action(); + FAIL() << "Expected DAL exception containing " << expected; + } catch (const Dal::Exception_& exception) { + ASSERT_NE(std::string(exception.what()).find(expected), std::string::npos) << exception.what(); + } + } class ConstantDiscountCurve_ : public DiscountCurve_ { double value_; @@ -102,6 +117,24 @@ namespace { void Write(Archive::Store_&) const override {} }; + template class SplitForwardCurve_ : public Tape::DiscountCurve_ { + Date_ split_; + T_ historical_; + T_ future_; + + public: + SplitForwardCurve_(const String_& name, const String_& ccy, const Date_& split, const T_& historical, const T_& future) + : Tape::DiscountCurve_(name, ccy), split_(split), historical_(historical), future_(future) {} + + T_ operator()(const Date_& from, const Date_&) const override { return from < split_ ? historical_ : future_; } + void Poll(Vector_* all) const override { all->push_back(this); } + void Poll(std::map>*) const override {} + [[nodiscard]] SplitForwardCurve_* Clone(const String_& newName, const YCComponent_::substitutions_t&) const override { + return new SplitForwardCurve_(newName, this->ccy_.String(), split_, historical_, future_); + } + void Write(Archive::Store_&) const override {} + }; + struct PricingMarket_ { ConstantDiscountCurve_ domestic_{"domestic", "USD", 1.0}; ConstantDiscountCurve_ foreign_{"foreign", "EUR", 1.0}; @@ -165,7 +198,7 @@ TEST(XccyPricingTest, TestFixedPlanLeavesUnconfiguredRateFixingMetadataEmpty) { config.domesticRateFixing_ = FixingIdentity_(); config.foreignRateFixing_ = FixingIdentity_(); - const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 1, 4), config); + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 8), Date_(2025, 1, 8), config); ASSERT_FALSE(plan.domesticPeriods_.empty()); ASSERT_FALSE(plan.foreignPeriods_.empty()); @@ -227,6 +260,33 @@ TEST(XccyPricingTest, TestRequiredHistoricalFixingsIncludeValuationDatePayments) } } +TEST(XccyPricingTest, TestStartedMultiResetTradeRequiresOnlyUnsettledDependencyClosure) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 8), Date_(2025, 1, 8), MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET)); + const DateTime_ valuationTime(Date_(2024, 7, 9), 12, 0); + const auto requests = RequiredHistoricalFixings(plan, valuationTime); + + ASSERT_EQ(requests.size(), 3U); + ASSERT_TRUE(HasRequest(requests, "USD-SOFR-3M", plan.domesticPeriods_[2].rateFixingTime_)); + ASSERT_TRUE(HasRequest(requests, "EUR-ESTR-3M", plan.foreignPeriods_[2].rateFixingTime_)); + ASSERT_TRUE(HasRequest(requests, "FX[EUR/USD]", plan.resets_[1].fxFixingTime_)); + ASSERT_FALSE(HasRequest(requests, "FX[EUR/USD]", plan.resets_[0].fxFixingTime_)); + + MarketFixingSnapshot_::values_t values; + values[plan.config_.domesticRateFixing_.indexName_][plan.domesticPeriods_[2].rateFixingTime_] = 0.04; + values[plan.config_.foreignRateFixing_.indexName_][plan.foreignPeriods_[2].rateFixingTime_] = 0.03; + values[FxIndexName(plan.config_.pair_)][plan.resets_[1].fxFixingTime_] = 1.20; + const MarketFixingSnapshot_ fixings(values); + const PricingMarket_ curves; + const auto market = curves.View(valuationTime); + + const auto resolved = ResolveXccyNotionals(plan, market, fixings); + ASSERT_EQ(resolved.mtmDeltas_.size(), plan.resets_.size()); + ASSERT_NEAR(resolved.mtmDeltas_[0], 0.0, 1.0e-12); + ASSERT_NEAR(resolved.mtmDeltas_[1], 0.0, 1.0e-12); + ASSERT_NEAR(resolved.mtmDeltas_[2], -10.0, 1.0e-12); + ASSERT_TRUE(std::isfinite(PriceXccyParSpread(plan, market, fixings))); +} + TEST(XccyPricingTest, TestResetPlanRejectsNegativeFxFixingLag) { auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::RESETTABLE); config.fxReset_.fixingLag_ = -1; @@ -271,14 +331,14 @@ TEST(XccyPricingTest, TestTwoPeriodMtmPrincipalCashflowsMatchHandCalculation) { ASSERT_EQ(resolved.mtmDeltas_.size(), 1U); ASSERT_NEAR(resolved.mtmDeltas_[0], 10.0, 1.0e-12); - const double explicitDomesticPrincipal = -110.0 - 10.0 + 120.0; + const double explicitDomesticPrincipal = -110.0 + 10.0 + 120.0; const double explicitForeignPrincipal = (-100.0 + 100.0) * 1.10; const double explicitForeignAnnuity = 100.0 * (plan.foreignPeriods_[0].accrual_.dcf_ + plan.foreignPeriods_[1].accrual_.dcf_) * 1.10; const double expectedParSpread = (explicitDomesticPrincipal - explicitForeignPrincipal) / explicitForeignAnnuity; ASSERT_NEAR(PriceXccyParSpread(plan, market, fixings), expectedParSpread, 1.0e-12); } -TEST(XccyPricingTest, TestMultipleFutureMtmExchangesUpdateDomesticPvSequentially) { +TEST(XccyPricingTest, TestMultipleFutureMtmExchangesAddDomesticPvSequentially) { auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET); config.domesticNotional_ = 8.0; config.foreignNotional_ = 1.0; @@ -317,7 +377,9 @@ TEST(XccyPricingTest, TestMultipleFutureMtmExchangesUpdateDomesticPvSequentially market.basis_ = &basis; const MarketFixingSnapshot_ fixings; - ASSERT_NEAR(PriceXccyParSpread(plan, market, fixings), -4.0 / 3.0, 1.0e-12); + const double expectedDomesticPv = 16.0 * 0x1p50 + 1.0; + const double expectedForeignAnnuity = 3.0 / 4.0; + ASSERT_NEAR(PriceXccyParSpread(plan, market, fixings), expectedDomesticPv / expectedForeignAnnuity, 1.0e-12); } namespace { @@ -419,6 +481,135 @@ TEST(XccyPricingTest, TestFutureRateFixingUsesActiveCurve) { ASSERT_NE(firstSpread, secondSpread); } +TEST(XccyPricingTest, TestStartedFixedPlanWithoutCanonicalRateIdentityFailsContextually) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED); + config.domesticRateFixing_ = FixingIdentity_(); + config.foreignRateFixing_ = FixingIdentity_(); + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 7, 4), config); + const DateTime_ valuationTime(Date_(2024, 4, 5), 12, 0); + + AssertDalExceptionContains([&]() { static_cast(RequiredHistoricalFixings(plan, valuationTime)); }, "domestic floating coupon"); + const PricingMarket_ curves; + const MarketFixingSnapshot_ fixings; + AssertDalExceptionContains([&]() { static_cast(PriceXccyParSpread(plan, curves.View(valuationTime), fixings)); }, + "canonical fixing identity"); +} + +TEST(XccyPricingTest, TestSameDayEmptyRateIdentityRemainsForecastableWithoutConfiguredTime) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED); + config.convention_.domesticIndex_.fixingLag_ = 0; + config.convention_.foreignIndex_.fixingLag_ = 0; + config.domesticRateFixing_ = FixingIdentity_(); + config.foreignRateFixing_ = FixingIdentity_(); + const auto plan = BuildXccyCashflowPlan(Date_(2024, 4, 4), Date_(2024, 7, 4), config); + const PricingMarket_ curves; + const MarketFixingSnapshot_ fixings; + + ASSERT_NO_THROW(static_cast(PriceXccyParSpread(plan, curves.View(DateTime_(Date_(2024, 4, 4), 23, 59)), fixings))); +} + +TEST(XccyPricingTest, TestInitialExchangeUsesExplicitTradeStartRatherThanAdjustedAccrualStart) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED); + config.convention_.initialNotionalExchange_ = true; + config.convention_.finalNotionalExchange_ = false; + config.convention_.spreadOnForeignLeg_ = true; + config.convention_.domesticLeg_.businessDayConvention_ = BizDayConvention_("Following"); + config.convention_.foreignLeg_.businessDayConvention_ = BizDayConvention_("Following"); + config.convention_.domesticIndex_.fixingLag_ = 0; + config.convention_.foreignIndex_.fixingLag_ = 0; + const Date_ valuationDate(2024, 1, 5); + const Date_ start(2024, 1, 6); + const auto plan = BuildXccyCashflowPlan(start, Date_(2024, 4, 6), config); + ASSERT_NE(plan.domesticPeriods_.front().schedule_.accrualStart_, start); + + MappedDiscountCurve_ domestic("domestic_start", "USD", valuationDate); + MappedDiscountCurve_ foreign("foreign_start", "EUR", valuationDate); + ConstantDiscountCurve_ basis("basis_start", "USD", 1.0); + domestic.SetValuationDf(start, 0.50); + foreign.SetValuationDf(start, 0.25); + const CollateralType_ ois(CollateralType_::Value_::OIS); + Tape::JointCurveBlock_ domesticBlock; + Tape::JointCurveBlock_ foreignBlock; + domesticBlock.discountCurves[ois] = &domestic; + domesticBlock.forwardCurves[PeriodLength_("3M")] = &domestic; + foreignBlock.discountCurves[ois] = &foreign; + foreignBlock.forwardCurves[PeriodLength_("3M")] = &foreign; + XccyMarketView_ market; + market.valuationTime_ = DateTime_(valuationDate, 12, 0); + market.pair_ = config.pair_; + market.collateralCurrency_ = config.pair_.domestic_; + market.fxSpot_ = 1.10; + market.domestic_ = &domesticBlock; + market.foreign_ = &foreignBlock; + market.basis_ = &basis; + + const double foreignAnnuity = config.foreignNotional_ * plan.foreignPeriods_[0].accrual_.dcf_ * 1.10; + const double expected = (-config.domesticNotional_ * 0.50 + config.foreignNotional_ * 1.10 * 0.25) / foreignAnnuity; + ASSERT_NEAR(PriceXccyParSpread(plan, market, MarketFixingSnapshot_()), expected, 1.0e-12); +} + +TEST(XccyPricingTest, TestTypedMarketRejectsNonFiniteSpotAndDiscountFactors) { + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 4, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED)); + const DateTime_ valuationTime(Date_(2024, 1, 3), 12, 0); + const MarketFixingSnapshot_ fixings; + const CustomPricingMarket_ infiniteSpot(1.0, 1.0, 1.0, std::numeric_limits::infinity()); + const CustomPricingMarket_ infiniteDiscount(std::numeric_limits::infinity(), 1.0, 1.0); + + ASSERT_THROW(static_cast(PriceXccyParSpread(plan, infiniteSpot.View(valuationTime), fixings)), Dal::Exception_); + ASSERT_THROW(static_cast(PriceXccyParSpread(plan, infiniteDiscount.View(valuationTime), fixings)), Dal::Exception_); +} + +TEST(XccyPricingTest, TestStartedTradeAadTreatsHistoricalRateFixingsAsPassiveAndFutureCouponsAsActive) { + const DateTime_ valuationTime(Date_(2024, 4, 5), 12, 0); + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED); + config.convention_.domesticIndex_.useProjectionCurve_ = true; + config.convention_.foreignIndex_.useProjectionCurve_ = true; + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 10, 4), config); + ASSERT_EQ(plan.domesticPeriods_.size(), 3U); + ASSERT_LT(plan.domesticPeriods_[1].rateFixingTime_, valuationTime); + ASSERT_GT(plan.domesticPeriods_[2].rateFixingTime_, valuationTime); + MarketFixingSnapshot_::values_t values; + values[plan.config_.domesticRateFixing_.indexName_][plan.domesticPeriods_[1].rateFixingTime_] = 0.04; + values[plan.config_.foreignRateFixing_.indexName_][plan.foreignPeriods_[1].rateFixingTime_] = 0.03; + const MarketFixingSnapshot_ fixings(values); + + auto* tape = AAD::Tape(); + AAD::Clear(*tape); + { + TapeGuard_ guard(tape); + Vector_ activeForwards = RegisterCurveParameters({0.95, 0.96, 0.97, 0.98}); + AAD::NewRecording(*tape); + SplitForwardCurve_ domesticDiscount("domestic_discount_aad", "USD", Date_(2024, 1, 1), 1.0, 1.0); + SplitForwardCurve_ foreignDiscount("foreign_discount_aad", "EUR", Date_(2024, 1, 1), 1.0, 1.0); + SplitForwardCurve_ basis("basis_aad", "USD", Date_(2024, 1, 1), 1.0, 1.0); + SplitForwardCurve_ domesticForward("domestic_forward_aad", "USD", Date_(2024, 7, 1), activeForwards[0], activeForwards[1]); + SplitForwardCurve_ foreignForward("foreign_forward_aad", "EUR", Date_(2024, 7, 1), activeForwards[2], activeForwards[3]); + const CollateralType_ ois(CollateralType_::Value_::OIS); + Tape::JointCurveBlock_ domesticBlock; + Tape::JointCurveBlock_ foreignBlock; + domesticBlock.discountCurves[ois] = &domesticDiscount; + domesticBlock.forwardCurves[PeriodLength_("3M")] = &domesticForward; + foreignBlock.discountCurves[ois] = &foreignDiscount; + foreignBlock.forwardCurves[PeriodLength_("3M")] = &foreignForward; + XccyMarketView_ market; + market.valuationTime_ = valuationTime; + market.pair_ = plan.config_.pair_; + market.collateralCurrency_ = plan.config_.pair_.domestic_; + market.fxSpot_ = 1.10; + market.domestic_ = &domesticBlock; + market.foreign_ = &foreignBlock; + market.basis_ = &basis; + + Vector_ spreads = {PriceXccyParSpread(plan, market, fixings)}; + const Matrix_<> jacobian = HarvestCurveJacobian(*tape, activeForwards, spreads); + ASSERT_NEAR(jacobian(0, 0), 0.0, 1.0e-14); + ASSERT_NEAR(jacobian(0, 2), 0.0, 1.0e-14); + ASSERT_GT(std::fabs(jacobian(0, 1)), 1.0e-6); + ASSERT_GT(std::fabs(jacobian(0, 3)), 1.0e-6); + } + AAD::Clear(*tape); +} + TEST(XccyPricingTest, TestPaidCouponIsFilteredBeforeFixingLookup) { const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 7, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED)); const DateTime_ valuationTime(Date_(2024, 4, 5), 12, 0); diff --git a/docs/methodology/xccy_calibration.md b/docs/methodology/xccy_calibration.md index e727c1503..50ec6ae2f 100644 --- a/docs/methodology/xccy_calibration.md +++ b/docs/methodology/xccy_calibration.md @@ -35,7 +35,8 @@ domestic period onward. The three `XccyNotionalMode_` values are: - `MARK_TO_MARKET`: notionals follow the same formula as `RESETTABLE`, and each reset effective date exchanges the domestic notional change $\Delta N_{d,i}=N_{d,i}-N_{d,i-1}$. Under DAL's domestic-leg PV sign - convention, the adjustment is $-\Delta N_{d,i}P_d(t,T_i)$. + convention, the adjustment has the same receive/pay orientation as the + positive domestic final exchange and is $+\Delta N_{d,i}P_d(t,T_i)$. Coupon PVs use the period notional, accrual fraction, projected or observed rate, and the payment-date discount factor. Foreign coupons and notional From d94818201ee338fdc5b226510d3c5d17fd32d13f Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 06:10:58 +0800 Subject: [PATCH 22/38] fix(calibration): publish inverse at solved state --- .../dal/math/optimization/underdetermined.cpp | 15 ++++- .../tests/curve/test_xccyjointjacobian.cpp | 51 +++++++++++++++ .../optimization/test_underdetermined.cpp | 64 +++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/dal-cpp/dal/math/optimization/underdetermined.cpp b/dal-cpp/dal/math/optimization/underdetermined.cpp index bf226c767..dc55baebf 100644 --- a/dal-cpp/dal/math/optimization/underdetermined.cpp +++ b/dal-cpp/dal/math/optimization/underdetermined.cpp @@ -103,6 +103,16 @@ namespace Dal { Underdetermined::Jacobian_* J(const Vector_<>& x, const Vector_<>& f) { REQUIRE(nRestarts_-- > 0, "Exhausted gradient evaluations in underdetermined search"); + return BuildJacobian(x, f); + } + + Underdetermined::Jacobian_* JAtSolution(const Vector_<>& x, const Vector_<>& scaledF) { + Vector_<> unscaledF = scaledF; + Transform(&unscaledF, tol_, std::multiplies<>()); + return BuildJacobian(x, unscaledF); + } + + Underdetermined::Jacobian_* BuildJacobian(const Vector_<>& x, const Vector_<>& f) { if (auto sparse = func_.Gradient(x, f)) { sparse->DivideRows(tol_); return sparse; @@ -260,7 +270,10 @@ namespace Dal { Transform(xOld, s, std::plus<>(), &xNew); Vector_<> fNew = func.F(xNew); if (*MaxElement(fNew) < 1.0 && *MinElement(fNew) > -1.0) { - StoreEffectiveJacobianInverse(*j, w, effJInv); + if (effJInv) { + std::unique_ptr jSolution(func.JAtSolution(xNew, fNew)); + StoreEffectiveJacobianInverse(*jSolution, w, effJInv); + } if (fwdJacobianAtSolution) { fwdJacobianAtSolution->Clear(); Vector_<> fUnscaled = fNew; diff --git a/dal-cpp/tests/curve/test_xccyjointjacobian.cpp b/dal-cpp/tests/curve/test_xccyjointjacobian.cpp index ce96cb475..9d30ac5e3 100644 --- a/dal-cpp/tests/curve/test_xccyjointjacobian.cpp +++ b/dal-cpp/tests/curve/test_xccyjointjacobian.cpp @@ -294,6 +294,57 @@ TEST(XccyJointJacobianTest, TestAnalyticStackMatchesCentralDifferencesAndRangesP ASSERT_NE(calibrated.residualRanges_[4].name_.find("xccy"), String_::npos); } +TEST(XccyJointJacobianTest, TestEffectiveInversePredictsQuoteBumpsAcrossAllThreeResidualBlocks) { + const Fixture_ fixture = MakeFixture(); + const JointXccyCalibrationResult_ base = CalibrateJointXccyMarket(fixture.spec_); + const Vector_<> baseParameters = Parameters(base); + constexpr double bump = 1.0e-6; + ASSERT_FALSE(base.effJacobianInverse_.Empty()); + ASSERT_EQ(base.effJacobianInverse_.Rows(), static_cast(baseParameters.size())); + + const Vector_ residualRows = { + base.residualRanges_[0].offset_, + base.residualRanges_[2].offset_, + base.residualRanges_[4].offset_, + }; + for (int block = 0; block < static_cast(residualRows.size()); ++block) { + JointXccyCalibrationSpec_ bumpedSpec = fixture.spec_; + bumpedSpec.domestic_.curves_[0].initialGuessPerNode_ = Slice(baseParameters, 0, 2); + bumpedSpec.domestic_.curves_[1].initialGuessPerNode_ = Slice(baseParameters, 2, 2); + bumpedSpec.foreign_.curves_[0].initialGuessPerNode_ = Slice(baseParameters, 4, 2); + bumpedSpec.foreign_.curves_[1].initialGuessPerNode_ = Slice(baseParameters, 6, 2); + bumpedSpec.basis_.initialGuessPerNode_ = Slice(baseParameters, 8, 2); + if (block < 2) { + auto& declaration = block == 0 ? bumpedSpec.domestic_.curves_.front() : bumpedSpec.foreign_.curves_.front(); + const auto* deposit = dynamic_cast(declaration.instruments_.front().get()); + ASSERT_NE(deposit, nullptr); + const auto span = deposit->TimeSpan(); + declaration.instruments_.front() = Handle_( + new Deposit_(deposit->TradeDate(), span.first, span.second, deposit->MarketRate() + bump, deposit->FloatConvention())); + } else { + const auto& original = bumpedSpec.basis_.instruments_.front(); + const auto span = original->TimeSpan(); + bumpedSpec.basis_.instruments_.front() = Handle_( + new CrossCurrencySwap_(span.first, span.first, span.second, original->MarketRate() + bump, original->Config())); + } + + const Vector_<> bumpedParameters = Parameters(CalibrateJointXccyMarket(bumpedSpec)); + Vector_<> observedMoves(baseParameters.size()); + Vector_<> predictedMoves(baseParameters.size()); + double observedNorm = 0.0; + for (int parameter = 0; parameter < static_cast(baseParameters.size()); ++parameter) { + observedMoves[parameter] = bumpedParameters[parameter] - baseParameters[parameter]; + predictedMoves[parameter] = base.effJacobianInverse_(parameter, residualRows[block]) * bump / fixture.spec_.tolerance_; + observedNorm = std::max(observedNorm, std::fabs(observedMoves[parameter])); + } + ASSERT_GT(observedNorm, 1.0e-8) << "block=" << block; + for (int parameter = 0; parameter < static_cast(baseParameters.size()); ++parameter) { + ASSERT_NEAR(predictedMoves[parameter], observedMoves[parameter], 0.03 * observedNorm + 1.0e-10) + << "block=" << block << " residualRow=" << residualRows[block] << " parameter=" << parameter; + } + } +} + TEST(XccyJointJacobianTest, TestExactApproximateAnalyticAndBumpedMatrixContracts) { const Fixture_ fixture = MakeFixture(); const JointXccyCalibrationResult_ analytic = CalibrateJointXccyMarket(fixture.spec_); diff --git a/dal-cpp/tests/math/optimization/test_underdetermined.cpp b/dal-cpp/tests/math/optimization/test_underdetermined.cpp index 6aeada694..38287aa80 100644 --- a/dal-cpp/tests/math/optimization/test_underdetermined.cpp +++ b/dal-cpp/tests/math/optimization/test_underdetermined.cpp @@ -85,6 +85,30 @@ namespace { void SecantUpdate(const Vector_<>&, const Vector_<>&) override {} }; + class NonlinearSquareAnalyticFunc_ : public Underdetermined::Function_ { + mutable Vector_> gradientXs_; + mutable Vector_> gradientFs_; + + public: + [[nodiscard]] Vector_<> F(const Vector_<>& x) const override { return Vector_<>{x[0] * x[0] - 4.0}; } + + [[nodiscard]] Underdetermined::Jacobian_* Gradient(const Vector_<>& x, const Vector_<>& f) const override { + gradientXs_.push_back(x); + gradientFs_.push_back(f); + Matrix_<> j(1, 1); + j(0, 0) = 2.0 * x[0]; + return new DenseJacobian_(j); + } + + [[nodiscard]] const Vector_>& GradientXs() const { return gradientXs_; } + [[nodiscard]] const Vector_>& GradientFs() const { return gradientFs_; } + }; + + class NonlinearSquareDenseFunc_ : public Underdetermined::Function_ { + public: + [[nodiscard]] Vector_<> F(const Vector_<>& x) const override { return Vector_<>{x[0] * x[0] - 4.0}; } + }; + class BacktrackProbeFunc_ : public Underdetermined::Function_ { Vector_>& evaluations_; @@ -303,6 +327,46 @@ TEST(UnderdeterminedTest, TestFindPopulatesEffectiveJacobianInverse) { ASSERT_NEAR(effJacobianInverse(1, 0), 2.0e-11, 1e-18); } +TEST(UnderdeterminedTest, TestEffectiveInverseUsesFreshAnalyticJacobianAndUnscaledResidualAtSolution) { + NonlinearSquareAnalyticFunc_ func; + const Vector_<> guess = {1.0}; + const Vector_<> tolerance = {1.0e-8}; + TriDiagonal_ weights(1); + weights.Set(0, 0, 1.0); + std::unique_ptr decomposition(weights.DecomposeSymmetric()); + + Matrix_<> effectiveInverse; + const Vector_<> solved = Underdetermined::Find(func, guess, tolerance, *decomposition, MakeControls(), &effectiveInverse); + ASSERT_NEAR(solved[0], 2.0, 1.0e-8); + ASSERT_EQ(effectiveInverse.Rows(), 1); + ASSERT_EQ(effectiveInverse.Cols(), 1); + ASSERT_NEAR(effectiveInverse(0, 0) / tolerance[0], 1.0 / (2.0 * solved[0]), 1.0e-12); + + int solutionGradient = -1; + for (int i = 0; i < static_cast(func.GradientXs().size()); ++i) + if (std::abs(func.GradientXs()[i][0] - solved[0]) < 1.0e-12) + solutionGradient = i; + ASSERT_GE(solutionGradient, 0); + ASSERT_NEAR(func.GradientFs()[solutionGradient][0], func.F(solved)[0], 1.0e-16); +} + +TEST(UnderdeterminedTest, TestEffectiveInverseUsesFreshDenseFiniteDifferenceJacobianAtSolution) { + NonlinearSquareDenseFunc_ func; + const Vector_<> guess = {1.0}; + const Vector_<> tolerance = {1.0e-8}; + TriDiagonal_ weights(1); + weights.Set(0, 0, 1.0); + std::unique_ptr decomposition(weights.DecomposeSymmetric()); + + Matrix_<> effectiveInverse; + const Vector_<> solved = Underdetermined::Find(func, guess, tolerance, *decomposition, MakeControls(), &effectiveInverse); + constexpr double finiteDifferenceBump = 1.0e-4; + ASSERT_NEAR(solved[0], 2.0, 1.0e-8); + ASSERT_EQ(effectiveInverse.Rows(), 1); + ASSERT_EQ(effectiveInverse.Cols(), 1); + ASSERT_NEAR(effectiveInverse(0, 0) / tolerance[0], 1.0 / (2.0 * solved[0] + finiteDifferenceBump), 1.0e-10); +} + // The trailing fwdJacobianAtSolution out-param captures the UNSCALED analytic forward Jacobian at // the solution via a single raw func.Gradient(xNew, fUnscaled) call on the convergence branch -- NOT // the XScaledFunc_::J path, so DivideRows(tol) is never applied. When the caller passes nullptr the From fccf19adc46fa82a81f8cc18ec0f1e285eec30ec Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 06:24:53 +0800 Subject: [PATCH 23/38] fix(xccy): validate projection factors --- dal-cpp/dal/curve/xccypricing.cpp | 5 +- .../dal/math/optimization/underdetermined.cpp | 150 ++++++++++++------ dal-cpp/tests/curve/test_xccypricing.cpp | 33 ++++ 3 files changed, 134 insertions(+), 54 deletions(-) diff --git a/dal-cpp/dal/curve/xccypricing.cpp b/dal-cpp/dal/curve/xccypricing.cpp index 9541ca2cb..0d1c3dcd7 100644 --- a/dal-cpp/dal/curve/xccypricing.cpp +++ b/dal-cpp/dal/curve/xccypricing.cpp @@ -249,8 +249,9 @@ namespace Dal { template T_ ActiveForwardRate(const XccyCouponPeriod_& period, const Tape::DiscountCurve_& forecast) { const T_ df = forecast(period.schedule_.accrualStart_, period.schedule_.accrualEnd_); - REQUIRE(Dal::AAD::Value(df) > 0.0 && period.accrual_.dcf_ > 0.0, - "XCCY floating coupon requires positive forecast discount factor and accrual fraction"); + const double dfValue = Dal::AAD::Value(df); + REQUIRE(std::isfinite(dfValue) && dfValue > 0.0 && period.accrual_.dcf_ > 0.0, + "XCCY floating coupon requires positive finite forecast discount factor and positive accrual fraction"); return (T_(1.0) / df - T_(1.0)) / static_cast(period.accrual_.dcf_); } diff --git a/dal-cpp/dal/math/optimization/underdetermined.cpp b/dal-cpp/dal/math/optimization/underdetermined.cpp index dc55baebf..58d929a3b 100644 --- a/dal-cpp/dal/math/optimization/underdetermined.cpp +++ b/dal-cpp/dal/math/optimization/underdetermined.cpp @@ -241,6 +241,94 @@ namespace Dal { decomp->Solve(rhs, &retVal); return retVal; } + + struct FindState_ { + Vector_<> xOld_; + Vector_<> fOld_; + std::unique_ptr jacobian_; + Vector_<> xNew_; + Vector_<> step_; + bool approximateJacobian_ = false; + bool restart_ = true; + + FindState_(const Vector_<>& guess, XScaledFunc_* func) : xOld_(guess), fOld_(func->F(xOld_)), xNew_(xOld_.size()), step_(xOld_.size()) {} + }; + + struct FindBacktrackResult_ { + bool solved_ = false; + bool tookStep_ = false; + }; + + bool IsFindSolution(const Vector_<>& f) { return *MaxElement(f) < 1.0 && *MinElement(f) > -1.0; } + + void StoreFindDiagnostics(XScaledFunc_* func, + const Underdetermined::Function_& funcIn, + const Vector_<>& x, + const Vector_<>& scaledF, + const Vector_<>& tol, + const Sparse::SymmetricDecomposition_& w, + Matrix_<>* effJInv, + Matrix_<>* fwdJacobianAtSolution) { + if (effJInv) { + std::unique_ptr jSolution(func->JAtSolution(x, scaledF)); + StoreEffectiveJacobianInverse(*jSolution, w, effJInv); + } + if (fwdJacobianAtSolution) { + fwdJacobianAtSolution->Clear(); + Vector_<> fUnscaled = scaledF; + Transform(&fUnscaled, tol, std::multiplies<>()); + std::unique_ptr jSolution(funcIn.Gradient(x, fUnscaled)); + if (jSolution) + StoreForwardJacobianAtSolution(*jSolution, fwdJacobianAtSolution); + } + } + + double BacktrackMinimum(const Vector_<>& fOld, const Vector_<>& fNew) { + const double oldOld = InnerProduct(fOld, fOld); + const double oldNew = InnerProduct(fOld, fNew); + const double newNew = InnerProduct(fNew, fNew); + const double denominator = oldOld - 2.0 * oldNew + newNew; + return denominator > 0.0 ? (newNew - oldNew) / denominator : 1.0; + } + + void AcceptFindStep(const Vector_<>& fNew, FindState_* state) { + if (!state->restart_) { + state->jacobian_->SecantUpdate(state->step_, Apply(std::minus<>(), fNew, state->fOld_)); + state->approximateJacobian_ = true; + } + state->xOld_ = state->xNew_; + state->fOld_ = fNew; + } + + FindBacktrackResult_ BacktrackFindStep(XScaledFunc_* func, + const Underdetermined::Function_& funcIn, + const Vector_<>& tol, + const Sparse::SymmetricDecomposition_& w, + const Underdetermined::Controls_& controls, + Matrix_<>* effJInv, + Matrix_<>* fwdJacobianAtSolution, + FindState_* state) { + for (int iBacktrack = 0; iBacktrack < controls.maxBacktrackTries_; ++iBacktrack) { + Transform(state->xOld_, state->step_, std::plus<>(), &state->xNew_); + const Vector_<> fNew = func->F(state->xNew_); + if (IsFindSolution(fNew)) { + StoreFindDiagnostics(func, funcIn, state->xNew_, fNew, tol, w, effJInv, fwdJacobianAtSolution); + return {true, false}; + } + + const double kMin = BacktrackMinimum(state->fOld_, fNew); + if (kMin < controls.backtrackTolerance_) { + AcceptFindStep(fNew, state); + return {false, true}; + } + if (kMin > controls.restartTolerance_) + state->restart_ = true; + const double k = min(controls.maxBacktrack_, min(kMin, 2 * (kMin - controls.backtrackTolerance_))); + REQUIRE(k > 0.0, "backtrack step must be positive"); + state->step_ *= 1.0 - k; + } + return {}; + } } // namespace Vector_<> Underdetermined::Find(const Function_& funcIn, @@ -251,62 +339,20 @@ namespace Dal { Matrix_<>* effJInv, Matrix_<>* fwdJacobianAtSolution) { XScaledFunc_ func(tol, funcIn, controls); - Vector_<> xOld(guess); - Vector_<> fOld = func.F(xOld); - std::unique_ptr j; - Vector_<> xNew(xOld.size()), s(xOld.size()); + FindState_ state(guess, &func); SquareMatrix_<> q; - bool approxJ = false, restart = true; for (;;) { - if (restart) { - j.reset(func.J(xOld, fOld)); - approxJ = false; - restart = false; - } - QPStep(fOld, *j, w, &q, &s); - bool tookStep = false; - for (int iBacktrack = 0; iBacktrack < controls.maxBacktrackTries_; ++iBacktrack) { - Transform(xOld, s, std::plus<>(), &xNew); - Vector_<> fNew = func.F(xNew); - if (*MaxElement(fNew) < 1.0 && *MinElement(fNew) > -1.0) { - if (effJInv) { - std::unique_ptr jSolution(func.JAtSolution(xNew, fNew)); - StoreEffectiveJacobianInverse(*jSolution, w, effJInv); - } - if (fwdJacobianAtSolution) { - fwdJacobianAtSolution->Clear(); - Vector_<> fUnscaled = fNew; - Transform(&fUnscaled, tol, std::multiplies<>()); - std::unique_ptr jSol(funcIn.Gradient(xNew, fUnscaled)); - if (jSol) - StoreForwardJacobianAtSolution(*jSol, fwdJacobianAtSolution); - } - return xNew; - } - - const double oldOld = InnerProduct(fOld, fOld); - const double oldNew = InnerProduct(fOld, fNew); - const double newNew = InnerProduct(fNew, fNew); - const double denominator = oldOld - 2.0 * oldNew + newNew; - const double kMin = denominator > 0.0 ? (newNew - oldNew) / denominator : 1.0; - if (kMin < controls.backtrackTolerance_) { - if (!restart) { - j->SecantUpdate(s, Apply(std::minus<>(), fNew, fOld)); - approxJ = true; - } - xOld = xNew; - fOld = fNew; - tookStep = true; - break; - } - if (kMin > controls.restartTolerance_) - restart = true; - double k = min(controls.maxBacktrack_, min(kMin, 2 * (kMin - controls.backtrackTolerance_))); - REQUIRE(k > 0.0, "backtrack step must be positive"); - s *= 1.0 - k; + if (state.restart_) { + state.jacobian_.reset(func.J(state.xOld_, state.fOld_)); + state.approximateJacobian_ = false; + state.restart_ = false; } - REQUIRE(tookStep || approxJ, "Could not find a descent direction in underdetermined search"); + QPStep(state.fOld_, *state.jacobian_, w, &q, &state.step_); + const FindBacktrackResult_ backtrack = BacktrackFindStep(&func, funcIn, tol, w, controls, effJInv, fwdJacobianAtSolution, &state); + if (backtrack.solved_) + return state.xNew_; + REQUIRE(backtrack.tookStep_ || state.approximateJacobian_, "Could not find a descent direction in underdetermined search"); } } diff --git a/dal-cpp/tests/curve/test_xccypricing.cpp b/dal-cpp/tests/curve/test_xccypricing.cpp index e72bf4118..d9642c3a6 100644 --- a/dal-cpp/tests/curve/test_xccypricing.cpp +++ b/dal-cpp/tests/curve/test_xccypricing.cpp @@ -559,6 +559,39 @@ TEST(XccyPricingTest, TestTypedMarketRejectsNonFiniteSpotAndDiscountFactors) { ASSERT_THROW(static_cast(PriceXccyParSpread(plan, infiniteDiscount.View(valuationTime), fixings)), Dal::Exception_); } +TEST(XccyPricingTest, TestTypedMarketRejectsNonFiniteProjectionDiscountFactor) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED); + config.convention_.domesticIndex_.useProjectionCurve_ = true; + config.convention_.foreignIndex_.useProjectionCurve_ = true; + config.convention_.domesticIndex_.fixingLag_ = 0; + config.convention_.foreignIndex_.fixingLag_ = 0; + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 4, 4), config); + const DateTime_ valuationTime(Date_(2024, 1, 3), 12, 0); + ConstantDiscountCurve_ domesticDiscount("domestic_discount_finite", "USD", 1.0); + ConstantDiscountCurve_ foreignDiscount("foreign_discount_finite", "EUR", 1.0); + ConstantDiscountCurve_ domesticProjection("domestic_projection_infinite", "USD", std::numeric_limits::infinity()); + ConstantDiscountCurve_ foreignProjection("foreign_projection_finite", "EUR", 1.0); + ConstantDiscountCurve_ basis("basis_finite", "USD", 1.0); + const CollateralType_ ois(CollateralType_::Value_::OIS); + Tape::JointCurveBlock_ domesticBlock; + Tape::JointCurveBlock_ foreignBlock; + domesticBlock.discountCurves[ois] = &domesticDiscount; + domesticBlock.forwardCurves[PeriodLength_("3M")] = &domesticProjection; + foreignBlock.discountCurves[ois] = &foreignDiscount; + foreignBlock.forwardCurves[PeriodLength_("3M")] = &foreignProjection; + XccyMarketView_ market; + market.valuationTime_ = valuationTime; + market.pair_ = config.pair_; + market.collateralCurrency_ = config.pair_.domestic_; + market.fxSpot_ = 1.10; + market.domestic_ = &domesticBlock; + market.foreign_ = &foreignBlock; + market.basis_ = &basis; + + AssertDalExceptionContains([&]() { static_cast(PriceXccyParSpread(plan, market, MarketFixingSnapshot_())); }, + "positive finite forecast discount factor"); +} + TEST(XccyPricingTest, TestStartedTradeAadTreatsHistoricalRateFixingsAsPassiveAndFutureCouponsAsActive) { const DateTime_ valuationTime(Date_(2024, 4, 5), 12, 0); auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED); From 38b28f38e68edd8b62bfd9a07648ba81d17495c5 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 06:34:49 +0800 Subject: [PATCH 24/38] fix(example): keep MTM calibration identifiable --- .../xccy_mtm_calibration.cpp | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp b/dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp index 0a2287c77..5e378f541 100644 --- a/dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp +++ b/dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp @@ -37,6 +37,12 @@ namespace { return Handle_(NewDiscountPWC(name, ccy.String(), PiecewiseConstant_(knots, values))); } + Vector_<> ShiftedInitialGuess(Vector_<> parameters) { + for (auto& parameter : parameters) + parameter += 0.001; + return parameters; + } + RateIndexConvention_ Index(bool projection) { RateIndexConvention_ result; result.useProjectionCurve_ = projection; @@ -72,6 +78,7 @@ namespace { discount.knotDates_ = knots; discount.targetCollateral_ = OIS; discount.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + discount.initialGuessPerNode_ = ShiftedInitialGuess(discountParameters); JointCurveDeclaration_ forward; forward.curveName_ = String_(ccy.String()) + "_3m"; forward.knotDates_ = knots; @@ -79,6 +86,7 @@ namespace { forward.targetTenor_ = THREE_MONTHS; forward.calibrateDiscountCurve_ = false; forward.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + forward.initialGuessPerNode_ = ShiftedInitialGuess(forwardParameters); for (const auto& maturity : maturities) { discount.instruments_.push_back(QuotedDeposit(today, maturity, result.discountIndex_, *result.market_)); forward.instruments_.push_back(QuotedDeposit(today, maturity, result.forwardIndex_, *result.market_)); @@ -138,11 +146,16 @@ namespace { result.curveName_ = "USD_EUR_basis"; result.knotDates_ = knots; result.parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; + result.initialGuessPerNode_ = ShiftedInitialGuess(basisParameters); for (int i = 0; i < static_cast(maturities.size()); ++i) { const Date_ start = i == 0 ? inProgressStart : today; - const CrossCurrencySwap_ prototype(start, start, maturities[i], 0.0, config); - result.instruments_.push_back( - Handle_(new CrossCurrencySwap_(start, start, maturities[i], (*prototype.Precompute())(quoteMarket), config))); + CrossCurrencySwapConfig_ instrumentConfig = config; + // Keep the started trade MTM while future resettable quotes identify the basis term structure. + if (i > 0) + instrumentConfig.notionalMode_ = XccyNotionalMode_::Value_::RESETTABLE; + const CrossCurrencySwap_ prototype(start, start, maturities[i], 0.0, instrumentConfig); + result.instruments_.push_back(Handle_( + new CrossCurrencySwap_(start, start, maturities[i], (*prototype.Precompute())(quoteMarket), instrumentConfig))); } return result; } From a1c9d80ebb993cb79df3dc3a2bc16c9fa6ffc890 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 06:36:08 +0800 Subject: [PATCH 25/38] refactor(xccy): reduce remaining binding complexity --- .../tests/curve/test_xccybasisjacobian.cpp | 34 +++++++++++-------- dal-python/src/bindings/curve.cpp | 12 +++---- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/dal-cpp/tests/curve/test_xccybasisjacobian.cpp b/dal-cpp/tests/curve/test_xccybasisjacobian.cpp index 3803dc2ae..c79b38d9b 100644 --- a/dal-cpp/tests/curve/test_xccybasisjacobian.cpp +++ b/dal-cpp/tests/curve/test_xccybasisjacobian.cpp @@ -123,6 +123,24 @@ namespace { } } + void AssertCapturedFixingsAndReplaceGlobals(const Handle_& snapshot, const MarketFixingSnapshot_::values_t& expected) { + for (const auto& indexHistory : expected) { + for (const auto& fixing : indexHistory.second) + ASSERT_NEAR(snapshot->Require(indexHistory.first, fixing.first, "captured calibration fixing"), fixing.second, 1.0e-12); + + FixHistory_ replacement; + for (const auto& fixing : indexHistory.second) + replacement.vals_.push_back({fixing.first, fixing.second + 0.10}); + XGLOBAL::StoreFixings(indexHistory.first, replacement, false); + } + } + + void AssertSnapshotFixings(const Handle_& snapshot, const MarketFixingSnapshot_::values_t& expected) { + for (const auto& indexHistory : expected) + for (const auto& fixing : indexHistory.second) + ASSERT_NEAR(snapshot->Require(indexHistory.first, fixing.first, "immutable calibration fixing"), fixing.second, 1.0e-12); + } + Fixture_ MakeFixture() { Fixture_ result; const Date_ valuationDate(2025, 1, 16); @@ -229,20 +247,8 @@ TEST(XccyBasisJacobianTest, TestOmittedSnapshotCapturesRequiredGlobalFixingsOnce const auto capturedResult = CalibrateCrossCurrencyMarket(omittedSpec); ASSERT_TRUE(capturedResult.market_.Fixings()); - for (const auto& indexHistory : fixture.fixingValues_) { - for (const auto& fixing : indexHistory.second) - ASSERT_NEAR(capturedResult.market_.Fixings()->Require(indexHistory.first, fixing.first, "captured calibration fixing"), fixing.second, - 1.0e-12); - - FixHistory_ replacement; - for (const auto& fixing : indexHistory.second) - replacement.vals_.push_back({fixing.first, fixing.second + 0.10}); - XGLOBAL::StoreFixings(indexHistory.first, replacement, false); - } - for (const auto& indexHistory : fixture.fixingValues_) - for (const auto& fixing : indexHistory.second) - ASSERT_NEAR(capturedResult.market_.Fixings()->Require(indexHistory.first, fixing.first, "immutable calibration fixing"), fixing.second, - 1.0e-12); + ASSERT_NO_FATAL_FAILURE(AssertCapturedFixingsAndReplaceGlobals(capturedResult.market_.Fixings(), fixture.fixingValues_)); + ASSERT_NO_FATAL_FAILURE(AssertSnapshotFixings(capturedResult.market_.Fixings(), fixture.fixingValues_)); const auto* explicitBasis = dynamic_cast*>(explicitResult.basisCurves_.at(fixture.spec_.basisPair_).get()); const auto* capturedBasis = dynamic_cast*>(capturedResult.basisCurves_.at(fixture.spec_.basisPair_).get()); diff --git a/dal-python/src/bindings/curve.cpp b/dal-python/src/bindings/curve.cpp index 106f0b410..490eacf53 100644 --- a/dal-python/src/bindings/curve.cpp +++ b/dal-python/src/bindings/curve.cpp @@ -431,11 +431,9 @@ namespace { const std::shared_ptr& base) -> std::shared_ptr { Vector_ nodeDates; - for (auto item : nodeDatesPy) - nodeDates.push_back(py::cast(item)); + SetDates(&nodeDates, nodeDatesPy); Vector_<> zeroRates; - for (auto item : zeroRatesPy) - zeroRates.push_back(py::cast(item)); + SetDoubles(&zeroRates, zeroRatesPy); Handle_ baseHandle( std::const_pointer_cast(base)); return std::const_pointer_cast( @@ -455,11 +453,9 @@ namespace { const std::shared_ptr& base) -> std::shared_ptr { Vector_ knotDates; - for (auto item : knotDatesPy) - knotDates.push_back(py::cast(item)); + SetDates(&knotDates, knotDatesPy); Vector_<> fwdRates; - for (auto item : fwdRatesPy) - fwdRates.push_back(py::cast(item)); + SetDoubles(&fwdRates, fwdRatesPy); Handle_ baseHandle( std::const_pointer_cast(base)); return std::const_pointer_cast( From eb4a4c55f0c8100999462e168c346486fde6d927 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 07:21:54 +0800 Subject: [PATCH 26/38] refactor(xccy): externalize diagnostic state --- dal-cpp/dal/curve/xccyjointcalibration.cpp | 23 ++++---- .../optimization/test_underdetermined.cpp | 55 ++++++------------- 2 files changed, 30 insertions(+), 48 deletions(-) diff --git a/dal-cpp/dal/curve/xccyjointcalibration.cpp b/dal-cpp/dal/curve/xccyjointcalibration.cpp index c59fb4f8e..2c9749c6a 100644 --- a/dal-cpp/dal/curve/xccyjointcalibration.cpp +++ b/dal-cpp/dal/curve/xccyjointcalibration.cpp @@ -211,7 +211,7 @@ namespace Dal { const Vector_* plans_; Handle_ fixings_; CurveJacobianMode_ jacobianMode_; - mutable int evaluationCount_ = 0; + int* evaluationCount_; template Vector_ Residuals(const Vector_& parameters) const { auto domestic = JointCalibrationInternal::BuildTypedCurveBlock(layout_->domesticCollection_, layout_->domesticSlots_, parameters); @@ -249,11 +249,12 @@ namespace Dal { const JointLayout_& layout, const Vector_& plans, const Handle_& fixings, - CurveJacobianMode_ jacobianMode) - : spec_(&spec), layout_(&layout), plans_(&plans), fixings_(fixings), jacobianMode_(jacobianMode) {} + CurveJacobianMode_ jacobianMode, + int* evaluationCount) + : spec_(&spec), layout_(&layout), plans_(&plans), fixings_(fixings), jacobianMode_(jacobianMode), evaluationCount_(evaluationCount) {} [[nodiscard]] Vector_<> F(const Vector_<>& parameters) const override { - ++evaluationCount_; + ++*evaluationCount_; return Residuals(parameters); } @@ -267,8 +268,6 @@ namespace Dal { Vector_ residuals = Residuals(activeParameters); return new XCurveJacobian_(HarvestCurveJacobian(*tape, activeParameters, residuals)); } - - [[nodiscard]] int EvaluationCount() const { return evaluationCount_; } }; Vector_<> BuildInitialGuess(const JointXccyCalibrationSpec_& spec, const JointLayout_& layout) { @@ -417,11 +416,11 @@ namespace Dal { {String_("xccy:") + spec.basis_.curveName_, layout.basisSlot_.residualOffset_, layout.basisSlot_.nInstruments_}); } - void PopulateSummaryAndMatrices(const JointXccyResidualFunction_& function, SolveResult_* solve, JointXccyCalibrationResult_* result) { + void PopulateSummaryAndMatrices(int evaluationCount, SolveResult_* solve, JointXccyCalibrationResult_* result) { const ResidualStats_ stats = ResidualStats(result->residuals_); result->jointMaxAbsResidual_ = stats.maxAbsResidual_; result->jointRmsResidual_ = stats.rmsResidual_; - result->solverEvaluations_ = function.EvaluationCount(); + result->solverEvaluations_ = evaluationCount; if (solve->hasEffectiveInverse_) result->effJacobianInverse_ = std::move(solve->effectiveInverse_); result->jacobianAtSolution_ = std::move(solve->forwardJacobian_); @@ -430,6 +429,7 @@ namespace Dal { JointXccyCalibrationResult_ AssembleResult(const JointXccyCalibrationSpec_& spec, const JointLayout_& layout, const JointXccyResidualFunction_& function, + const int* evaluationCount, const Handle_& fixings, SolveResult_* solve) { JointXccyCalibrationResult_ result; @@ -451,7 +451,7 @@ namespace Dal { &result.foreignDiagnostics_); PopulateRates(spec, layout, result.residuals_, &result.marketRates_, &result.modelRates_); PopulateXccyDiagnostics(spec, layout, result, solve->approximate_, &result.xccyDiagnostics_); - PopulateSummaryAndMatrices(function, solve, &result); + PopulateSummaryAndMatrices(*evaluationCount, solve, &result); PopulateRanges(spec, layout, &result); result.converged_ = true; return result; @@ -476,9 +476,10 @@ namespace Dal { const Vector_<> guess = BuildInitialGuess(spec, layout); const std::unique_ptr smoothing = BuildSmoothing(layout); - JointXccyResidualFunction_ function(spec, layout, plans, fixings, options.jacobianMode_); + int evaluationCount = 0; + JointXccyResidualFunction_ function(spec, layout, plans, fixings, options.jacobianMode_, &evaluationCount); SolveResult_ solve = Solve(spec, options, function, guess, *smoothing, layout.totalResiduals_); - JointXccyCalibrationResult_ result = AssembleResult(spec, layout, function, fixings, &solve); + JointXccyCalibrationResult_ result = AssembleResult(spec, layout, function, &evaluationCount, fixings, &solve); const double convergenceBound = spec.solveMode_ == CurveSolveMode_::Value_::EXACT ? 10.0 * spec.tolerance_ : 10.0 * spec.fitTolerance_; REQUIRE(result.jointMaxAbsResidual_ <= convergenceBound, "Joint XCCY calibration failed to converge for pair " + PairName(spec.pair_) + ": maxAbsResidual = " + String::FromDouble(result.jointMaxAbsResidual_) + diff --git a/dal-cpp/tests/math/optimization/test_underdetermined.cpp b/dal-cpp/tests/math/optimization/test_underdetermined.cpp index 38287aa80..ac00036c0 100644 --- a/dal-cpp/tests/math/optimization/test_underdetermined.cpp +++ b/dal-cpp/tests/math/optimization/test_underdetermined.cpp @@ -85,23 +85,26 @@ namespace { void SecantUpdate(const Vector_<>&, const Vector_<>&) override {} }; + struct NonlinearSquareGradientState_ { + Vector_> xs_; + Vector_> fs_; + }; + class NonlinearSquareAnalyticFunc_ : public Underdetermined::Function_ { - mutable Vector_> gradientXs_; - mutable Vector_> gradientFs_; + NonlinearSquareGradientState_* gradientState_; public: + explicit NonlinearSquareAnalyticFunc_(NonlinearSquareGradientState_* gradientState) : gradientState_(gradientState) {} + [[nodiscard]] Vector_<> F(const Vector_<>& x) const override { return Vector_<>{x[0] * x[0] - 4.0}; } [[nodiscard]] Underdetermined::Jacobian_* Gradient(const Vector_<>& x, const Vector_<>& f) const override { - gradientXs_.push_back(x); - gradientFs_.push_back(f); + gradientState_->xs_.push_back(x); + gradientState_->fs_.push_back(f); Matrix_<> j(1, 1); j(0, 0) = 2.0 * x[0]; return new DenseJacobian_(j); } - - [[nodiscard]] const Vector_>& GradientXs() const { return gradientXs_; } - [[nodiscard]] const Vector_>& GradientFs() const { return gradientFs_; } }; class NonlinearSquareDenseFunc_ : public Underdetermined::Function_ { @@ -328,7 +331,8 @@ TEST(UnderdeterminedTest, TestFindPopulatesEffectiveJacobianInverse) { } TEST(UnderdeterminedTest, TestEffectiveInverseUsesFreshAnalyticJacobianAndUnscaledResidualAtSolution) { - NonlinearSquareAnalyticFunc_ func; + NonlinearSquareGradientState_ gradientState; + NonlinearSquareAnalyticFunc_ func(&gradientState); const Vector_<> guess = {1.0}; const Vector_<> tolerance = {1.0e-8}; TriDiagonal_ weights(1); @@ -343,11 +347,11 @@ TEST(UnderdeterminedTest, TestEffectiveInverseUsesFreshAnalyticJacobianAndUnscal ASSERT_NEAR(effectiveInverse(0, 0) / tolerance[0], 1.0 / (2.0 * solved[0]), 1.0e-12); int solutionGradient = -1; - for (int i = 0; i < static_cast(func.GradientXs().size()); ++i) - if (std::abs(func.GradientXs()[i][0] - solved[0]) < 1.0e-12) + for (int i = 0; i < static_cast(gradientState.xs_.size()); ++i) + if (std::abs(gradientState.xs_[i][0] - solved[0]) < 1.0e-12) solutionGradient = i; ASSERT_GE(solutionGradient, 0); - ASSERT_NEAR(func.GradientFs()[solutionGradient][0], func.F(solved)[0], 1.0e-16); + ASSERT_NEAR(gradientState.fs_[solutionGradient][0], func.F(solved)[0], 1.0e-16); } TEST(UnderdeterminedTest, TestEffectiveInverseUsesFreshDenseFiniteDifferenceJacobianAtSolution) { @@ -367,11 +371,7 @@ TEST(UnderdeterminedTest, TestEffectiveInverseUsesFreshDenseFiniteDifferenceJaco ASSERT_NEAR(effectiveInverse(0, 0) / tolerance[0], 1.0 / (2.0 * solved[0] + finiteDifferenceBump), 1.0e-10); } -// The trailing fwdJacobianAtSolution out-param captures the UNSCALED analytic forward Jacobian at -// the solution via a single raw func.Gradient(xNew, fUnscaled) call on the convergence branch -- NOT -// the XScaledFunc_::J path, so DivideRows(tol) is never applied. When the caller passes nullptr the -// hook is skipped entirely (Gradient is not called at the solution); a Gradient that returns nullptr -// clears the output matrix rather than falling back to the dense finite-difference matrix. +// Forward diagnostics preserve the raw analytic Jacobian rather than the tolerance-scaled solver Jacobian. TEST(UnderdeterminedTest, TestFindPopulatesForwardJacobianAtSolution) { MultiResidualFunc_ func; @@ -387,13 +387,10 @@ TEST(UnderdeterminedTest, TestFindPopulatesForwardJacobianAtSolution) { Matrix_<> fwdJacobian; Vector_<> calculated = Underdetermined::Find(func, guess, tol, *decomp, MakeControls(), nullptr, &fwdJacobian); - // Solution unchanged. ASSERT_NEAR(calculated[0], 2.0 / 3.0, 1e-10); ASSERT_NEAR(calculated[1], 5.0 / 3.0, 1e-10); ASSERT_NEAR(calculated[2], 7.0 / 3.0, 1e-10); - // Shape: nResiduals x nX, and the constant analytic J is captured unscaled (rows 1.0 in cols 0,2 - // and 1,1 in cols 1,2 -- MultiResidualFunc_'s DenseJacobian_). ASSERT_EQ(fwdJacobian.Rows(), 2); ASSERT_EQ(fwdJacobian.Cols(), 3); ASSERT_NEAR(fwdJacobian(0, 0), 1.0, 1e-12); @@ -404,15 +401,11 @@ TEST(UnderdeterminedTest, TestFindPopulatesForwardJacobianAtSolution) { ASSERT_NEAR(fwdJacobian(1, 2), 1.0, 1e-12); } -// The convergence-branch hook must pass the UNSCALED residual func.F(xNew) to func.Gradient, not the -// scaled XScaledFunc_::F output. With tol far from 1 the two differ, so a ResidualCheckingFunc_ -// (which asserts f == F(x) inside Gradient) trips if a scaled f is passed. This pins the contract -// for any future Function_ whose Gradient actually consumes f. +// Analytic gradients may consume the residual, so diagnostics must pass it unscaled. TEST(UnderdeterminedTest, TestFindPassesUnscaledResidualToAtSolutionGradient) { ResidualCheckingFunc_ func; Vector_<> guess = {0.0, 0.0, 0.0}; - // tol != 1 so the scaled residual differs from the unscaled one by the 1e-3 factor. Vector_<> tol = {1.0e-3, 1.0e-3}; TriDiagonal_ weights(3); @@ -425,9 +418,6 @@ TEST(UnderdeterminedTest, TestFindPassesUnscaledResidualToAtSolutionGradient) { const Vector_<> solved = Underdetermined::Find(func, guess, tol, *decomp, MakeControls(), nullptr, &fwdJacobian); ASSERT_FALSE(fwdJacobian.Empty()); - // The convergence-call f must be the UNSCALED residual, which is ~0 at the solution. If the hook - // passed the scaled fNew, f would be ~0/1e-3 and still round to 0 -- so instead compare directly - // against F(solution): the unscaled residual is exactly F(solved), the scaled one is F(solved)/tol. const Vector_<> fAtSolution = func.FAtSolution(solved, 1e-9); ASSERT_EQ(fAtSolution.size(), 2); const Vector_<> expected = func.F(solved); @@ -435,17 +425,12 @@ TEST(UnderdeterminedTest, TestFindPassesUnscaledResidualToAtSolutionGradient) { ASSERT_NEAR(fAtSolution[1], expected[1], 1e-12); } -// When the caller passes nullptr for fwdJacobianAtSolution, the solver must NOT call the -// at-solution Gradient at all -- the convergence-branch hook is skipped entirely. This probes that -// contract directly: a CountingResidualFunc_ records every x passed to Gradient, and the solution x -// may appear in that record only when the out-param is supplied (then exactly once, as the -// convergence call). Replaces an earlier vacuous check that allocated a matrix it never passed in. +// At-solution gradient work is diagnostic-only and must be skipped when no output is requested. TEST(UnderdeterminedTest, TestFindSkipsAtSolutionGradientWhenOutParamNull) { const Vector_<> guess = {0.0, 0.0, 0.0}; const Vector_<> tol = {1.0e-10, 1.0e-10}; - // Reference solution and the per-iteration Gradient-call count with no out-param. CountingResidualFunc_ funcNull; TriDiagonal_ weightsNull(3); weightsNull.Set(0, 0, 1.0); @@ -456,8 +441,6 @@ TEST(UnderdeterminedTest, TestFindSkipsAtSolutionGradientWhenOutParamNull) { const auto& xsNull = funcNull.GradientXs(); const int nGradientNoOut = static_cast(xsNull.size()); - // With the out-param supplied, the solver makes exactly one ADDITIONAL Gradient call -- the - // convergence-branch call -- and that call is at the solution. CountingResidualFunc_ funcOut; TriDiagonal_ weightsOut(3); weightsOut.Set(0, 0, 1.0); @@ -472,8 +455,6 @@ TEST(UnderdeterminedTest, TestFindSkipsAtSolutionGradientWhenOutParamNull) { for (int i = 0; i < static_cast(solved.size()); ++i) ASSERT_NEAR(solvedOut[i], solved[i], 1e-10); - // The solution was NOT a Gradient evaluation point when no out-param was requested... ASSERT_FALSE(WasGradientCalledAt(xsNull, solved, 1e-9)); - // ...and IS (exactly the one convergence call) when the out-param is supplied. ASSERT_TRUE(WasGradientCalledAt(xsOut, solved, 1e-9)); } From dfe1af4a2c6f0be3d5b207fd2112d576873de62a Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 07:19:29 +0800 Subject: [PATCH 27/38] chore(xccy): close post-ready review findings --- dal-cpp/tests/currency/test_currencydata.cpp | 2 +- dal-cpp/tests/curve/test_xccymarket.cpp | 2 +- dal-cpp/tests/indice/test_fixingsnapshot.cpp | 2 +- dal-cpp/tests/time/test_schedules.cpp | 2 +- ...13-xccy-resettable-mtm-plan-corrections.md | 93 -- .../plans/2026-07-13-xccy-resettable-mtm.md | 922 ------------------ ...3-xccy-in-progress-rate-fixing-addendum.md | 44 - ...-xccy-resettable-mtm-benchmark-addendum.md | 79 -- .../2026-07-13-xccy-resettable-mtm-design.md | 356 ------- 9 files changed, 4 insertions(+), 1498 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-13-xccy-resettable-mtm-plan-corrections.md delete mode 100644 docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md delete mode 100644 docs/superpowers/specs/2026-07-13-xccy-in-progress-rate-fixing-addendum.md delete mode 100644 docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-benchmark-addendum.md delete mode 100644 docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-design.md diff --git a/dal-cpp/tests/currency/test_currencydata.cpp b/dal-cpp/tests/currency/test_currencydata.cpp index ad993b081..27775b0a4 100644 --- a/dal-cpp/tests/currency/test_currencydata.cpp +++ b/dal-cpp/tests/currency/test_currencydata.cpp @@ -2,6 +2,7 @@ // Created by wegam on 2022/2/10. // +#include #include #include @@ -11,7 +12,6 @@ #include #include #include -#include using namespace Dal; diff --git a/dal-cpp/tests/curve/test_xccymarket.cpp b/dal-cpp/tests/curve/test_xccymarket.cpp index cb7fc2abb..09d3cee80 100644 --- a/dal-cpp/tests/curve/test_xccymarket.cpp +++ b/dal-cpp/tests/curve/test_xccymarket.cpp @@ -2,6 +2,7 @@ // Created by GitHub Copilot on 2026/6/6. // +#include #include #include @@ -14,7 +15,6 @@ #include #include #include -#include #include using namespace Dal; diff --git a/dal-cpp/tests/indice/test_fixingsnapshot.cpp b/dal-cpp/tests/indice/test_fixingsnapshot.cpp index 9d3cc386f..9c9e6a701 100644 --- a/dal-cpp/tests/indice/test_fixingsnapshot.cpp +++ b/dal-cpp/tests/indice/test_fixingsnapshot.cpp @@ -2,9 +2,9 @@ // Created by dal-implementer on 2026/7/13. // +#include #include -#include #include #include #include diff --git a/dal-cpp/tests/time/test_schedules.cpp b/dal-cpp/tests/time/test_schedules.cpp index 15931f79d..f9ee0707b 100644 --- a/dal-cpp/tests/time/test_schedules.cpp +++ b/dal-cpp/tests/time/test_schedules.cpp @@ -2,9 +2,9 @@ // Created by wegam on 2022/10/2. // +#include #include -#include #include #include #include diff --git a/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm-plan-corrections.md b/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm-plan-corrections.md deleted file mode 100644 index 62b274adb..000000000 --- a/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm-plan-corrections.md +++ /dev/null @@ -1,93 +0,0 @@ -# XCCY Resettable/MTM Implementation Plan — Normative Corrections - -This file is part of -`docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md`. Apply these corrections -when executing the plan. They resolve issues found by the writing-plans type and benchmark -self-review; the Windows sandbox prevented in-place updates to the newly created plan. - -## 1. Core Joint Spec Uses Core Fields - -Replace the `JointXccyCalibrationSpec_` definition in Task 6 with: - -```cpp -struct JointXccyCalibrationSpec_ { - DateTime_ valuationTime_; - CurrencyPair_ pair_; - Ccy_ collateralCurrency_; - double fxSpot_ = 0.0; - JointCurrencyCurveSpec_ domestic_; - JointCurrencyCurveSpec_ foreign_; - XccyBasisCurveDeclaration_ basis_; - Handle_ fixings_; - double tolerance_ = 1.0e-8; - double fitTolerance_ = 1.0e-6; - double initialGuess_ = 0.0; - int maxEvaluations_ = 200; - int maxRestarts_ = 20; - CurveSolveMode_ solveMode_ = CurveSolveMode_::Value_::EXACT; -}; -``` - -Core `dal-cpp` must not depend on `CurveSolverOptions_`, which is declared in -`dal-public/src/curvespec.hpp`. In Task 7, `JointXccyCalibrationSpecBuilder_` may own one -public `CurveSolverOptions_`; its `Build()` method copies those values into the flat core -fields above. - -## 2. Use `std::optional` - -The `MarketFixingSnapshot_::Find` signature and every local fixing lookup in Tasks 2 and -4 use `std::optional`, not an unqualified `optional`: - -```cpp -[[nodiscard]] std::optional Find(const String_& indexName, - const DateTime_& fixingTime) const; -``` - -Include `` in `fixingsnapshot.hpp` and `xccypricing.hpp` where required. - -## 3. Construct Global Fixing Test Data Explicitly - -Replace the `FixHistory_({{...}})` expressions in Task 2 with C++17-compatible aggregate -setup: - -```cpp -FixHistory_ first; -first.vals_ = {{fixing, 1.10}}; -XGLOBAL::StoreFixings("FX[EUR/USD]", first, false); - -const auto snapshot = SnapshotGlobalFixings({{"FX[EUR/USD]", fixing}}); - -FixHistory_ replacement; -replacement.vals_ = {{fixing, 1.20}}; -XGLOBAL::StoreFixings("FX[EUR/USD]", replacement, false); -ASSERT_NEAR(snapshot->Require("FX[EUR/USD]", fixing, "test"), 1.10, 1.0e-12); -``` - -## 4. Capture a Legacy XCCY Performance Baseline Before Task 1 - -Execute the following benchmark phase before changing production code: - -1. Create `dal-cpp/benchmarks/xccy_perf/` with only baseline-compatible workloads: - fixed 10Y precompute, fixed 10Y passive pricing, fixed ten-instrument basket, and the - existing basis-only calibration. -2. Register and build `xccy_perf` at the current feature-start commit. -3. Run at least 10 samples in Release and retain min/median/max plus workload sizes. -4. Commit the baseline-compatible benchmark separately with message - `perf(xccy): establish fixed pricing baseline`. - -Task 10 then extends that same target with resettable, MTM, in-progress, analytic/bumped, -and three-block workloads. Compare unchanged fixed rows against the feature-start commit, -not against a merge base that lacks the target. Continue to compare the eight standard -DAL benchmark gates against the merge base with `master` using the paired interleaved -protocol. - -## 5. Benchmark Completion Evidence - -The final performance report contains two baselines: - -- `xccy_perf` fixed rows: feature-start benchmark commit versus final branch; -- the eight standard DAL gates: merge base with `master` versus final branch. - -New reset/MTM and three-block rows publish absolute results and become the first future -baseline. A greater-than-4% delta is actionable only after paired, interleaved runs with at -least 10 samples per binary. diff --git a/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md b/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md deleted file mode 100644 index 582b1cac1..000000000 --- a/docs/superpowers/plans/2026-07-13-xccy-resettable-mtm.md +++ /dev/null @@ -1,922 +0,0 @@ -# XCCY Resettable/MTM Notional and In-Progress Trades Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add fixed, resettable, and mark-to-market XCCY notionals; deterministic fixing-aware valuation of in-progress trades; staged basis calibration; and simultaneous domestic/foreign/basis calibration with analytic Jacobians across C++, Python, and Excel. - -**Architecture:** Precompute an immutable XCCY cashflow plan, resolve historical values from one immutable market-fixing snapshot, and value the plan through one scalar-templated pricing kernel. Basis-only and three-block calibration both call that kernel; the joint solver builds all active curves from one parameter vector and never nests calibration calls. - -**Tech Stack:** C++17, DAL curve/discount abstractions, DAL AAD tape and `Underdetermined` solver, Google Test, pybind11/pytest, Machinist-generated Excel bindings, CMake, and DAL's `Bench::Run` micro-benchmark harness. - -## Global Constraints - -- `XccyNotionalMode_` has exactly `FIXED`, `RESETTABLE`, and `MARK_TO_MARKET`; do not retain two independent notional-mode booleans. -- The foreign notional is fixed. The domestic coupon notional resets from the second domestic accrual period at `foreignNotional * FX(fixingTime)`. -- `MARK_TO_MARKET` additionally exchanges `newDomesticNotional - previousDomesticNotional` on the reset effective date. -- Reset effective dates equal domestic coupon accrual starts. Independent reset schedules, intraperiod resets, and foreign-leg resets are out of scope. -- Pricing and collateral currency must equal the currency pair's domestic currency. Reject third-currency collateral. -- One immutable `MarketFixingSnapshot_` carries domestic rate, foreign rate, and FX reset fixings. -- A fixing strictly before valuation time is mandatory; at valuation time use supplied fixing or the active forward; after valuation time use the active forward. -- Exclude cashflows with `paymentDate < valuationDate`; include `paymentDate == valuationDate` at discount factor one. -- Historical fixings are passive AAD constants. Future rates and FX resets remain differentiable through their active curves. -- Preserve existing fixed-notional C++/Python builders and the staged basis-only calibration entry point. -- The three-block solve uses one parameter vector ordered as domestic curves, foreign curves, then basis; residuals are domestic instruments, foreign instruments, then XCCY instruments. -- Analytic Jacobian requests on the new XCCY paths fail with an eligibility reason instead of silently changing to bumped mode. -- C++ follows `.clang-format` and `.codex/skills/dal-agent-team/references/shared-rules.md`. Tests use `TEST`, `ASSERT_*`, and explicit AAD tape cleanup. -- Functional and Jacobian tests must pass before benchmark measurement. - ---- - -## File Map - -### Core protocol, fixings, and pricing - -- Create `dal-cpp/dal/curve/xccynotionalmode.hpp`: Machinist enum source. -- Modify `dal-cpp/dal/protocol/rateconvention.hpp`: remove the two notional booleans from `CrossCurrencyConvention_`. -- Modify `dal-cpp/dal/curve/xccyinstrument.hpp`: fixing identities, reset convention, swap config, cashflow-plan access, and legacy constructor adapter. -- Modify `dal-cpp/dal/curve/xccyinstrument.cpp`: plan construction and passive `Rate_` adapter. -- Create `dal-cpp/dal/indice/fixingsnapshot.hpp` and `.cpp`: immutable normalized snapshot and global-store capture. -- Modify `dal-cpp/dal/indice/index/fx.hpp` and `.cpp`: public canonical direct/reverse FX index-name helpers. -- Create `dal-cpp/dal/curve/xccypricing.hpp` and `.cpp`: cashflow-plan structs, typed market view, fixing resolution, and pricing kernel. - -### Calibration - -- Modify `dal-cpp/dal/curve/xccycalibration.hpp` and `.cpp`: valuation context, domestic collateral currency, fixing snapshot, analytic/bumped options, and kernel integration. -- Create `dal-cpp/dal/curve/jointcalibration_internal.hpp`: reusable curve-slot, parameter slicing, curve construction, smoothing, and diagnostics helpers extracted from `jointcalibration.cpp`. -- Modify `dal-cpp/dal/curve/jointcalibration.cpp`: consume the extracted helpers without behavior change. -- Create `dal-cpp/dal/curve/xccyjointcalibration.hpp` and `.cpp`: the single domestic/foreign/basis solve and layout metadata. - -### Tests and surfaces - -- Create `dal-cpp/tests/indice/test_fixingsnapshot.cpp`. -- Create `dal-cpp/tests/curve/test_xccypricing.cpp`. -- Modify `dal-cpp/tests/curve/test_xccymarket.cpp`. -- Create `dal-cpp/tests/curve/test_xccyjointcalibration.cpp` and `test_xccyjointjacobian.cpp`. -- Modify `dal-cpp/tests/currency/test_currencydata.cpp`. -- Modify `dal-public/src/curveprotocol.hpp`, `curveinstrument.hpp`, `xccycalibration.hpp`, and `xccycalibration.cpp`. -- Modify `dal-public/tests/test_curve_protocol.cpp`, `test_curve_instrument.cpp`, and `test_xccy_calibration.cpp`. -- Modify `dal-python/src/bindings/curve.cpp`; create `dal-python/tests/test_xccy_resettable.py` and `test_xccy_joint.py`. -- Modify `dal-excel/src/__curve_storable.hpp`, `__curveprotocol.cpp`, `__curveinstrument.cpp`, and `__xccycalibration.cpp`; regenerate core and Excel `dal-*/auto/MG_*` outputs. - -### Benchmark and documentation - -- Create `dal-cpp/benchmarks/xccy_perf/CMakeLists.txt` and `xccy_perf.cpp`; modify `dal-cpp/benchmarks/CMakeLists.txt`. -- Create `dal-cpp/examples/xccy_mtm_calibration/CMakeLists.txt` and `xccy_mtm_calibration.cpp`; modify `dal-cpp/examples/CMakeLists.txt`. -- Modify `docs/methodology/xccy_calibration.md`, `docs/methodology/yield_curve_jacobian.md`, `docs/public-api.md`, `docs/README.md`, `dal-python/README.md`, `dal-excel/README.md`, and `CHANGELOG.md`. - ---- - -### Task 1: Replace Boolean Notional Flags with a Generated Mode and Config Types - -**Files:** -- Create: `dal-cpp/dal/curve/xccynotionalmode.hpp` -- Modify: `dal-cpp/dal/protocol/rateconvention.hpp` -- Modify: `dal-cpp/dal/curve/xccyinstrument.hpp` -- Modify: `dal-cpp/tests/currency/test_currencydata.cpp` -- Modify: `dal-cpp/tests/curve/test_xccymarket.cpp` -- Generate: `dal-cpp/dal/auto/MG_XccyNotionalMode_enum.hpp` -- Generate: `dal-cpp/dal/auto/MG_XccyNotionalMode_enum.inc` -- Generate: matching `dal-excel/auto/MG_XccyNotionalMode_*` artifacts - -**Interfaces:** -- Produces: `XccyNotionalMode_`, `FixingIdentity_`, `FxResetConvention_`, and `CrossCurrencySwapConfig_`. -- Preserves: the current `CrossCurrencySwap_` constructor as a `FIXED` adapter until the public facade migrates. - -- [ ] **Step 1: Write the failing enum/config tests** - -Add assertions that the currency default is one single mode and that reset timing is explicit: - -```cpp -TEST(CurrencyDataTest, TestXcsDefaultConventionHasNoNotionalModeBooleans) { - const CrossCurrencyConvention_& xcs = Ccy::Conventions::Xcs()(Ccy_("USD")); - ASSERT_TRUE(xcs.initialNotionalExchange_); - ASSERT_TRUE(xcs.finalNotionalExchange_); - ASSERT_TRUE(xcs.spreadOnForeignLeg_); -} - -TEST(XccyMarketTest, TestResetConfigRequiresExplicitFixingIdentity) { - CrossCurrencySwapConfig_ config; - config.notionalMode_ = XccyNotionalMode_::Value_::RESETTABLE; - ASSERT_THROW(static_cast(CrossCurrencySwap_(Date_(2024, 1, 2), Date_(2024, 1, 4), Date_(2025, 1, 4), - 0.0, config)), - Dal::Exception_); -} -``` - -- [ ] **Step 2: Build to verify the new types are missing** - -Run: `cmake --build build --target dal_cpp_tests -j 4` - -Expected: compilation fails because `CrossCurrencySwapConfig_` and `XccyNotionalMode_` are undefined. - -- [ ] **Step 3: Add Machinist enum markup and core config structs** - -Create `xccynotionalmode.hpp` with: - -```cpp -/*IF-------------------------------------------------------------------------- -enumeration XccyNotionalMode - Cross-currency notional evolution rule -switchable -alternative FIXED -alternative RESETTABLE -alternative MARK_TO_MARKET --IF-------------------------------------------------------------------------*/ - -#pragma once -#include -``` - -In `xccyinstrument.hpp`, add: - -```cpp -struct FixingIdentity_ { - String_ indexName_; - int fixingHour_ = -1; - int fixingMinute_ = -1; -}; - -struct FxResetConvention_ { - int fixingLag_ = -1; - Holidays_ fixingHolidays_; - BizDayConvention_ fixingConvention_ = BizDayConvention_("Preceding"); - int fixingHour_ = -1; - int fixingMinute_ = -1; -}; - -struct CrossCurrencySwapConfig_ { - CurrencyPair_ pair_; - double domesticNotional_ = 100.0; - double foreignNotional_ = 100.0; - CrossCurrencyConvention_ convention_; - XccyNotionalMode_ notionalMode_ = XccyNotionalMode_::Value_::FIXED; - FxResetConvention_ fxReset_; - FixingIdentity_ domesticRateFixing_; - FixingIdentity_ foreignRateFixing_; -}; -``` - -Delete `resettableNotional_` and `markToMarketNotional_` from `CrossCurrencyConvention_`. Add constructor validation: positive finite notionals, distinct pair currencies, valid maturity, and explicit reset lag/time for non-`FIXED` modes. - -- [ ] **Step 4: Regenerate and check generated sources** - -Run: `cmake --build build --target dal_generate -j 4` - -Run: `cmake --build build --target dal_check_generated -j 4` - -Expected: both commands succeed and core plus Excel generated enum files are present. - -- [ ] **Step 5: Run the protocol tests** - -Run: `bin/dal_cpp_tests --gtest_filter=CurrencyDataTest.*:XccyMarketTest.TestResetConfigRequiresExplicitFixingIdentity` - -Expected: all selected tests pass. - -- [ ] **Step 6: Commit the protocol unit** - -```bash -git add dal-cpp/dal/curve/xccynotionalmode.hpp dal-cpp/dal/protocol/rateconvention.hpp dal-cpp/dal/curve/xccyinstrument.hpp dal-cpp/dal/auto dal-excel/auto dal-cpp/tests/currency/test_currencydata.cpp dal-cpp/tests/curve/test_xccymarket.cpp -git commit -m "feat(xccy): define notional modes and reset config" -``` - ---- - -### Task 2: Add Immutable Market Fixing Snapshots - -**Files:** -- Create: `dal-cpp/dal/indice/fixingsnapshot.hpp` -- Create: `dal-cpp/dal/indice/fixingsnapshot.cpp` -- Modify: `dal-cpp/dal/indice/index/fx.hpp` -- Modify: `dal-cpp/dal/indice/index/fx.cpp` -- Create: `dal-cpp/tests/indice/test_fixingsnapshot.cpp` - -**Interfaces:** -- Consumes: `CurrencyPair_`, `DateTime_`, and the existing `Global::Fixings_::History` store. -- Produces: - -```cpp -struct FixingRequest_ { String_ indexName_; DateTime_ fixingTime_; }; - -class MarketFixingSnapshot_ { -public: - using history_t = std::map; - using values_t = std::map; - explicit MarketFixingSnapshot_(const values_t& values = values_t()); - [[nodiscard]] optional Find(const String_& indexName, const DateTime_& fixingTime) const; - [[nodiscard]] double Require(const String_& indexName, const DateTime_& fixingTime, const String_& context) const; -}; - -Handle_ SnapshotGlobalFixings(const Vector_& requests); -String_ FxIndexName(const CurrencyPair_& pair); -String_ ReverseFxIndexName(const CurrencyPair_& pair); -``` - -- [ ] **Step 1: Write failing snapshot tests** - -Cover direct lookup, reverse FX reciprocal, consistent/inconsistent two-way quotes, invalid numbers, missing required values, and global-store immutability: - -```cpp -TEST(FixingSnapshotTest, TestSnapshotDoesNotObserveLaterGlobalMutation) { - const DateTime_ fixing(Date_(2024, 1, 2), 11, 0); - XGLOBAL::StoreFixings("FX[EUR/USD]", FixHistory_({{fixing, 1.10}}), false); - const auto snapshot = SnapshotGlobalFixings({{"FX[EUR/USD]", fixing}}); - XGLOBAL::StoreFixings("FX[EUR/USD]", FixHistory_({{fixing, 1.20}}), false); - ASSERT_NEAR(snapshot->Require("FX[EUR/USD]", fixing, "test"), 1.10, 1.0e-12); -} -``` - -- [ ] **Step 2: Run the test and verify failure** - -Run: `cmake --build build --target dal_cpp_tests -j 4` - -Expected: compilation fails because `fixingsnapshot.hpp` and its types do not exist. - -- [ ] **Step 3: Implement normalized immutable storage** - -The constructor copies input, checks every value with `std::isfinite(value) && value > 0.0`, and checks direct/reverse FX pairs with: - -```cpp -REQUIRE(std::fabs(direct * reverse - 1.0) <= 1.0e-10, - "Inconsistent direct/reverse FX fixings for " + directName + " at " + DateTime::ToString(fixingTime)); -``` - -`SnapshotGlobalFixings` deduplicates requests, reads the global histories once, and stores only requested timestamps. `Require` names the index, timestamp, and supplied context in its exception. - -- [ ] **Step 4: Expose canonical FX names** - -Move the current private naming formula into free functions used by both `Index::Fx_` and the snapshot resolver: - -```cpp -String_ FxIndexName(const Ccy_& domestic, const Ccy_& foreign) { - return String_("FX[") + foreign.String() + "/" + domestic.String() + "]"; -} -``` - -- [ ] **Step 5: Run fixing tests** - -Run: `bin/dal_cpp_tests --gtest_filter=FixingSnapshotTest.*:IndexFxTest.*` - -Expected: all selected tests pass. - -- [ ] **Step 6: Commit the snapshot unit** - -```bash -git add dal-cpp/dal/indice/fixingsnapshot.hpp dal-cpp/dal/indice/fixingsnapshot.cpp dal-cpp/dal/indice/index/fx.hpp dal-cpp/dal/indice/index/fx.cpp dal-cpp/tests/indice/test_fixingsnapshot.cpp -git commit -m "feat(fixings): add immutable market snapshots" -``` - ---- - -### Task 3: Precompute XCCY Cashflow and Reset Plans - -**Files:** -- Create: `dal-cpp/dal/curve/xccypricing.hpp` -- Create: `dal-cpp/dal/curve/xccypricing.cpp` -- Modify: `dal-cpp/dal/curve/xccyinstrument.hpp` -- Modify: `dal-cpp/dal/curve/xccyinstrument.cpp` -- Create: `dal-cpp/tests/curve/test_xccypricing.cpp` - -**Interfaces:** -- Consumes: `CrossCurrencySwapConfig_`, `BuildLegPeriods`, and `SchedulePeriod_`. -- Produces: - -```cpp -struct XccyCouponPeriod_ { - SchedulePeriod_ schedule_; - AccrualPeriod_ accrual_; - String_ rateIndexName_; - DateTime_ rateFixingTime_; -}; - -struct XccyResetEvent_ { - Date_ effectiveDate_; - DateTime_ fxFixingTime_; - int domesticPeriodIndex_ = 0; -}; - -struct XccyCashflowPlan_ { - Vector_ domesticPeriods_; - Vector_ foreignPeriods_; - Vector_ resets_; - CrossCurrencySwapConfig_ config_; - Date_ start_; - Date_ maturity_; -}; - -XccyCashflowPlan_ BuildXccyCashflowPlan(const Date_& start, const Date_& maturity, const CrossCurrencySwapConfig_& config); -Vector_ RequiredHistoricalFixings(const XccyCashflowPlan_& plan, const DateTime_& valuationTime); -``` - -- [ ] **Step 1: Write failing plan tests** - -Add exact assertions for quarterly periods, a short stub, fixing lag/holiday adjustment, first-period no-reset behavior, second-period reset, and MTM event dates: - -```cpp -TEST(XccyPricingTest, TestQuarterlyMtmPlanResetsFromSecondPeriod) { - const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2025, 1, 4), MakeQuarterlyMtmConfig()); - ASSERT_EQ(plan.domesticPeriods_.size(), 4U); - ASSERT_EQ(plan.resets_.size(), 3U); - ASSERT_EQ(plan.resets_[0].effectiveDate_, plan.domesticPeriods_[1].schedule_.accrualStart_); - ASSERT_EQ(plan.resets_[0].domesticPeriodIndex_, 1); -} -``` - -- [ ] **Step 2: Run and confirm failure** - -Run: `cmake --build build --target dal_cpp_tests -j 4` - -Expected: compilation fails because the plan API is missing. - -- [ ] **Step 3: Implement plan construction only** - -Build both coupon schedules with `BuildLegPeriods`, combine each schedule fixing date with the configured hour/minute, and generate reset events only for domestic indices `1..N-1`. Do not access curves or fixings in this function. - -For `FIXED`, return an empty reset vector. For non-fixed modes, validate each reset effective date equals the target domestic accrual start and that reset events are strictly increasing. - -- [ ] **Step 4: Implement fixing-request enumeration** - -Filter coupons with `paymentDate >= valuationTime.Date()`. Add rate requests only when `rateFixingTime_ < valuationTime`; add FX requests only when `fxFixingTime_ < valuationTime`. Sort by index then time and deduplicate. - -- [ ] **Step 5: Run plan tests** - -Run: `bin/dal_cpp_tests --gtest_filter=XccyPricingTest.Test*Plan*:XccyPricingTest.TestRequiredHistoricalFixings*` - -Expected: all selected tests pass. - -- [ ] **Step 6: Commit the planning unit** - -```bash -git add dal-cpp/dal/curve/xccypricing.hpp dal-cpp/dal/curve/xccypricing.cpp dal-cpp/dal/curve/xccyinstrument.hpp dal-cpp/dal/curve/xccyinstrument.cpp dal-cpp/tests/curve/test_xccypricing.cpp -git commit -m "feat(xccy): precompute reset and cashflow plans" -``` - ---- - -### Task 4: Implement the Typed Pricing Kernel and In-Progress Valuation - -**Files:** -- Modify: `dal-cpp/dal/curve/xccypricing.hpp` -- Modify: `dal-cpp/dal/curve/xccypricing.cpp` -- Modify: `dal-cpp/dal/curve/xccyinstrument.cpp` -- Modify: `dal-cpp/tests/curve/test_xccypricing.cpp` -- Modify: `dal-cpp/tests/curve/test_xccymarket.cpp` - -**Interfaces:** -- Consumes: `XccyCashflowPlan_`, `MarketFixingSnapshot_`, and domestic/foreign/basis typed curves. -- Produces: - -```cpp -template struct XccyMarketView_ { - DateTime_ valuationTime_; - CurrencyPair_ pair_; - Ccy_ collateralCurrency_; - T_ fxSpot_; - const Tape::JointCurveBlock_* domestic_ = nullptr; - const Tape::JointCurveBlock_* foreign_ = nullptr; - const Tape::DiscountCurve_* basis_ = nullptr; -}; - -template -T_ PriceXccyParSpread(const XccyCashflowPlan_& plan, - const XccyMarketView_& market, - const MarketFixingSnapshot_& fixings); -``` - -- [ ] **Step 1: Write failing fixing-source tests** - -Cover past/equal/future rate and FX timestamps. Assert missing historical values throw and future values respond to changed forecast/basis curves. - -- [ ] **Step 2: Write the failing hand-calculated MTM test** - -Use two domestic periods, fixed foreign notional `100`, initial domestic notional `110`, second-period FX fixing `1.20`, unit discount factors, zero floating coupons, and enabled initial/final exchanges. Assert the domestic MTM delta is `+10`, the final domestic exchange is `120`, and the kernel par-spread numerator matches the explicit signed cashflow sum. - -```cpp -ASSERT_NEAR(resolved.domesticNotionals_[0], 110.0, 1.0e-12); -ASSERT_NEAR(resolved.domesticNotionals_[1], 120.0, 1.0e-12); -ASSERT_NEAR(resolved.mtmDeltas_[0], 10.0, 1.0e-12); -ASSERT_NEAR(actualParSpread, explicitNumerator / explicitForeignAnnuity, 1.0e-12); -``` - -- [ ] **Step 3: Write failing in-progress tests** - -Construct a trade with one paid coupon, one past-fixed/future-paid coupon, one valuation-date payment, and one future coupon. Assert the first is absent, the second uses the snapshot, the valuation-date discount factor is one, and the future coupon changes with its forecast curve. - -- [ ] **Step 4: Implement scalar-templated resolution** - -Use this branch rule for rate and FX values: - -```cpp -if (fixingTime < market.valuationTime_) - return T_(fixings.Require(indexName, fixingTime, context)); -if (fixingTime == market.valuationTime_) { - const optional supplied = fixings.Find(indexName, fixingTime); - if (supplied) - return T_(*supplied); -} -return activeForward(); -``` - -Resolve domestic notionals first, then coupons and notional events. For `RESETTABLE`, omit intermediate deltas. For `MARK_TO_MARKET`, discount each delta on its effective date. Filter historical payment events before fixing lookup. - -- [ ] **Step 5: Replace the fixed-only `CrossCurrencySwapRate_` body** - -Keep `CrossCurrencySwap_::Rate_::operator()(const CrossCurrencyMarket_&)` for compatibility, but make it adapt the passive market to `XccyMarketView_` and call `PriceXccyParSpread`. Remove the two “not implemented” checks and the “in-progress swaps not supported” check. - -- [ ] **Step 6: Run pricing tests** - -Run: `bin/dal_cpp_tests --gtest_filter=XccyPricingTest.*:XccyMarketTest.TestCrossCurrencySwap*` - -Expected: fixing, manual MTM, in-progress, fixed regression, missing-fixing, and matured-trade tests pass. - -- [ ] **Step 7: Commit the pricing unit** - -```bash -git add dal-cpp/dal/curve/xccypricing.hpp dal-cpp/dal/curve/xccypricing.cpp dal-cpp/dal/curve/xccyinstrument.cpp dal-cpp/tests/curve/test_xccypricing.cpp dal-cpp/tests/curve/test_xccymarket.cpp -git commit -m "feat(xccy): price reset and in-progress cashflows" -``` - ---- - -### Task 5: Integrate the Kernel into Basis-Only Calibration and Add Analytic Jacobians - -**Files:** -- Modify: `dal-cpp/dal/curve/xccycalibration.hpp` -- Modify: `dal-cpp/dal/curve/xccycalibration.cpp` -- Modify: `dal-cpp/tests/curve/test_xccymarket.cpp` -- Create: `dal-cpp/tests/curve/test_xccybasisjacobian.cpp` - -**Interfaces:** -- Adds to `CrossCurrencyCalibrationSpec_`: `DateTime_ valuationTime_`, `Ccy_ collateralCurrency_`, and `Handle_ fixings_`. -- Produces: - -```cpp -struct CrossCurrencyCalibrationOptions_ { - CurveJacobianMode_ jacobianMode_ = CurveJacobianMode_::Value_::ANALYTIC; - bool computeEffJacobianInverse_ = true; - bool computeForwardJacobian_ = true; -}; - -CrossCurrencyCalibrationResult_ CalibrateCrossCurrencyMarket( - const CrossCurrencyCalibrationSpec_& spec, - const CrossCurrencyCalibrationOptions_& options); -``` - -- [ ] **Step 1: Write failing staged-calibration tests** - -Add one future-start MTM instrument and one in-progress MTM instrument with a supplied snapshot. Generate quotes from a known flat basis, calibrate from zero, and assert both model rates reprice within `1.0e-8`. - -- [ ] **Step 2: Write the failing basis Jacobian comparison** - -At a fixed parameter vector, evaluate `XccyCalibrationFunc_::Gradient` in analytic mode and compare every element with central differences using `h = 1.0e-6` and tolerance `1.0e-7`. Assert columns associated only with historical FX resets do not acquire extra fixing sensitivity. - -- [ ] **Step 3: Run and verify failure** - -Run: `cmake --build build --target dal_cpp_tests -j 4` - -Expected: compilation fails because the options overload and analytic XCCY residual path are missing. - -- [ ] **Step 4: Make valuation context explicit** - -Stop reading `Global::Dates_::EvaluationDate()` inside `CrossCurrencyMarket_`. Store the valuation date/time and collateral currency in the market/spec, validate `collateralCurrency_ == basisPair_.domestic_`, and keep the existing `today_` adapter for callers that do not set `valuationTime_`. - -- [ ] **Step 5: Add the AAD gradient** - -Build the basis curve with `BuildDiscountCurveT`, build passive domestic/foreign typed views, call `PriceXccyParSpread` for every residual, and return `XCurveJacobian_(HarvestCurveJacobian(...))`. If the requested basis parameterization or instrument plan is ineligible, throw an exception naming the reason. - -- [ ] **Step 6: Return diagnostics according to options** - -Pass `effJacobianInverse_` and forward Jacobian pointers to `Underdetermined::Find` only when requested. Keep them empty in approximate mode and state that behavior in result comments. - -- [ ] **Step 7: Run staged and Jacobian tests** - -Run: `bin/dal_cpp_tests --gtest_filter=XccyMarketTest.*:XccyBasisJacobianTest.*` - -Expected: staged fixed/reset/MTM calibration, started-trade calibration, analytic/central-difference comparison, and bumped mode pass. - -- [ ] **Step 8: Commit basis calibration integration** - -```bash -git add dal-cpp/dal/curve/xccycalibration.hpp dal-cpp/dal/curve/xccycalibration.cpp dal-cpp/tests/curve/test_xccymarket.cpp dal-cpp/tests/curve/test_xccybasisjacobian.cpp -git commit -m "feat(xccy): calibrate basis with reset-aware AAD pricing" -``` - ---- - -### Task 6: Add the Three-Block Domestic/Foreign/Basis Joint Solver - -**Files:** -- Create: `dal-cpp/dal/curve/jointcalibration_internal.hpp` -- Modify: `dal-cpp/dal/curve/jointcalibration.cpp` -- Create: `dal-cpp/dal/curve/xccyjointcalibration.hpp` -- Create: `dal-cpp/dal/curve/xccyjointcalibration.cpp` -- Create: `dal-cpp/tests/curve/test_xccyjointcalibration.cpp` -- Create: `dal-cpp/tests/curve/test_xccyjointjacobian.cpp` -- Modify: `dal-cpp/tests/curve/test_joint_calibration.cpp` -- Modify: `dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp` - -**Interfaces:** -- Consumes: `JointCurveDeclaration_`, the typed XCCY kernel, `Underdetermined`, and `MarketFixingSnapshot_`. -- Produces: - -```cpp -struct CalibrationBlockRange_ { - String_ name_; - int offset_ = 0; - int size_ = 0; -}; - -struct JointCurrencyCurveSpec_ { - Ccy_ ccy_; - DayBasis_ liborBasis_ = DayBasis_("ACT_365F"); - Vector_ curves_; -}; - -struct XccyBasisCurveDeclaration_ { - String_ curveName_ = "xccy_basis"; - Vector_> instruments_; - Vector_ knotDates_; - CurveParameterization_ parameterization_ = CurveParameterization_::Value_::PIECEWISE_CONSTANT_FWD; - double smoothingWeight_ = 1.0; - Vector_ initialGuessPerNode_; -}; - -struct JointXccyCalibrationSpec_ { - DateTime_ valuationTime_; - CurrencyPair_ pair_; - Ccy_ collateralCurrency_; - double fxSpot_ = 0.0; - JointCurrencyCurveSpec_ domestic_; - JointCurrencyCurveSpec_ foreign_; - XccyBasisCurveDeclaration_ basis_; - Handle_ fixings_; - CurveSolverOptions_ solver_; -}; -``` - -`JointXccyCalibrationResult_` contains domestic/foreign curve-block handles, basis curve, FX forwards, grouped diagnostics, `jacobianAtSolution_`, `effJacobianInverse_`, and vectors of parameter/residual `CalibrationBlockRange_`. - -- [ ] **Step 1: Write a failing synthetic recovery test** - -Build known domestic and foreign OIS/3M curves plus a known basis curve. Quote domestic instruments, foreign instruments, and 1Y–5Y resettable/MTM XCCY swaps from that market. Start all curve parameters away from the truth, solve once, and assert every instrument group reprices within `1.0e-8`. - -- [ ] **Step 2: Write failing validation tests** - -Cover mismatched currencies, third-currency collateral, unordered knots, missing curve slots, no remaining XCCY annuity, invalid spot, duplicate declarations, and empty instrument groups. Error assertions must match the offending pair or slot name. - -- [ ] **Step 3: Write the failing stacked Jacobian test** - -Compare analytic and central-difference matrices for every domestic, foreign, and basis column. Assert the range metadata exactly partitions all columns and rows without gaps or overlap. - -- [ ] **Step 4: Extract reusable joint-curve helpers without changing existing behavior** - -Move slot definitions, validation, parameter slicing, typed curve construction, smoothing assembly, and diagnostics from the anonymous namespace in `jointcalibration.cpp` into `jointcalibration_internal.hpp`. Keep the original `CalibrateJointMultiCurve` public types and output unchanged. - -Run: `bin/dal_cpp_tests --gtest_filter=JointCalibrationTest.*:JointAnalyticJacobianTest.*` - -Expected: all existing joint tests pass before adding XCCY solve logic. - -- [ ] **Step 5: Implement one residual function** - -Build the unknown vector in this exact order: - -```text -domestic declaration 0..N | foreign declaration 0..M | basis nodes -``` - -Build one active domestic block, one active foreign block, and one active basis curve per evaluation. Evaluate domestic and foreign `YCInstrument_` residuals through their existing typed rates, then XCCY residuals through `PriceXccyParSpread`. Do not call `CalibrateJointMultiCurve` or `CalibrateCrossCurrencyMarket` from the residual function. - -- [ ] **Step 6: Add exact, approximate, analytic, and bumped paths** - -Use block-diagonal smoothing over all declarations. Exact mode returns both effective inverse and optional forward Jacobian; approximate mode returns neither inverse and marks diagnostics accordingly. Analytic eligibility must include every domestic/foreign instrument plus every XCCY plan. - -- [ ] **Step 7: Run joint XCCY tests** - -Run: `bin/dal_cpp_tests --gtest_filter=XccyJointCalibrationTest.*:XccyJointJacobianTest.*:JointCalibrationTest.*:JointAnalyticJacobianTest.*` - -Expected: synthetic recovery, validation, exact/approximate, analytic/bumped, layout, and all pre-existing joint tests pass. - -- [ ] **Step 8: Commit the joint solver unit** - -```bash -git add dal-cpp/dal/curve/jointcalibration_internal.hpp dal-cpp/dal/curve/jointcalibration.cpp dal-cpp/dal/curve/xccyjointcalibration.hpp dal-cpp/dal/curve/xccyjointcalibration.cpp dal-cpp/tests/curve/test_xccyjointcalibration.cpp dal-cpp/tests/curve/test_xccyjointjacobian.cpp dal-cpp/tests/curve/test_joint_calibration.cpp dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp -git commit -m "feat(xccy): jointly calibrate domestic foreign and basis curves" -``` - ---- - -### Task 7: Add Public C++ Builders and Compatibility Tests - -**Files:** -- Modify: `dal-public/src/curveprotocol.hpp` -- Modify: `dal-public/src/curveinstrument.hpp` -- Modify: `dal-public/src/xccycalibration.hpp` -- Modify: `dal-public/src/xccycalibration.cpp` -- Modify: `dal-public/tests/test_curve_protocol.cpp` -- Modify: `dal-public/tests/test_curve_instrument.cpp` -- Modify: `dal-public/tests/test_xccy_calibration.cpp` - -**Interfaces:** -- Produces: `FxResetConventionNew`, `MarketFixingSnapshotNew`, `CrossCurrencySwapConfigBuilder_`, the config overload of `CrossCurrencySwapNew`, `JointXccyCalibrationSpecBuilder_`, `CalibrateJointXccyMarket`, and result accessors. -- Preserves: the current long `CrossCurrencySwapNew` overload as `FIXED`. - -- [ ] **Step 1: Write failing builder round-trip tests** - -Set every reset, fixing identity, collateral, snapshot, curve declaration, solver, and instrument field; call `Build()`; assert each core spec field is preserved. Add a compile-and-run test for the legacy long overload. - -- [ ] **Step 2: Run and verify failure** - -Run: `cmake --build build --target dal_public_tests -j 4` - -Expected: compilation fails because the new public builders are undefined. - -- [ ] **Step 3: Implement config-focused public factories** - -Keep required dates/rate as function arguments and move optional behavior into config: - -```cpp -Handle_ CrossCurrencySwapNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const CrossCurrencySwapConfig_& config); -``` - -`MarketFixingSnapshotNew` accepts `std::map>`. The joint builder owns flat solver fields only if Python member-pointer compatibility requires it; `Build()` converts them into one `CurveSolverOptions_`. - -- [ ] **Step 4: Run public tests** - -Run: `build/dal-public/dal_public_tests --gtest_filter=CurveProtocolTest.*:CurveInstrumentTest.TestCrossCurrency*:XccyCalibrationTest.*` - -Expected: old and new instrument builders, snapshot, staged builder, joint builder, and result layout pass. - -- [ ] **Step 5: Commit the public API unit** - -```bash -git add dal-public/src/curveprotocol.hpp dal-public/src/curveinstrument.hpp dal-public/src/xccycalibration.hpp dal-public/src/xccycalibration.cpp dal-public/tests/test_curve_protocol.cpp dal-public/tests/test_curve_instrument.cpp dal-public/tests/test_xccy_calibration.cpp -git commit -m "feat(public): expose resettable and joint XCCY builders" -``` - ---- - -### Task 8: Add Python Bindings and Tests - -**Files:** -- Modify: `dal-python/src/bindings/curve.cpp` -- Create: `dal-python/tests/test_xccy_resettable.py` -- Create: `dal-python/tests/test_xccy_joint.py` -- Modify: `dal-python/tests/test_xccy_calibration.py` - -**Interfaces:** -- Consumes: all public builders and result types from Task 7. -- Produces: Python enum/config/snapshot/joint builder classes and keyword-based functions. - -- [ ] **Step 1: Write failing Python tests** - -Create a nested fixing mapping, build an in-progress MTM swap, assert missing historical values raise, run a small joint calibration, and read parameter/residual ranges plus the Jacobian dimensions: - -```python -snapshot = dal.MarketFixingSnapshot_New({ - "USD-SOFR-3M": {rate_fixing_time: 0.0525}, - "EUR-EURIBOR-3M": {foreign_fixing_time: 0.0310}, - "FX[EUR/USD]": {fx_fixing_time: 1.10}, -}) -assert snapshot is not None -assert result.jacobianAtSolution_.rows() == sum(r.size_ for r in result.residualRanges_) -assert result.jacobianAtSolution_.cols() == sum(r.size_ for r in result.parameterRanges_) -``` - -- [ ] **Step 2: Run and verify failure** - -Run: `python -m pytest dal-python/tests/test_xccy_resettable.py dal-python/tests/test_xccy_joint.py -q` - -Expected: import or attribute failures for the new bindings. - -- [ ] **Step 3: Bind enum, configs, snapshots, builders, and results** - -Expose new fields with snake_case aliases while retaining existing underscore field names used by current tests. Convert nested Python dictionaries to the C++ snapshot map and convert instrument lists to `Handle_` collections with the existing const-pointer ownership pattern. - -- [ ] **Step 4: Keep the old Python call unchanged** - -Retain the current `CrossCurrencySwap_New(trade_date, start, maturity, market_rate, currencies, ...)` registration. Add a config overload distinguished by its final argument type; do not reorder the old arguments. - -- [ ] **Step 5: Run Python XCCY tests** - -Run: `python -m pytest dal-python/tests/test_xccy_calibration.py dal-python/tests/test_xccy_resettable.py dal-python/tests/test_xccy_joint.py -q` - -Expected: all selected tests pass. - -- [ ] **Step 6: Commit the Python unit** - -```bash -git add dal-python/src/bindings/curve.cpp dal-python/tests/test_xccy_calibration.py dal-python/tests/test_xccy_resettable.py dal-python/tests/test_xccy_joint.py -git commit -m "feat(python): bind resettable and joint XCCY APIs" -``` - ---- - -### Task 9: Add Excel Handles, Functions, and Generated Sources - -**Files:** -- Modify: `dal-excel/src/__curve_storable.hpp` -- Modify: `dal-excel/src/__curveprotocol.cpp` -- Modify: `dal-excel/src/__curveinstrument.cpp` -- Modify: `dal-excel/src/__xccycalibration.cpp` -- Generate: `dal-excel/auto/MG_XccyResetConvention_New_*` -- Generate: `dal-excel/auto/MG_MarketFixingSnapshot_New_*` -- Generate: `dal-excel/auto/MG_CrossCurrencySwap_Config_New_*` -- Generate: `dal-excel/auto/MG_Calibrate_JointXccy_*` -- Generate: joint result accessor sources and HTML help - -**Interfaces:** -- Produces worksheet functions `XCCYRESETCONVENTION.NEW`, `MARKETFIXINGSNAPSHOT.NEW`, `CROSSCURRENCYSWAP.CONFIG.NEW`, `CALIBRATE.JOINTXCCY`, and joint result accessors. -- Preserves `CROSSCURRENCYSWAP.NEW` and `CALIBRATE.XCCYMARKET` registrations. - -- [ ] **Step 1: Add storable wrappers** - -Add `StorableCrossCurrencySwapConfig_`, `StorableMarketFixingSnapshot_`, and `StorableJointXccyCalibrationResult_`. The result wrapper retains handles to both solved curve blocks and the basis curve so Excel accessors cannot outlive them. - -- [ ] **Step 2: Add Machinist public declarations** - -Use handles for nested configs and results. `MARKETFIXINGSNAPSHOT.NEW` accepts parallel index-name, fixing-datetime, and value arrays and validates equal length before building the normalized map. `CALIBRATE.JOINTXCCY` keeps solver values in the existing two-column settings dictionary. - -- [ ] **Step 3: Implement result accessors** - -Expose `domesticBlock`, `foreignBlock`, `basisCurve`, `fxForwards`, `marketRates`, `modelRates`, `residuals`, `jacobian`, `parameterRanges`, and `residualRanges`. Unknown attributes list all accepted names in the error. - -- [ ] **Step 4: Regenerate and verify generated drift** - -Run: `cmake --build build --target dal_generate -j 4` - -Run: `cmake --build build --target dal_check_generated -j 4` - -Expected: generation and drift check succeed. - -- [ ] **Step 5: Build Excel on Windows** - -Run: `cmake --build build --config Release --target dal_excel` - -Expected: the add-in and all generated public functions compile successfully. - -- [ ] **Step 6: Commit the Excel unit** - -```bash -git add dal-excel/src/__curve_storable.hpp dal-excel/src/__curveprotocol.cpp dal-excel/src/__curveinstrument.cpp dal-excel/src/__xccycalibration.cpp dal-excel/auto dal-cpp/dal/auto -git commit -m "feat(excel): expose resettable and joint XCCY functions" -``` - ---- - -### Task 10: Add `xccy_perf` and Run Regression Benchmarks - -**Files:** -- Create: `dal-cpp/benchmarks/xccy_perf/CMakeLists.txt` -- Create: `dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp` -- Modify: `dal-cpp/benchmarks/CMakeLists.txt` - -**Interfaces:** -- Consumes: passive pricing, basis-only calibration, joint calibration, analytic/bumped options, and immutable snapshots. -- Produces: normalized precompute/price/basket rows and absolute calibration rows through `Bench::Print`. - -- [ ] **Step 1: Register an initially failing benchmark target** - -Add `xccy_perf` to `DAL_BENCHMARK_TARGETS` and create its `CMakeLists.txt` using the same DAL/AAD/thread link pattern as `curve_calibration_perf`. - -Run: `cmake --build build --target xccy_perf -j 4` - -Expected: build fails until `xccy_perf.cpp` is added. - -- [ ] **Step 2: Implement validated pricing workloads** - -Use 3 warmups and 10 repeats. Build 10Y quarterly fixed/resettable/MTM instruments, an in-progress MTM instrument with historical domestic-rate, foreign-rate, and FX fixings, and ten-instrument baskets. Normalize batched results to per-operation or per-instrument nanoseconds. Require finite, nonzero checksums before printing. - -- [ ] **Step 3: Implement validated calibration workloads** - -Use 15 XCCY instruments. Add basis-only exact analytic solve-only, analytic plus diagnostics, and bumped plus diagnostics cases. Add the same three cases for full three-block exact calibration plus one approximate analytic case. Before timing, run once and require every reported residual norm is within the configured tolerance. - -- [ ] **Step 4: Build and smoke-run the new benchmark** - -Run: `cmake --build build --config Release --target xccy_perf -j 4` - -Run: `build/dal-cpp/benchmarks/xccy_perf/xccy_perf` - -Expected: every required named row prints min/median/max/repeat columns and the process exits zero. - -- [ ] **Step 5: Establish the branch/baseline comparison** - -At execution time, use `superpowers:using-git-worktrees` to create a clean merge-base benchmark worktree. Build both baseline and branch in Release. Run paired interleaved samples, baseline then branch then branch then baseline, until each binary has at least 10 samples. Use build-tree binaries only and reduce each row to the best observed minimum. - -Report: - -```text -Benchmark | Baseline min | Branch min | Delta | Verdict | Samples/environment -``` - -Flag only deltas greater than 4%. For reset/MTM and three-block rows that do not exist at merge base, report absolute min/median/max and mark them “new baseline”. - -- [ ] **Step 6: Run the standard DAL regression set** - -Run the same paired protocol for `tape_perf`, `jacobian_perf`, `pde_perf`, `rng_perf`, `interp_perf`, `krylov_perf`, `banded_perf`, and `cholesky_perf`. - -Expected: no repeatable branch regression greater than 4%; otherwise stop packaging and investigate the responsible hot path. - -- [ ] **Step 7: Commit benchmark coverage** - -```bash -git add dal-cpp/benchmarks/CMakeLists.txt dal-cpp/benchmarks/xccy_perf/CMakeLists.txt dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp -git commit -m "perf(xccy): benchmark reset pricing and joint calibration" -``` - ---- - -### Task 11: Add Example, Documentation, Changelog, and Final Verification - -**Files:** -- Create: `dal-cpp/examples/xccy_mtm_calibration/CMakeLists.txt` -- Create: `dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp` -- Modify: `dal-cpp/examples/CMakeLists.txt` -- Modify: `docs/methodology/xccy_calibration.md` -- Modify: `docs/methodology/yield_curve_jacobian.md` -- Modify: `docs/public-api.md` -- Modify: `docs/README.md` -- Modify: `dal-python/README.md` -- Modify: `dal-excel/README.md` -- Modify: `CHANGELOG.md` - -**Interfaces:** -- Consumes: the final public C++, Python, and Excel surfaces. -- Produces: one compilable current-state example and reconciled documentation. - -- [ ] **Step 1: Add a compiling end-to-end example** - -The example must be 20–50 lines in its main call sequence and show: explicit fixing identities, one snapshot containing rate and FX values, an in-progress MTM swap, domestic/foreign/basis declarations, `CalibrateJointXccyMarket`, and Jacobian range output. Generate synthetic quotes from a known market so it runs without external data. - -- [ ] **Step 2: Build and run the example** - -Run: `cmake --build build --target xccy_mtm_calibration -j 4` - -Run: `build/dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration` - -Expected: the program reports converged domestic, foreign, and basis blocks; maximum residual below `1.0e-8`; and nonempty Jacobian layout. - -- [ ] **Step 3: Reconcile methodology and API docs** - -Document the exact notional formulas, reset/fixing timing, payment-date rule, generalized fixing snapshot, domestic-collateral restriction, staged versus joint calibration, parameter/residual layout, and analytic/bumped behavior. Keep historical migration detail out of methodology docs. - -- [ ] **Step 4: Add changelog entry** - -Record the new XCCY capability and the breaking replacement of the two public convention booleans by `XccyNotionalMode_`. Include compatibility of the old convenience builder and new Python/Excel functions. - -- [ ] **Step 5: Run targeted functional suites** - -Run: `bin/dal_cpp_tests --gtest_filter=FixingSnapshotTest.*:XccyPricingTest.*:XccyMarketTest.*:XccyBasisJacobianTest.*:XccyJointCalibrationTest.*:XccyJointJacobianTest.*:JointCalibrationTest.*:JointAnalyticJacobianTest.*` - -Run: `build/dal-public/dal_public_tests --gtest_filter=CurveProtocolTest.*:CurveInstrumentTest.*:XccyCalibrationTest.*` - -Run: `python -m pytest dal-python/tests/test_xccy_calibration.py dal-python/tests/test_xccy_resettable.py dal-python/tests/test_xccy_joint.py -q` - -Expected: every selected suite passes. - -- [ ] **Step 6: Run full build, tests, generation check, and benchmark smoke** - -Run: `bash ./build_linux.sh` - -Run: `cmake --build build --target dal_check_generated -j 4` - -Run: `(cd build && ctest --output-on-failure)` - -Run: `build/dal-cpp/benchmarks/xccy_perf/xccy_perf` - -Expected: full build and CTest pass, no generated drift exists, and the benchmark exits zero. - -- [ ] **Step 7: Run formatting and diff checks** - -Format only changed C++ files with the repository `.clang-format`, then run: - -Run: `git diff --check` - -Expected: no whitespace errors and no unrelated user changes in the diff. - -- [ ] **Step 8: Request DAL review and performance gate** - -Use `dal-reviewer` for correctness, style, bindings, generated files, docs, and merge readiness. Use `dal-performancer` to publish the paired regression table with sample counts and environmental notes. Resolve every blocking or significant finding and rerun the affected verification command. - -- [ ] **Step 9: Commit documentation and example** - -```bash -git add dal-cpp/examples/xccy_mtm_calibration dal-cpp/examples/CMakeLists.txt docs/methodology/xccy_calibration.md docs/methodology/yield_curve_jacobian.md docs/public-api.md docs/README.md dal-python/README.md dal-excel/README.md CHANGELOG.md -git commit -m "docs(xccy): document MTM valuation and joint calibration" -``` - ---- - -## Completion Evidence - -Before declaring the feature complete, retain these artifacts in the implementation handoff: - -- targeted and full test command outputs; -- generated-source drift check output; -- analytic-versus-central-difference Jacobian maximum absolute difference; -- joint synthetic recovery residuals and parameter block layout; -- the `xccy_perf` absolute result table; -- paired branch/baseline tables for existing fixed XCCY cases and the eight standard DAL benchmark gates; -- reviewer verdict and any remaining nonblocking caveats. diff --git a/docs/superpowers/specs/2026-07-13-xccy-in-progress-rate-fixing-addendum.md b/docs/superpowers/specs/2026-07-13-xccy-in-progress-rate-fixing-addendum.md deleted file mode 100644 index 34239aea6..000000000 --- a/docs/superpowers/specs/2026-07-13-xccy-in-progress-rate-fixing-addendum.md +++ /dev/null @@ -1,44 +0,0 @@ -# XCCY In-Progress Trades — Rate Fixing Addendum - -This addendum is part of -`docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-design.md` and is normative -for the implementation plan. - -## Generalized Snapshot - -Use one immutable `MarketFixingSnapshot_`, not an FX-only snapshot. Its canonical -mapping is `index name -> DateTime_ -> positive finite value` and it contains: - -- domestic floating-rate fixings; -- foreign floating-rate fixings; -- FX reset fixings. - -The XCCY swap configuration carries explicit canonical domestic and foreign rate-index -names. A resettable or mark-to-market configuration also identifies the canonical FX -index through its currency pair. No canonical name may be inferred from tenor alone. - -## Coupon Resolution - -For a coupon whose payment date is on or after the valuation date: - -- `rateFixingTime < valuationTime`: a historical rate fixing is mandatory; -- `rateFixingTime == valuationTime`: use a supplied fixing when present, otherwise use - the active forecast curve; -- `rateFixingTime > valuationTime`: use the active forecast curve. - -Historical rate fixings are passive constants in AAD evaluation. Future or today-unfixed -rates retain the active scalar type and curve sensitivities. Coupons whose payment date is -before the valuation date are removed before fixing resolution and therefore do not -require historical fixing data. - -## Validation and Tests - -- Reject a missing canonical rate-index name when an unpaid coupon has a historical - fixing time. -- Reject missing, nonpositive, NaN, or infinite historical rate fixings with instrument, - leg, index name, and fixing timestamp in the message. -- Test a started trade with a past-fixed/future-paid coupon on each leg. -- Verify historical rate-fixing rows have zero curve sensitivity while future coupon rows - retain domestic or foreign forecast-curve sensitivity. -- The in-progress `xccy_perf` workload must include both historical rate fixings and - historical FX reset fixings in one snapshot. diff --git a/docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-benchmark-addendum.md b/docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-benchmark-addendum.md deleted file mode 100644 index 99198e2d7..000000000 --- a/docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-benchmark-addendum.md +++ /dev/null @@ -1,79 +0,0 @@ -# XCCY Resettable/MTM — Benchmark Design Addendum - -This addendum is part of -`docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-design.md` and is normative -for the implementation plan. - -## Benchmark Target - -Add `dal-cpp/benchmarks/xccy_perf/` and register `xccy_perf` in -`dal-cpp/benchmarks/CMakeLists.txt`. A dedicated target is required because -`ycinstrument_perf` does not price XCCY instruments and `curve_calibration_perf` -does not exercise FX-reset resolution, basis calibration, or the three-block joint -solver. - -Every timed workload must first validate finite, nonzero checksums and the expected -repricing tolerance. Batches must be large enough that timer overhead is immaterial. - -## Required Workloads - -### Instrument Planning and Pricing - -- 10Y quarterly `FIXED`, `RESETTABLE`, and `MARK_TO_MARKET` precompute operations. -- Passive price operations for the same three all-future-reset instruments. -- An in-progress 10Y `MARK_TO_MARKET` price containing both historical and future resets - resolved from one immutable fixing snapshot. -- Ten-instrument pricing baskets for each notional mode. - -Report per-operation or per-instrument timings after batch normalization so the three -notional modes are directly comparable. - -### Calibration - -- Basis-only exact calibration with 15 XCCY instruments: - - analytic solve-only; - - analytic plus diagnostics/Jacobian; - - bumped plus diagnostics/Jacobian. -- Full domestic/foreign/basis exact joint calibration: - - analytic solve-only; - - analytic plus diagnostics/Jacobian; - - bumped plus diagnostics/Jacobian. -- Full three-block approximate joint calibration to cover regularized residual - evaluation. - -The analytic and bumped variants must use identical instruments, knots, initial guesses, -solver tolerances, and repricing acceptance. Timing must not compare workloads that do -different numerical work. - -## Regression Protocol - -- Use the merge base with `master` as the baseline unless another baseline is explicitly - selected. -- Build baseline and branch in Release with benchmarks enabled. -- Run binaries from their respective build trees, never from stale installed `bin/` - locations. -- Use paired, interleaved baseline/branch runs with at least 10 samples per binary. -- Reduce to best-of-N minima and treat a delta as actionable only when it exceeds 4%, the - conservative end of DAL's calibrated 2–4% benchmark noise floor. -- Record sample count, environment quietness, min/median/max timings, workload size, and - any inconclusive result. - -## Acceptance Criteria - -- [ ] Existing fixed-notional XCCY pricing and basis-only calibration do not regress by - more than 4% relative to the merge-base implementation. -- [ ] The standard eight DAL gates (`tape_perf`, `jacobian_perf`, `pde_perf`, `rng_perf`, - `interp_perf`, `krylov_perf`, `banded_perf`, and `cholesky_perf`) have no - greater-than-4% branch regression under the same paired protocol. -- [ ] All new `xccy_perf` workloads publish absolute min/median/max results and normalized - per-operation metrics. -- [ ] The first merged reset/MTM and three-block measurements establish the future - regression baseline; they are not compared with nonexistent pre-feature cases. -- [ ] Analytic and bumped calibration cases meet equivalent repricing tolerances before - their timing ratio is reported. - -## Delivery Gate - -Run functional and Jacobian tests before benchmarks. Performance measurement is a -post-correctness gate and must be completed before Python/Excel packaging is declared -finished, so late binding changes cannot hide a core regression. diff --git a/docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-design.md b/docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-design.md deleted file mode 100644 index 7dcdb7e8d..000000000 --- a/docs/superpowers/specs/2026-07-13-xccy-resettable-mtm-design.md +++ /dev/null @@ -1,356 +0,0 @@ -# XCCY Resettable/MTM Notional and In-Progress Trades — Design - -## Source - -- User request and design decisions confirmed on 2026-07-13. -- Related methodology: `docs/methodology/xccy_calibration.md` and `docs/methodology/yield_curve_jacobian.md`. - -## Problem Statement - -The current cross-currency swap implementation supports only fixed domestic and foreign -notionals and rejects valuation after the swap start. It also reads no historical FX -fixings and calibrates only a basis curve over frozen domestic and foreign curve blocks. -The extension must support period-by-period resettable and mark-to-market notionals, -deterministic valuation of in-progress trades, staged basis calibration, and a single -joint solve of domestic, foreign, and basis curves with an analytic residual Jacobian. - -## Goals - -- Define unambiguous notional, FX fixing, reset timing, historical cashflow, and notional - exchange rules. -- Use one pricing formula for passive `double` valuation and active AAD valuation. -- Preserve the existing fixed-notional public builders and basis-only calibration entry - point. -- Add public C++, Python, and Excel surfaces for reset configuration, fixing snapshots, - and three-block joint calibration. -- Make missing or contradictory fixing data fail deterministically before solving. -- Expose enough Jacobian layout metadata that callers do not infer parameter or residual - block positions. - -## Non-Goals - -- Third-currency collateral, collateral switching, or cheapest-to-deliver collateral. -- Resetting the foreign leg. -- Independent reset frequency, intraperiod resets, or reset dates not aligned with the - domestic coupon accrual starts. -- Intraday settlement status for cashflows or separate clean/accrued valuation. -- A general-purpose multi-currency cashflow graph. - -## Product Semantics - -### Notional Modes - -Replace the existing `resettableNotional_` and `markToMarketNotional_` booleans with one -generated enum, `XccyNotionalMode_`: - -- `FIXED`: both notionals remain fixed for the full trade. -- `RESETTABLE`: the foreign notional remains fixed; the domestic coupon notional is reset - for each domestic accrual period. No intermediate notional-difference cashflow is paid. -- `MARK_TO_MARKET`: uses the same reset domestic coupon notionals as `RESETTABLE` and also - exchanges each change in domestic notional on the reset effective date. - -The first domestic coupon period uses the builder-supplied domestic notional. Starting -with the second domestic coupon period, - -```text -domesticNotional[i] = foreignNotional * FX(fixingTime[i]) -``` - -where FX is quoted in domestic currency units per one foreign currency unit. For -`MARK_TO_MARKET`, the domestic notional exchange at the reset effective date is - -```text -domesticNotional[i] - domesticNotional[i - 1] -``` - -with the same receive/pay orientation as the domestic final-notional exchange. The -pricing kernel applies the overall leg signs consistently when forming the par spread. - -### Reset Timing - -- The domestic leg is the resetting leg; the foreign leg notional is fixed. -- Each reset effective date is the corresponding domestic coupon accrual start. -- The FX fixing date is obtained by moving backward by `fixingLag_` business days on - `fixingHolidays_`, then applying `fixingConvention_`. -- The fixing timestamp combines that date with configured `fixingHour_` and - `fixingMinute_`. -- The first period uses the supplied initial domestic notional rather than recomputing it - from a fixing. -- Domestic and foreign coupon frequencies may differ. The domestic reset schedule must - align exactly with domestic coupon accrual starts. - -### Initial, Intermediate, and Final Notional Exchanges - -- `initialNotionalExchange_` controls the builder-supplied initial domestic and foreign - exchanges at the trade start for every notional mode. -- Only `MARK_TO_MARKET` generates intermediate domestic notional-difference exchanges. -- `finalNotionalExchange_` exchanges the last outstanding domestic notional and the fixed - foreign notional at maturity. -- Historical notional exchanges follow the same payment-date filtering rule as coupons. - -## Fixing Data Contract - -### Immutable Snapshot - -`FxFixingSnapshot_` is an immutable mapping from normalized FX index name and `DateTime_` -to a positive finite value. Calibration and valuation consume a snapshot, never live -global fixing state. - -Public builders accept an optional snapshot. If none is supplied, the valuation entry -point copies all required FX fixings from the existing process-wide fixing store once, -before precomputation or solver evaluation. Subsequent global-store changes cannot alter -that valuation or calibration. - -### Naming and Orientation - -- Canonical keys retain the existing `Index::Fx_` naming convention: - `FX[foreign/domestic]` for a domestic-per-foreign quote. -- A reverse fixing may be used by taking its reciprocal. -- If both orientations exist at the same timestamp, their product must equal one within - a fixed numerical tolerance; otherwise snapshot construction fails. -- Zero, negative, NaN, and infinite fixing values are rejected. - -### Historical and Future Resolution - -- `fixingTime < valuationTime`: a historical fixing is mandatory; missing data is an - error and cannot be replaced with a curve-implied value. -- `fixingTime == valuationTime`: use a supplied fixing if present; otherwise treat the - reset as unfixed and use the current FX forward. -- `fixingTime > valuationTime`: use the current basis-adjusted FX forward. - -Historical resolved values are passive constants. Today-unfixed and future values retain -the pricing scalar type so their derivatives flow through domestic, foreign, and basis -curve parameters. - -## Historical Cashflow Rules - -The first version uses date-level settlement because the existing XCCY market exposes a -valuation date but no intraday payment-settlement state. - -- `paymentDate < valuationDate`: the cashflow is settled and excluded from PV, par - spread, residuals, and Jacobians. -- `paymentDate == valuationDate`: the cashflow is unsettled, included with discount - factor one. -- A coupon fixed in the past but paid on or after the valuation date uses its historical - fixing and remains in PV. -- Historical initial and MTM notional-adjustment cashflows are excluded. -- A future final exchange uses the last outstanding domestic notional and the fixed - foreign notional. -- Valuation is dirty/full PV; no separate accrued-interest output is introduced. - -A matured trade may be represented and has zero remaining PV. It cannot be used as a -calibration instrument when it has no remaining spread annuity. - -## Collateral and Currency Scope - -- The pricing currency and collateral currency are both the currency pair's domestic - currency. -- `CrossCurrencyMarket_` and both calibration specifications record the collateral - currency explicitly. -- The first implementation rejects `collateralCurrency != pair.domestic_`. -- Foreign cashflows are converted to domestic currency with the basis-adjusted FX forward - generated by the same market view used to discount and forecast them. - -This explicit restriction prevents a missing third-currency curve from being silently -substituted by an OIS fallback. - -## Internal Architecture - -### `XccyCashflowPlan_` - -An immutable plan created by `CrossCurrencySwap_::Precompute()` contains both legs' -coupon periods, accrual factors, fixing and payment dates, domestic reset dates, all -potential notional exchanges, notional mode, and spread-leg selection. It describes -events but performs no curve or fixing lookup. - -### `FxResetResolver_` - -The resolver takes a valuation time, fixing snapshot, and current typed XCCY market view. -It produces period domestic notionals and MTM differences using historical constants or -active FX forwards according to the fixing contract. - -### `XccyPricingKernel` - -One templated kernel computes coupon PVs, initial/intermediate/final notional PVs, -foreign-to-domestic conversion, annuities, par spreads, and calibration residuals. -`T_ = double` supports passive pricing and manual cashflow tests; -`T_ = AAD::Number_` supports analytic Jacobians. Historical cashflows are filtered before -summation. - -### Joint Residual Function - -The joint unknown vector has the stable partition - -```text -[domestic curve parameters | foreign curve parameters | basis parameters] -``` - -Each residual evaluation builds all three typed curve views once and evaluates a stacked -residual vector containing domestic instruments, foreign instruments, and XCCY -instruments. It never calls a nested calibration routine. Future resets therefore refer -to the active curves from the current solver iteration, eliminating procedural curve -cycles while retaining the mathematical coupling in one nonlinear system. - -The existing basis-only calibration uses the same pricing kernel with frozen domestic and -foreign blocks and only the basis parameter partition active. - -## Public API - -### Instrument Configuration - -The new public shape is a configuration object rather than more positional arguments: - -```cpp -struct FxResetConvention_ { - XccyNotionalMode_ mode_ = XccyNotionalMode_::Value_::FIXED; - int fixingLag_ = 0; - Holidays_ fixingHolidays_; - BizDayConvention_ fixingConvention_ = BizDayConvention_("Preceding"); - int fixingHour_ = 0; - int fixingMinute_ = 0; -}; - -struct CrossCurrencySwapConfig_ { - CurrencyPair_ pair_; - double domesticNotional_ = 100.0; - double foreignNotional_ = 100.0; - CrossCurrencyConvention_ convention_; - FxResetConvention_ reset_; -}; -``` - -The new overload is: - -```cpp -Handle_ CrossCurrencySwapNew(const Date_& tradeDate, - const Date_& start, - const Date_& maturity, - double marketRate, - const CrossCurrencySwapConfig_& config); -``` - -The current long overload remains source compatible and delegates to a `FIXED` config. -`CrossCurrencySwapConfigBuilder_` supplies a binding-friendly construction path. - -### Basis-Only Calibration - -`CrossCurrencyCalibrationSpecBuilder_` adds `valuationTime_`, -`collateralCurrency_`, and optional `fxFixings_`. The existing `today_` field remains: -when `valuationTime_` is not set, it maps to midnight on `today_`. - -### Three-Block Joint Calibration - -`JointXccyCalibrationSpecBuilder_` contains: - -- valuation time, currency pair, domestic collateral currency, and FX spot; -- domestic and foreign `JointCurveDeclaration_` collections; -- one basis curve declaration; -- domestic, foreign, and XCCY instrument collections; -- optional FX fixing snapshot; -- one shared `CurveSolverOptions_`. - -The implementation factors the existing joint curve declaration and solver machinery into -reusable internal helpers rather than duplicating solver control fields. - -The joint result carries solved domestic and foreign `CurveBlock_` objects, basis and FX -forward curves, diagnostics grouped by residual class, the stacked residual Jacobian, and -explicit parameter/residual block ranges. - -### Python and Excel - -- Python mirrors the enum, reset convention, swap config/builder, fixing snapshot, joint - builder, result, diagnostics, and range metadata. Snapshot construction accepts a - nested `{index_name: {datetime: value}}` mapping. -- Excel retains the existing `CROSSCURRENCYSWAP.NEW`. New worksheet functions create an - FX reset convention/config handle and build a configured XCCY swap without changing the - old registration. Another function creates an immutable FX fixing snapshot from index, - datetime, and value ranges. -- `CALIBRATE.JOINTXCCY` accepts curve and instrument handles plus a settings dictionary. - Result accessors expose curves, diagnostics, Jacobian, and block ranges. -- Machinist regenerates both core and Excel enum artifacts. - -## Error Contract - -Errors name the instrument where applicable and include the offending currency pair, -collateral currency, fixing timestamp, or curve slot. Validation rejects: - -- unsupported collateral currency; -- missing historical fixing; -- inconsistent direct/reverse fixings; -- nonpositive or nonfinite fixing/notional/spot values; -- invalid fixing hour/minute or negative fixing lag; -- reset dates not aligned with domestic coupon accrual starts; -- currency-pair, curve-block, basis-orientation, or valuation-date mismatch; -- a calibration instrument with no remaining spread annuity; -- an analytic Jacobian request for an ineligible parameterization, without silent fallback. - -## Test Strategy and Acceptance Criteria - -### Cashflow and Fixing Unit Tests - -- [ ] `FIXED`, `RESETTABLE`, and `MARK_TO_MARKET` plans contain the expected coupon and - notional events. -- [ ] Fixing lag, holidays, business-day adjustment, and a stub schedule produce exact - expected dates. -- [ ] Direct, reverse, duplicate-consistent, duplicate-inconsistent, missing, nonpositive, - and nonfinite fixings follow the contract. -- [ ] Past, equal-to-valuation, and future fixing timestamps select the intended source. -- [ ] Changing the global fixing store after snapshot creation cannot change valuation. - -### Pricing and In-Progress Trade Tests - -- [ ] Existing fixed-notional pricing results remain unchanged. -- [ ] Domestic notionals reset period by period to fixed foreign notional times FX. -- [ ] A two-period MTM swap matches a hand calculation of every coupon and notional - exchange cashflow. -- [ ] An in-progress swap excludes settled payments, retains past-fixed/future-paid - coupons, and includes valuation-date payments at discount factor one. -- [ ] A matured trade has zero PV and is rejected as a calibration instrument. - -### Calibration and Jacobian Tests - -- [ ] Existing basis-only calibration continues to converge and reprice. -- [ ] A synthetic joint system recovers domestic, foreign, and basis curve parameters in - one solve and reprices all three instrument groups within tolerance. -- [ ] Exact and approximate solve modes are both covered. -- [ ] The analytic stacked Jacobian matches a central-difference Jacobian element by - element across domestic, foreign, and basis parameter blocks. -- [ ] Historical resets have no fixing sensitivity; future resets have the expected curve - sensitivities. -- [ ] Quote-to-parameter effective inverse results agree with explicit quote bump and - recalibration. -- [ ] AAD tape state is cleared before and after each AAD test. - -### Binding Tests - -- [ ] Existing C++ and Python fixed-notional calls remain valid. -- [ ] C++/Python builders round-trip every new field. -- [ ] Python constructs a fixing snapshot, MTM swap, joint specification, and reads the - Jacobian layout. -- [ ] Excel generated sources compile and smoke tests cover configured swap construction, - fixing snapshot input, and joint calibration/result accessors. - -## Delivery Sequence - -1. Add the enum, reset/config types, immutable fixing snapshot, and validation. -2. Add cashflow planning, resolver, and passive/AAD pricing kernel with unit tests. -3. Integrate in-progress valuation and basis-only calibration without changing legacy - fixed-notional results. -4. Add the single-system domestic/foreign/basis joint solve and Jacobian diagnostics. -5. Add public C++ builders, Python bindings, Excel functions/generated artifacts, examples, - methodology documentation, public API documentation, and changelog entry. - -Each stage must pass its targeted tests plus existing XCCY and joint-calibration regression -tests before the next stage begins. - -## Risks and Mitigations - -- **Fixing data contract:** immutable normalized snapshots isolate valuation from mutable - process-wide state. -- **Collateral convention:** an explicit domestic-currency-only check prevents accidental - third-currency curve fallback. -- **Curve cycles:** one stacked residual function and one unknown vector replace nested - calibration calls. -- **Passive/AAD drift:** one templated pricing kernel owns all formulas. -- **API growth:** config/builders contain optional behavior while legacy overloads remain. -- **Scope growth:** nonaligned resets and third-currency collateral are explicit non-goals. From 1c719141b609dc3e1ebd7c5c1345a08e9c80fdac Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 09:36:11 +0800 Subject: [PATCH 28/38] style(xccy): order DAL headers after system headers in new tests Move out from between gtest and the system headers and into the DAL/project block, matching the include order in test_xccypricing.cpp and the unit-test style guide. - dal-cpp/tests/curve/test_xccyjointcalibration.cpp - dal-cpp/tests/curve/test_xccyjointjacobian.cpp - dal-cpp/tests/curve/test_xccybasisjacobian.cpp - dal-cpp/tests/indice/test_fixingsnapshot.cpp --- dal-cpp/tests/curve/test_xccybasisjacobian.cpp | 3 +-- dal-cpp/tests/curve/test_xccyjointcalibration.cpp | 3 +-- dal-cpp/tests/curve/test_xccyjointjacobian.cpp | 3 +-- dal-cpp/tests/indice/test_fixingsnapshot.cpp | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/dal-cpp/tests/curve/test_xccybasisjacobian.cpp b/dal-cpp/tests/curve/test_xccybasisjacobian.cpp index c79b38d9b..b407ab457 100644 --- a/dal-cpp/tests/curve/test_xccybasisjacobian.cpp +++ b/dal-cpp/tests/curve/test_xccybasisjacobian.cpp @@ -4,9 +4,8 @@ #include -#include - #include +#include #include #include #include diff --git a/dal-cpp/tests/curve/test_xccyjointcalibration.cpp b/dal-cpp/tests/curve/test_xccyjointcalibration.cpp index ea7156bb6..01661efab 100644 --- a/dal-cpp/tests/curve/test_xccyjointcalibration.cpp +++ b/dal-cpp/tests/curve/test_xccyjointcalibration.cpp @@ -4,10 +4,9 @@ #include -#include - #include #include +#include #include #include #include diff --git a/dal-cpp/tests/curve/test_xccyjointjacobian.cpp b/dal-cpp/tests/curve/test_xccyjointjacobian.cpp index 9d30ac5e3..28a581b3a 100644 --- a/dal-cpp/tests/curve/test_xccyjointjacobian.cpp +++ b/dal-cpp/tests/curve/test_xccyjointjacobian.cpp @@ -4,10 +4,9 @@ #include -#include - #include +#include #include #include #include diff --git a/dal-cpp/tests/indice/test_fixingsnapshot.cpp b/dal-cpp/tests/indice/test_fixingsnapshot.cpp index 9c9e6a701..861f62653 100644 --- a/dal-cpp/tests/indice/test_fixingsnapshot.cpp +++ b/dal-cpp/tests/indice/test_fixingsnapshot.cpp @@ -3,9 +3,9 @@ // #include -#include #include +#include #include #include #include From 67ec86c0f7d7a4b8ebf528085626039c3eb64b2c Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 09:59:00 +0800 Subject: [PATCH 29/38] docs(xccy): align calibration tables and note positive-fixing scope Reflow four markdown tables so every row shares one consistent per-column width (longest cell content + 2), and document the MarketFixingSnapshot_ constructor's current positive-only acceptance in the snapshot section. Doc-only review fixes; no code, test, or behavior change. - docs/methodology/yield_curve.md - docs/methodology/yield_curve_jacobian.md - docs/methodology/xccy_calibration.md --- docs/methodology/xccy_calibration.md | 17 ++++++++++------- docs/methodology/yield_curve.md | 22 +++++++++++----------- docs/methodology/yield_curve_jacobian.md | 10 +++++----- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/docs/methodology/xccy_calibration.md b/docs/methodology/xccy_calibration.md index 50ec6ae2f..0747ece8b 100644 --- a/docs/methodology/xccy_calibration.md +++ b/docs/methodology/xccy_calibration.md @@ -90,7 +90,10 @@ pricing context. `MarketFixingSnapshot_` is an immutable nested map `index name -> DateTime_ -> value`. The same snapshot can contain rate and FX -observations and is retained by the calibration result. Supplying a snapshot +observations and is retained by the calibration result. The snapshot currently +accepts positive values only: the constructor rejects non-positive entries, so +rate fixings from negative-rate regimes (for example EURIBOR, ESTR, TIBOR, or +SARON over 2014–2022) are not accepted. Supplying a snapshot makes it authoritative, including when it is explicitly empty. When a snapshot is omitted, staged and joint XCCY calibration first collect all historical requests across all instruments and copy those values from the process-wide @@ -122,12 +125,12 @@ basis knots, fit diagnostics, and optional matrices. Within each declaration, parameter columns follow its representation: -| Parameterization | Columns per declaration | -|------------------|-------------------------| -| `PIECEWISE_CONSTANT_FWD` | right-hand forwards in knot order | -| `PIECEWISE_LINEAR_FWD` | left/right forward pair at each knot | -| `LOG_DISCOUNT` | future-knot log discount factors | -| `ZERO_RATE` | future-knot continuously compounded zero rates | +| Parameterization | Columns per declaration | +|--------------------------|------------------------------------------------| +| `PIECEWISE_CONSTANT_FWD` | right-hand forwards in knot order | +| `PIECEWISE_LINEAR_FWD` | left/right forward pair at each knot | +| `LOG_DISCOUNT` | future-knot log discount factors | +| `ZERO_RATE` | future-knot continuously compounded zero rates | Residual rows use the same group order: domestic instrument groups, foreign instrument groups, then XCCY quotes. `parameterRanges_` and `residualRanges_` diff --git a/docs/methodology/yield_curve.md b/docs/methodology/yield_curve.md index dcccdfabd..a6675feb6 100644 --- a/docs/methodology/yield_curve.md +++ b/docs/methodology/yield_curve.md @@ -110,11 +110,11 @@ $$ with the implicit anchor ordinate $\ell_0=0$. The mapped ordinates are evaluated by the same `LogDfInterpolation_` implementation as `LOG_DISCOUNT`: -| `LogDfScheme_` | Mapped log-DF shape | Minimum future zero-rate nodes | -|----------------------|-------------------------------------------|--------------------------------| -| `LOG_LINEAR` | piecewise linear | 1 | +| `LogDfScheme_` | Mapped log-DF shape | Minimum future zero-rate nodes | +|---------------------|--------------------------------------------|--------------------------------| +| `LOG_LINEAR` | piecewise linear | 1 | | `LOG_CUBIC_NATURAL` | natural cubic with zero endpoint curvature | 2 | -| `MIXED` | linear head and natural-cubic tail | 3 | +| `MIXED` | linear head and natural-cubic tail | 3 | Consequently the schemes have exactly the shared log-DF boundary policy: before the anchor, linear and mixed clamp to zero while natural cubic extends its first polynomial; @@ -397,14 +397,14 @@ without changing the declaration's public future-knot vector. **Tape-layer primitives.** The scalar-templated curve and routing types are: -| Type | Role | Header | -|----------------------------------|----------------------------------------------------------|-------------------------------------| -| `Tape::DiscountPWC_` | Typed PWC-forward integration with optional typed base | `dal-cpp/dal/curve/ycconst.hpp` | -| `Tape::DiscountPWLF_` | Typed PWL-forward integration with optional typed base | `dal-cpp/dal/curve/ycpwlf.hpp` | -| `Tape::DiscountLogDF_` | Typed log-DF interpolation for all `LogDfScheme_` values | `dal-cpp/dal/curve/yclogdf.hpp` | +| Type | Role | Header | +|-----------------------------------|----------------------------------------------------------|-------------------------------------| +| `Tape::DiscountPWC_` | Typed PWC-forward integration with optional typed base | `dal-cpp/dal/curve/ycconst.hpp` | +| `Tape::DiscountPWLF_` | Typed PWL-forward integration with optional typed base | `dal-cpp/dal/curve/ycpwlf.hpp` | +| `Tape::DiscountLogDF_` | Typed log-DF interpolation for all `LogDfScheme_` values | `dal-cpp/dal/curve/yclogdf.hpp` | | `Tape::DiscountZeroRate_` | Typed zero-rate mapping and log-DF interpolation | `dal-cpp/dal/curve/yczerorate.hpp` | -| `Tape::JointCurveBlock_` | Multi-curve discount/forward routing in the `T_` domain | `dal-cpp/dal/curve/jointycctx.hpp` | -| `Tape::JointRate_` | Projection-capable rate base over a joint block | `dal-cpp/dal/curve/jointrate.hpp` | +| `Tape::JointCurveBlock_` | Multi-curve discount/forward routing in the `T_` domain | `dal-cpp/dal/curve/jointycctx.hpp` | +| `Tape::JointRate_` | Projection-capable rate base over a joint block | `dal-cpp/dal/curve/jointrate.hpp` | `BuildDiscountCurveT` dispatches once from each declaration's `CurveDefinition_` to the required typed curve. The AAD path uses a two-pass build: discount declarations first, diff --git a/docs/methodology/yield_curve_jacobian.md b/docs/methodology/yield_curve_jacobian.md index 05742790e..d648d4689 100644 --- a/docs/methodology/yield_curve_jacobian.md +++ b/docs/methodology/yield_curve_jacobian.md @@ -85,11 +85,11 @@ The passive and AAD paths both construct curves from a `CurveDefinition_` and separately at each call site. | `CurveParameterization_` | Columns contributed by one declaration | Stable column order | -|--------------------------|-----------------------------------------|---------------------------------------------------------------------------------| -| `PIECEWISE_CONSTANT_FWD` | $K$ | right-hand forward value at knots $0,\dots,K-1$ | -| `PIECEWISE_LINEAR_FWD` | $2K$ | `fLeft[0]`, `fRight[0]`, ..., `fLeft[K-1]`, `fRight[K-1]` | -| `LOG_DISCOUNT` | $K$ future declared knots | log DF at each future node; the pinned storage anchor is excluded | -| `ZERO_RATE` | $K$ future declared knots | continuously compounded decimal zero rate at each future node; anchor excluded | +|--------------------------|----------------------------------------|---------------------------------------------------------------------------------| +| `PIECEWISE_CONSTANT_FWD` | $K$ | right-hand forward value at knots $0,\dots,K-1$ | +| `PIECEWISE_LINEAR_FWD` | $2K$ | `fLeft[0]`, `fRight[0]`, ..., `fLeft[K-1]`, `fRight[K-1]` | +| `LOG_DISCOUNT` | $K$ future declared knots | log DF at each future node; the pinned storage anchor is excluded | +| `ZERO_RATE` | $K$ future declared knots | continuously compounded decimal zero rate at each future node; anchor excluded | For ZERO_RATE, node $j$ is mapped through $\ell_j=-z_j\tau_j$, so the direct node chain factor is From 4523ff5a8bd1d02c0b48afc85f4a1cacad5a3c66 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Tue, 14 Jul 2026 10:58:32 +0800 Subject: [PATCH 30/38] docs(xccy): specify example and benchmark expansion --- .../specs/xccy-examples-benchmarks.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .codex/artifacts/specs/xccy-examples-benchmarks.md diff --git a/.codex/artifacts/specs/xccy-examples-benchmarks.md b/.codex/artifacts/specs/xccy-examples-benchmarks.md new file mode 100644 index 000000000..cb7c340ed --- /dev/null +++ b/.codex/artifacts/specs/xccy-examples-benchmarks.md @@ -0,0 +1,77 @@ +# XCCY Examples and Benchmark Coverage - Specification + +## Source + +- Pull request: #230, `feat: add reset-aware XCCY pricing and joint calibration` +- User request and approved balanced design: 2026-07-14 +- Related methodology: `docs/methodology/xccy_calibration.md` and `docs/methodology/yield_curve_jacobian.md` + +## Problem Statement + +PR #230 adds broad reset-aware XCCY pricing and calibration behavior, but its runnable examples jump from fixed-notional staged calibration to a large joint recovery problem. The existing `xccy_perf` target covers future pricing and several calibration modes, but it omits a started-trade basket, reset-aware staged calibration, and execution in benchmark CI. Python exposes the joint workflow but has no runnable joint/reset-aware example. + +## Goals + +- Add an approachable C++ example that compares the three XCCY notional modes and demonstrates historical fixing snapshots for a started MTM trade. +- Add a runnable Python example for reset-aware joint domestic, foreign, and basis calibration. +- Extend `xccy_perf` from 21 to 24 stable output rows without creating a mode-by-solver cross product. +- Execute `xccy_perf` as an informational smoke benchmark on Linux and Windows CI. +- Cover the untested domestic-leg-spread par-quote branch with a deterministic hand-calculated core test. + +## Non-Goals + +- No production API, binding, enum, serialization, or pricing behavior changes. +- No Excel example workbook or Excel surface changes. +- No quote-risk tutorial or large joint-calibration scaling matrix. +- No benchmark error-path cases. +- Do not add `xccy_perf` to the paired base/head regression gate in this PR; `master` has no matching target or case set. +- Do not treat noisy basket timings as regression thresholds. + +## Functional Requirements + +- **FR1 - C++ notional-mode example**: Add the executable target `xccy_reset_pricing` under `dal-cpp/examples/xccy_reset_pricing/` and register it through the existing examples CMake lists. +- **FR2 - Future-trade comparison**: The C++ example shall compute the par quote for a common future-start XCCY swap as `FIXED`, `RESETTABLE`, and `MARK_TO_MARKET` in one deliberately non-flat market. It shall print each mode, reset count, MTM-delta count, next domestic notional, and par quote. +- **FR3 - Started-trade snapshot**: The C++ example shall build a started MTM cashflow plan, obtain `RequiredHistoricalFixings`, construct one immutable `MarketFixingSnapshot_`, and compute the trade's par quote at an intraday valuation time. It shall print the required fixing identities/times and a finite par quote. +- **FR4 - Self-validation**: The C++ example shall exit non-zero unless all reported numeric values are finite and the following invariants hold: `FIXED` has zero resets and MTM deltas; `RESETTABLE` has one reset for every period after the first and zero MTM deltas; `MARK_TO_MARKET` has one reset and one MTM-delta slot for every period after the first. The deliberately non-flat fixture shall also produce pairwise-distinct par quotes, and the started trade shall consume at least one historical FX fixing and one historical fixing for each rate index. +- **FR5 - Python joint example**: Add `dal-python/examples/007.xccy_joint_calibration.py`. Using only the installed public Python surface, it shall adapt the known-convergent joint fixture in `dal-python/tests/test_xccy_joint.py` to build domestic and foreign curve declarations, a reset-aware XCCY basis declaration containing one started MTM trade, an explicit immutable fixing snapshot, and a `JointXccyCalibrationSpecBuilder_`, then call `CalibrateJointXccyMarket`. The script shall use fixed trade dates, positive fixing identities/timestamps/values, and an explicit quoted spread; it shall not depend on unbound plan, required-fixing, or precompute APIs. +- **FR6 - Python diagnostics**: The Python example shall print convergence, named parameter/residual ranges, FX forwards, and maximum absolute residual. Explicit failure checks that remain active under `python -O` shall terminate non-zero unless calibration converges; residual ranges are contiguous and sum to both the residual-vector size and Jacobian row count; parameter ranges are contiguous and sum to the Jacobian column count; FX forwards are finite; and the maximum residual is within the configured tolerance. +- **FR7 - Started MTM basket benchmark**: Populate the in-progress MTM pricing case with ten swaps sharing the original start `Date::AddMonths(today, -3)` and maturing at `Date::AddMonths(start, 12 * year)` for years 1 through 10. Before precomputing any rate, build one authoritative union snapshot from deduplicated fixing requests across all ten plans. Existing basket machinery shall emit exactly `XCCY in-progress MTM 10-instrument BASKET / pass` and `XCCY in-progress MTM 10-instrument PER-INSTRUMENT` as the two additional labels. +- **FR8 - Reset-aware staged benchmark**: Add one mixed reset-aware staged `ANALYTIC +DIAG` calibration row named `XCCY reset-aware basis ANALYTIC +DIAG (15 instruments, 5 knots)`. Its 15 instruments shall include one started MTM trade and future resettable/MTM trades quoted from a known market, and the dry run shall validate configured repricing tolerance and diagnostic dimensions. +- **FR9 - Stable benchmark matrix**: `xccy_perf` shall emit exactly 24 uniquely named rows. Existing 21 row labels and workloads shall remain unchanged. +- **FR10 - CI smoke execution**: Add `xccy_perf` to the head-only benchmark arrays in both `.github/workflows/cmake-linux.yml` and `.github/workflows/cmake-windows.yml`. The binary must run successfully and publish its normal output; it shall not be added to `.github/scripts/check_benchmark_regressions.py`. +- **FR11 - Domestic-spread test**: Add a Google Test in `dal-cpp/tests/curve/test_xccypricing.cpp` with `spreadOnForeignLeg_ == false` and a hand-calculated expected par quote. The test shall follow the repository's gtest-first, `TEST`, and `ASSERT_*` conventions. +- **FR12 - Current-state documentation**: Register the new C++ target in `dal-cpp/examples/CMakeLists.txt`; list the C++ workflow in `docs/methodology/xccy_calibration.md`; and list the Python workflow in `dal-python/README.md`. Documentation shall describe only the current surface and shall not include implementation history. + +## Non-Functional Requirements + +- **Performance**: Record one representative AADET wall-clock run from the repository root using `DAL_NUM_THREADS=4 /usr/bin/time -f %e build/dal-cpp/benchmarks/xccy_perf/xccy_perf`. The prior 21-row fixture took 7.61 seconds on the development host, so 15 seconds is an advisory investigation threshold on the same host/configuration, not a portable CI gate. CI validates successful completion only. +- **Determinism**: Fixtures shall use fixed dates, explicit calendars/conventions, deterministic quotes, and immutable fixing snapshots. No mutable global or shared test fixture state may be introduced. +- **Compatibility**: Existing example names, benchmark row labels, public bindings, and default pricing/calibration behavior must remain unchanged. +- **Portability**: New C++ sources and workflow edits must build on supported GCC, Clang, and MSVC configurations without platform-specific timing assumptions. +- **Differentiability**: The reset-aware staged analytic benchmark must use the existing analytic Jacobian path and must not introduce AAD tape state outside existing calibration ownership. + +## Inputs and Outputs + +| Surface | Inputs | Observable output | +|---------|--------|-------------------| +| `xccy_reset_pricing` | Fixed dates, USD/EUR curves, FX spot, three notional modes, started-trade observations | Mode comparison table, required fixings, finite started-trade result, exit status | +| `007.xccy_joint_calibration.py` | Domestic/foreign declarations, reset-aware XCCY instruments, fixing snapshot, solver options | Convergence, ranges, FX forwards, maximum residual, exit status | +| `xccy_perf` | Existing fixed fixtures plus one union-snapshot basket and one mixed staged spec | 24 stable benchmark rows and successful dry-run validation | +| Core unit test | Domestic-leg spread configuration and deterministic market | Hand-calculated par quote assertion | + +## Acceptance Criteria + +- [ ] The domestic-spread expected quote is derived independently from its cashflows, and the focused core pricing test passes without production-code changes. +- [ ] `bin/dal_cpp_tests --gtest_filter='XccyPricingTest.*'` passes, including the new domestic-spread case. +- [ ] `xccy_reset_pricing` builds, runs, prints both sections, validates its observations, and exits 0. +- [ ] `007.xccy_joint_calibration.py` runs against the built Python package under normal Python and `python -O`, prints the required diagnostics, validates its result with explicit checks, and exits 0. +- [ ] `xccy_perf` builds, completes its dry runs, emits exactly 24 unique rows, and exits 0. +- [ ] A `DAL_NUM_THREADS=4` AADET wall-clock run is recorded from the build-tree `xccy_perf` binary; any same-host result above the advisory 15-second threshold is investigated and reported rather than automatically failed. +- [ ] The Linux and Windows benchmark workflows list and execute `xccy_perf` as a head-only smoke target. +- [ ] `.github/scripts/check_benchmark_regressions.py` and its eight-target regression set remain unchanged. +- [ ] The full core/public/Python test workflow and documentation integrity check pass. +- [ ] Formatting and diff checks are clean, and a DAL reviewer reports no blocking findings. + +## Open Questions + +None. The balanced cross-language scope was approved on 2026-07-14. From f659cafe1bf3e1539e72a586c8b82e9cd9a9ff8c Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Wed, 15 Jul 2026 02:44:01 +0800 Subject: [PATCH 31/38] docs(xccy): add examples benchmark implementation plan --- .../2026-07-14-xccy-examples-benchmarks.md | 694 ++++++++++++++++++ 1 file changed, 694 insertions(+) create mode 100644 .codex/artifacts/plans/2026-07-14-xccy-examples-benchmarks.md diff --git a/.codex/artifacts/plans/2026-07-14-xccy-examples-benchmarks.md b/.codex/artifacts/plans/2026-07-14-xccy-examples-benchmarks.md new file mode 100644 index 000000000..32680774a --- /dev/null +++ b/.codex/artifacts/plans/2026-07-14-xccy-examples-benchmarks.md @@ -0,0 +1,694 @@ +# XCCY Examples and Benchmark Coverage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add self-validating C++ and Python reset-aware XCCY examples, expand `xccy_perf` to 24 stable cases, execute it as Linux/Windows smoke coverage, and add the missing domestic-spread pricing test to PR #230. + +**Architecture:** Keep production pricing, calibration, bindings, and serialization unchanged. Build deterministic fixtures entirely in examples, tests, and the existing benchmark target; reuse immutable fixing snapshots and public calibration surfaces, then wire the completed benchmark only into head-side CI smoke lists. + +**Tech Stack:** C++17, DAL core/public APIs, Google Test, CMake, Python 3.10+ with pybind11 bindings, GitHub Actions YAML, DAL benchmark harness. + +## Global Constraints + +- Work from the isolated writable clone rooted at `/tmp/dal-pr230-examples.7HurRS`, based on PR #230 head `4523ff5a8bd1d02c0b48afc85f4a1cacad5a3c66`. +- Treat `.codex/artifacts/specs/xccy-examples-benchmarks.md` as controlling requirements. +- Do not change production API, binding, enum, serialization, or pricing/calibration behavior. +- Preserve every existing example name and all 21 existing `xccy_perf` labels and workloads. +- `xccy_perf` must emit exactly 24 unique rows and remain informational; do not add it to `.github/scripts/check_benchmark_regressions.py`. +- Use fixed dates, explicit calendars/conventions, deterministic quotes, immutable snapshots, and no shared mutable test state. +- Follow `.clang-format`; keep `` first; use `TEST`, `ASSERT_*`, and test names beginning with `Test`. +- Keep documentation current-state only; implementation history stays out of `docs/` and README files. +- Run new C++ code on GCC/Clang locally and rely on exact-head CI for MSVC portability. + +--- + +## File Map + +| Path | Responsibility | +|------|----------------| +| `dal-cpp/tests/curve/test_xccypricing.cpp` | Hand-calculated domestic-leg-spread coverage only | +| `dal-cpp/examples/xccy_reset_pricing/xccy_reset_pricing.cpp` | Focused notional-mode and started-fixing tutorial | +| `dal-cpp/examples/xccy_reset_pricing/CMakeLists.txt` | New executable and install wiring | +| `dal-cpp/examples/CMakeLists.txt` | Example discovery and platform options | +| `docs/methodology/xccy_calibration.md` | Current runnable C++ example listing | +| `dal-python/examples/007.xccy_joint_calibration.py` | Installed-surface reset-aware joint calibration tutorial | +| `dal-python/README.md` | Python example discovery and run command | +| `dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp` | Started basket, union snapshot, mixed staged calibration, 24-row matrix | +| `.github/workflows/cmake-linux.yml` | Head-only Linux `xccy_perf` smoke execution | +| `.github/workflows/cmake-windows.yml` | Windows `xccy_perf` smoke execution | + +## Specification Traceability + +| Requirements | Implemented and verified by | +|--------------|-----------------------------| +| FR1-FR4 | Task 2 C++ example, CMake registration, self-validation, and explicit run | +| FR5-FR6 | Task 3 Python installed-surface example, optimization-proof checks, and explicit runs | +| FR7-FR9 | Task 4 union snapshot, started basket, reset-aware staged row, and ordered 24-row assertion | +| FR10 | Task 5 Linux/Windows head smoke arrays and regression-script exclusion | +| FR11 | Task 1 independent domestic-spread formula and focused suite | +| FR12 | Tasks 2-3 current-state methodology/README discovery updates | +| Performance | Task 4 AADET/4-thread timing and advisory-threshold report; Task 6 full workflow | +| Determinism and compatibility | Tasks 1-4 fixed fixtures and Task 6 whole-diff review | +| Portability | Task 2 CMake platform options and Task 6 exact-head compiler/MSVC CI | +| Differentiability | Task 4 analytic-diagnostics row and Jacobian dimension validation | + +--- + +### Task 1: Cover the domestic-leg XCCY par quote + +**Files:** +- Modify: `dal-cpp/tests/curve/test_xccypricing.cpp`, after `CustomPricingMarket_` and before `TestFxResetAtValuationUsesSuppliedFixing` +- Test: `dal-cpp/tests/curve/test_xccypricing.cpp` + +**Interfaces:** +- Consumes: `MakeQuarterlyConfig`, `CustomPricingMarket_`, `BuildXccyCashflowPlan`, `PriceXccyParSpread` +- Produces: `TEST(XccyPricingTest, TestDomesticLegParSpreadMatchesHandCalculation)`; no production interface + +- [ ] **Step 1: Add the independent hand-calculated coverage test** + +```cpp +TEST(XccyPricingTest, TestDomesticLegParSpreadMatchesHandCalculation) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED); + config.convention_.initialNotionalExchange_ = false; + config.convention_.finalNotionalExchange_ = false; + config.convention_.spreadOnForeignLeg_ = false; + + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 7, 4), config); + ASSERT_EQ(plan.domesticPeriods_.size(), 2U); + ASSERT_EQ(plan.foreignPeriods_.size(), 2U); + + constexpr double domesticDf = 0.96; + constexpr double foreignDf = 0.99; + constexpr double basisDf = 0.98; + constexpr double spot = 1.10; + const CustomPricingMarket_ curves(domesticDf, foreignDf, basisDf, spot); + const auto market = curves.View(DateTime_(Date_(2024, 1, 1), 12, 0)); + + double domesticPv = 0.0; + double domesticAnnuity = 0.0; + for (const auto& period : plan.domesticPeriods_) { + const double annuity = config.domesticNotional_ * period.accrual_.dcf_ * domesticDf; + const double forwardRate = (1.0 / domesticDf - 1.0) / period.accrual_.dcf_; + domesticPv += forwardRate * annuity; + domesticAnnuity += annuity; + } + + const double foreignConversion = spot * foreignDf / basisDf; + double foreignPv = 0.0; + for (const auto& period : plan.foreignPeriods_) { + const double annuity = config.foreignNotional_ * period.accrual_.dcf_ * foreignConversion; + const double forwardRate = (1.0 / foreignDf - 1.0) / period.accrual_.dcf_; + foreignPv += forwardRate * annuity; + } + + const double expectedParSpread = (foreignPv - domesticPv) / domesticAnnuity; + ASSERT_LT(expectedParSpread, 0.0); + ASSERT_NEAR(PriceXccyParSpread(plan, market, MarketFixingSnapshot_()), expectedParSpread, 1.0e-12); +} +``` + +This is coverage of existing production behavior, so immediate success is expected. If it fails, stop for scope review instead of changing production code. + +- [ ] **Step 2: Build and run the focused test** + +Run: + +```bash +cmake --build build/Release-linux --target dal_cpp_tests --parallel 4 +build/Release-linux/dal-cpp/dal_cpp_tests \ + --gtest_filter='XccyPricingTest.TestDomesticLegParSpreadMatchesHandCalculation' +``` + +Expected: one passing test. + +- [ ] **Step 3: Run the entire XCCY pricing suite** + +```bash +build/Release-linux/dal-cpp/dal_cpp_tests --gtest_filter='XccyPricingTest.*' +``` + +Expected: all `XccyPricingTest` cases pass. + +- [ ] **Step 4: Commit only the test** + +```bash +git add dal-cpp/tests/curve/test_xccypricing.cpp +git diff --cached --name-status +git diff --cached --check +git commit -m "test(xccy): cover domestic-leg par spread" +``` + +Expected staged path: only `dal-cpp/tests/curve/test_xccypricing.cpp`. + +--- + +### Task 2: Add the self-validating C++ reset-pricing example + +**Files:** +- Create: `dal-cpp/examples/xccy_reset_pricing/xccy_reset_pricing.cpp` +- Create: `dal-cpp/examples/xccy_reset_pricing/CMakeLists.txt` +- Modify: `dal-cpp/examples/CMakeLists.txt` +- Modify: `docs/methodology/xccy_calibration.md` + +**Interfaces:** +- Consumes: `BuildXccyCashflowPlan`, `RequiredHistoricalFixings`, `ResolveXccyNotionals`, `PriceXccyParSpread`, `CrossCurrencySwap_::Precompute`, `CrossCurrencyMarket_` +- Produces: installed executable `xccy_reset_pricing`; local `ModeResult_`, `MarketFixture_`, `Config`, `EvaluateFutureMode`, `SnapshotFor`, `RunExample` + +- [ ] **Step 1: Verify the target is absent before implementation** + +```bash +cmake --build build/Release-linux --target xccy_reset_pricing --parallel 4 +``` + +Expected: failure because the target does not exist. + +- [ ] **Step 2: Create the deterministic fixture and result model** + +Use the following local types and fixture values in `xccy_reset_pricing.cpp`: + +```cpp +struct ModeResult_ { + const char* label_; + int periodCount_ = 0; + int resetCount_ = 0; + int mtmDeltaCount_ = 0; + double nextDomesticNotional_ = 0.0; + double parQuote_ = 0.0; +}; + +const Date_ today(2025, 1, 16); +const Vector_ knots = { + Date::AddMonths(today, 6), Date::AddMonths(today, 18), Date::AddMonths(today, 36), +}; +``` + +`MarketFixture_` must own USD/EUR OIS and 3M `CurveBlock_` handles plus a USD basis curve, then expose: + +```cpp +CrossCurrencyMarket_ Market(const DateTime_& valuationTime, + const Handle_& fixings) const; +``` + +Build piecewise-constant term structures with these forward values: + +```text +USD OIS 0.015, 0.022, 0.030 +USD 3M 0.025, 0.035, 0.050 +EUR OIS 0.008, 0.012, 0.018 +EUR 3M 0.018, 0.025, 0.032 +USD basis 0.001, 0.003, 0.006 +FX spot 1.10 +``` + +Do not set `XGLOBAL`; valuation time is explicit. + +- [ ] **Step 3: Implement the common configuration** + +```cpp +CrossCurrencySwapConfig_ Config(XccyNotionalMode_ mode) { + CrossCurrencySwapConfig_ result; + result.pair_ = CurrencyPair_(Ccy_("USD"), Ccy_("EUR")); + result.domesticNotional_ = 110.0; + result.foreignNotional_ = 100.0; + result.notionalMode_ = mode; + result.convention_.initialNotionalExchange_ = true; + result.convention_.finalNotionalExchange_ = true; + result.convention_.spreadOnForeignLeg_ = true; + result.convention_.domesticIndex_ = IndexConvention(); + result.convention_.foreignIndex_ = IndexConvention(); + result.convention_.domesticLeg_ = LegConvention(); + result.convention_.foreignLeg_ = LegConvention(); + result.fxReset_.fixingLag_ = 0; + result.fxReset_.fixingHolidays_ = Holidays::None(); + result.fxReset_.fixingConvention_ = BizDayConvention_("Unadjusted"); + result.fxReset_.fixingHour_ = 10; + result.fxReset_.fixingMinute_ = 30; + result.domesticRateFixing_ = {"USD-XCCY-RESET-3M", 11, 0}; + result.foreignRateFixing_ = {"EUR-XCCY-RESET-3M", 11, 0}; + return result; +} +``` + +Both legs use 3M, ACT/365F, OIS collateral, no holidays, unadjusted dates; both indices enable projection curves. + +- [ ] **Step 4: Implement and validate the future-mode table** + +Use `start = Date::AddMonths(today, 3)`, `maturity = Date::AddMonths(start, 24)`, and valuation `DateTime_(today, 9, 0)`. For `FIXED`, `RESETTABLE`, and `MARK_TO_MARKET`, build one plan and swap, compute the par quote via `Precompute`, and resolve notionals through a local `XccyMarketView_` assembled from `CrossCurrencyMarket_` accessors exactly as `CrossCurrencySwapKernelRate_` does. + +The validation must implement these exact rules: + +```cpp +fixed.resetCount_ == 0 && fixed.mtmDeltaCount_ == 0; +resettable.resetCount_ == resettable.periodCount_ - 1 && resettable.mtmDeltaCount_ == 0; +mtm.resetCount_ == mtm.periodCount_ - 1 && mtm.mtmDeltaCount_ == mtm.periodCount_ - 1; +std::fabs(fixed.parQuote_ - resettable.parQuote_) > 1.0e-12; +std::fabs(fixed.parQuote_ - mtm.parQuote_) > 1.0e-12; +std::fabs(resettable.parQuote_ - mtm.parQuote_) > 1.0e-12; +``` + +All quotes and next notionals must be finite. Print mode, reset count, MTM-delta count, next domestic notional, and par quote in basis points. + +- [ ] **Step 5: Implement and validate the started-MTM snapshot section** + +Use valuation `DateTime_(today, 12, 0)`, start `Date::AddMonths(today, -3)`, maturity `Date::AddMonths(start, 24)`, and MTM mode. For every request from `RequiredHistoricalFixings`, populate exactly one of: + +```cpp +values["USD-XCCY-RESET-3M"][request.fixingTime_] = 0.040; +values["EUR-XCCY-RESET-3M"][request.fixingTime_] = 0.030; +values[FxIndexName(config.pair_)][request.fixingTime_] = 1.20; +``` + +Reject unknown identities. Require a non-empty request set containing USD, EUR, and FX requests, retain the immutable snapshot in the market, print every request, compute the par quote, and require it to be finite. + +- [ ] **Step 6: Add explicit process failure behavior** + +```cpp +int main() { + try { + RegisterAll_::Init(); + return RunExample() ? 0 : 1; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} +``` + +- [ ] **Step 7: Register the target and install rule** + +Create `dal-cpp/examples/xccy_reset_pricing/CMakeLists.txt`: + +```cmake +file(GLOB_RECURSE XCCY_RESET_PRICING_FILES CONFIGURE_DEPENDS "*.hpp" "*.cpp") +add_executable(xccy_reset_pricing ${XCCY_RESET_PRICING_FILES}) +target_link_libraries(xccy_reset_pricing dal_library) + +if(DAL_USE_XAD_AAD) + target_link_libraries(xccy_reset_pricing XAD::xad) +elseif(DAL_USE_CODIPACK_AAD) + target_link_libraries(xccy_reset_pricing CoDiPack) +elseif(DAL_USE_ADEPT_AAD) + target_link_libraries(xccy_reset_pricing adept) +endif() + +if(NOT MSVC) + target_link_libraries(xccy_reset_pricing pthread) +endif() + +install(TARGETS xccy_reset_pricing + RUNTIME DESTINATION bin + PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE) +``` + +Add `add_subdirectory(xccy_reset_pricing)` and `xccy_reset_pricing` to the matching lists in `dal-cpp/examples/CMakeLists.txt`. + +- [ ] **Step 8: Build, run, format, and document** + +```bash +cmake --preset Release-linux -S . -B build/Release-linux +cmake --build build/Release-linux --target xccy_reset_pricing --parallel 4 +build/Release-linux/dal-cpp/examples/xccy_reset_pricing/xccy_reset_pricing +clang-format -i -sort-includes=0 dal-cpp/examples/xccy_reset_pricing/xccy_reset_pricing.cpp +cmake --build build/Release-linux --target xccy_reset_pricing --parallel 4 +build/Release-linux/dal-cpp/examples/xccy_reset_pricing/xccy_reset_pricing +``` + +Expected: counts `0/0`, `7/0`, `7/7`; finite pairwise-distinct quotes; historical USD/EUR/FX requests; exit 0. + +Replace the singular end-to-end example paragraph in `docs/methodology/xccy_calibration.md` with a current-state `Runnable Examples` section listing `xccy_reset_pricing` and `xccy_mtm_calibration`. + +- [ ] **Step 9: Commit the C++ example slice** + +```bash +git add dal-cpp/examples/CMakeLists.txt \ + dal-cpp/examples/xccy_reset_pricing/CMakeLists.txt \ + dal-cpp/examples/xccy_reset_pricing/xccy_reset_pricing.cpp \ + docs/methodology/xccy_calibration.md +git diff --cached --name-status +git diff --cached --check +git commit -m "feat(xccy): add reset pricing example" +``` + +--- + +### Task 3: Add the installed-surface Python joint example + +**Files:** +- Create: `dal-python/examples/007.xccy_joint_calibration.py` +- Modify: `dal-python/README.md` + +**Interfaces:** +- Consumes: bound builders and properties already exercised by `dal-python/tests/test_xccy_joint.py` and `test_xccy_resettable.py` +- Produces: executable `main() -> int`, `require`, `validate_ranges`, `validate_result`; no binding changes + +- [ ] **Step 1: Verify the script is absent** + +```bash +dal-python/.venv/bin/python dal-python/examples/007.xccy_joint_calibration.py +``` + +Expected: failure because the file does not exist. + +- [ ] **Step 2: Define exact dates, observations, and failure helper** + +```python +TODAY = dal.Date_(2025, 6, 20) +START = dal.Date_(2025, 3, 20) +XCCY_MATURITY = dal.Date_(2026, 3, 20) +CURVE_MATURITY = dal.Date_(2026, 6, 20) +VALUATION_TIME = dal.DateTime_(TODAY, 12, 0) +TOLERANCE = 1.0e-9 + +def require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) +``` + +Build a snapshot with USD observations `0.040` at `START 11:00` and `0.041` at `TODAY 11:00`; EUR observations `0.030` and `0.031` at the same respective times; and `FX[EUR/USD] = 1.20` at `TODAY 10:30`. + +- [ ] **Step 3: Build the known-convergent three-block joint fixture** + +Use only bound names. Create one OIS deposit declaration for USD at `0.040`, one for EUR at `0.030`, and one knot per declaration at `CURVE_MATURITY`. Each declaration uses ACT/365F, OIS collateral, `PIECEWISE_CONSTANT_FWD`, `LOG_LINEAR`, discount calibration, and `initial_guess_per_node = [market_rate]`. + +Build one started MTM XCCY swap with 3M ACT/365F legs and non-projection OIS indices, initial/final exchanges, foreign-leg spread, USD/EUR fixing identities at 11:00, FX reset lag 0/FOLLOWING/10:30, notionals 110/100, and quoted spread `0.001`. + +The basis declaration has that instrument, knot `XCCY_MATURITY`, PWC-forward parameterization, smoothing weight 1.0, and initial guess `[0.001]`. The joint builder uses the explicit snapshot, USD collateral, FX spot 1.10, exact mode defaults, initial guess 0.01, tolerance `TOLERANCE`, and 400 evaluations. + +- [ ] **Step 4: Implement optimization-proof result validation** + +```python +def validate_ranges(ranges, total: int, label: str) -> None: + expected_offset = 0 + for block in ranges: + require(block.offset == expected_offset, f"{label} range {block.name} is not contiguous") + require(block.size > 0, f"{label} range {block.name} is empty") + expected_offset += block.size + require(expected_offset == total, f"{label} ranges cover {expected_offset}, expected {total}") + +def validate_result(result) -> None: + require(result.converged, "joint XCCY calibration did not converge") + require(len(result.residuals) == result.jacobian_at_solution.rows(), "residual/Jacobian row mismatch") + validate_ranges(result.residual_ranges, len(result.residuals), "residual") + validate_ranges(result.parameter_ranges, result.jacobian_at_solution.cols(), "parameter") + forwards = result.fx_forward_curve.forwards + require(len(forwards) == len(result.fx_forward_curve.dates) and len(forwards) > 0, "invalid FX forward layout") + require(all(math.isfinite(value) for value in forwards), "non-finite FX forward") + require(math.isfinite(result.joint_max_abs_residual), "non-finite maximum residual") + require(result.joint_max_abs_residual <= TOLERANCE, "maximum residual exceeds tolerance") +``` + +Use no bare `assert`; checks must remain active under `python -O`. + +- [ ] **Step 5: Print diagnostics and add the module entry point** + +Print convergence, maximum residual, Jacobian dimensions, named parameter and residual half-open ranges, and every FX-forward date/value. Use: + +```python +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 6: Build and run normally and under optimization** + +```bash +UV_CACHE_DIR=/tmp/dal-pr230-uv-cache NUM_CORES=4 bash ./build_linux.sh --full +PYTHONPATH="$PWD/build/stage/Release-linux" \ + dal-python/.venv/bin/python dal-python/examples/007.xccy_joint_calibration.py +PYTHONPATH="$PWD/build/stage/Release-linux" \ + dal-python/.venv/bin/python -O dal-python/examples/007.xccy_joint_calibration.py +``` + +Expected: converged `3x3` solve, three contiguous parameter ranges, three contiguous residual ranges, finite FX forwards, residual at most `1e-9`, exit 0 in both modes. + +- [ ] **Step 7: Document and commit the Python slice** + +Under the current resettable/joint XCCY section in `dal-python/README.md`, link the script, state that it uses an explicit started-trade snapshot, list the printed diagnostics, and show the installed-package run command. Then: + +```bash +git add dal-python/examples/007.xccy_joint_calibration.py dal-python/README.md +git diff --cached --name-status +git diff --cached --check +git commit -m "feat(python): add joint XCCY example" +``` + +--- + +### Task 4: Expand `xccy_perf` to the exact 24-row matrix + +**Files:** +- Modify: `dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp` + +**Interfaces:** +- Consumes: `BuildXccyCashflowPlan`, `RequiredHistoricalFixings`, `CrossCurrencySwap_::TimeSpan`, existing `PricingCase_`, `MakeConfig`, `PrecomputeAll`, basis calibration helpers +- Produces: `HistoricalFixingValue`, `MakeHistoricalFixingSnapshot`, `MakeResetAwareCalibrationSpec`; 24 stable rows + +- [ ] **Step 1: Record the 21-row baseline** + +```bash +cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \ + -DDAL_BUILD_PUBLIC=OFF -DDAL_CPP_BUILD_TESTS=OFF \ + -DDAL_CPP_BUILD_EXAMPLES=OFF -DDAL_CPP_BUILD_BENCHMARKS=ON \ + -DDAL_BUILD_PYTHON=OFF -DDAL_USE_XAD_AAD=OFF \ + -DDAL_USE_CODIPACK_AAD=OFF -DDAL_USE_ADEPT_AAD=OFF \ + -DDAL_ENABLE_NATIVE_ARCH=ON +cmake --build build --target xccy_perf --parallel 4 +DAL_NUM_THREADS=4 build/dal-cpp/benchmarks/xccy_perf/xccy_perf > /tmp/xccy_perf-before.txt +``` + +Expected: 21 rows; the two started-basket labels and reset-aware calibration label are absent. + +- [ ] **Step 2: Add one authoritative union snapshot helper** + +```cpp +double HistoricalFixingValue(const FixingRequest_& request, const CrossCurrencySwapConfig_& config) { + if (request.indexName_ == config.domesticRateFixing_.indexName_) + return 0.04; + if (request.indexName_ == config.foreignRateFixing_.indexName_) + return 0.03; + REQUIRE(request.indexName_ == FxIndexName(config.pair_), "Unexpected XCCY benchmark fixing identity"); + return 1.20; +} + +Handle_ MakeHistoricalFixingSnapshot(const std::vector& instruments, + const DateTime_& valuationTime, + const CrossCurrencySwapConfig_& config); +``` + +For each instrument, obtain its start/maturity via `TimeSpan()`, build its plan, collect `RequiredHistoricalFixings`, and insert into one nested map. Existing keys must have the same value; `REQUIRE` on conflicts. Construct one immutable snapshot after all requests are deduplicated and before any `Precompute` call. + +- [ ] **Step 3: Populate the ten-instrument started-MTM basket** + +Replace `MakeInProgressMtmCase` with ten MTM swaps sharing `start = Date::AddMonths(fixture.today_, -3)` and maturities `Date::AddMonths(start, 12 * year)` for years 1 through 10. Build the union snapshot first, construct one intraday market with a 0.0020 basis curve, then precompute all rates and return: + +```cpp +return PricingCase_{"in-progress MTM", instruments.back(), rates.back(), rates, market}; +``` + +Run the benchmark. Expected: 23 rows and these two new labels: + +```text +XCCY in-progress MTM 10-instrument BASKET / pass +XCCY in-progress MTM 10-instrument PER-INSTRUMENT +``` + +- [ ] **Step 4: Add the mixed reset-aware staged fixture** + +Implement: + +```cpp +CrossCurrencyCalibrationSpec_ MakeResetAwareCalibrationSpec(const Fixture_& fixture); +``` + +Use valuation `DateTime_(fixture.today_, 12, 0)`, one started MTM trade from `today - 3M` to `start + 12M`, and fourteen future trades starting `today + 1M` with maturities at 12, 18, 24, 30, 36, 42, 48, 54, 60, 72, 84, 96, 108, and 120 months from today. Alternate future `RESETTABLE`/`MARK_TO_MARKET`, starting with `RESETTABLE`. + +Build one union snapshot across all 15 zero-quote prototypes. Quote them from the known intraday market using existing blocks, FX 1.10, USD collateral, snapshot, and the existing five-knot 0.0020 PWC basis curve. Populate the staged spec with the same blocks, knots, snapshot, tolerance `1e-8`, fit tolerance `1e-7`, and initial guess 0.0. + +Extend the untimed `ValidateBasisCalibration` check so diagnostic vectors have 15 entries, analytic forward Jacobian is `15x5`, and effective inverse is `5x15` when requested. + +- [ ] **Step 5: Add exactly one timed staged row** + +```cpp +const CrossCurrencyCalibrationSpec_ resetAwareBasisSpec = MakeResetAwareCalibrationSpec(fixture); +ValidateBasisCalibration(resetAwareBasisSpec, basisAnalyticDiagnostics); +RunBasisCalibration("XCCY reset-aware basis ANALYTIC +DIAG (15 instruments, 5 knots)", + resetAwareBasisSpec, + basisAnalyticDiagnostics); +``` + +Place the row after the three existing basis-only rows and before joint rows. Add no other reset-aware solver/mode combination. + +- [ ] **Step 6: Format, build, time, and verify the matrix** + +```bash +clang-format -i -sort-includes=0 dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp +cmake --build build --target xccy_perf --parallel 4 +DAL_NUM_THREADS=4 /usr/bin/time -f '%e' -o /tmp/xccy_perf.seconds \ + build/dal-cpp/benchmarks/xccy_perf/xccy_perf > /tmp/xccy_perf.txt +``` + +Parse benchmark rows with the same regular expression used by `.github/scripts/check_benchmark_regressions.py` and require this exact order: + +```python +expected = [ + "XCCY fixed 10Y PRECOMPUTE / operation", + "XCCY fixed 10Y PRICE / operation", + "XCCY fixed 10-instrument BASKET / pass", + "XCCY fixed 10-instrument PER-INSTRUMENT", + "XCCY resettable 10Y PRECOMPUTE / operation", + "XCCY resettable 10Y PRICE / operation", + "XCCY resettable 10-instrument BASKET / pass", + "XCCY resettable 10-instrument PER-INSTRUMENT", + "XCCY MTM 10Y PRECOMPUTE / operation", + "XCCY MTM 10Y PRICE / operation", + "XCCY MTM 10-instrument BASKET / pass", + "XCCY MTM 10-instrument PER-INSTRUMENT", + "XCCY in-progress MTM 10Y PRECOMPUTE / operation", + "XCCY in-progress MTM 10Y PRICE / operation", + "XCCY in-progress MTM 10-instrument BASKET / pass", + "XCCY in-progress MTM 10-instrument PER-INSTRUMENT", + "XCCY basis-only CALIBRATION (15 instruments, 5 knots)", + "XCCY basis-only ANALYTIC SOLVE (15 instruments, 5 knots)", + "XCCY basis-only BUMPED +DIAG (15 instruments, 5 knots)", + "XCCY reset-aware basis ANALYTIC +DIAG (15 instruments, 5 knots)", + "XCCY joint ANALYTIC SOLVE (15 XCCY, 3x5 knots)", + "XCCY joint ANALYTIC +DIAG (15 XCCY, 3x5 knots)", + "XCCY joint BUMPED +DIAG (15 XCCY, 3x5 knots)", + "XCCY joint ANALYTIC APPROXIMATE (15 XCCY, 3x5 knots)", +] +assert actual == expected, (len(actual), actual) +assert len(actual) == len(set(actual)) == 24 +print("xccy_perf: 24 unique rows") +``` + +Record `/tmp/xccy_perf.seconds`. Above 15 seconds on the same AADET/4-thread host is an investigation trigger, not a portable failure gate. + +- [ ] **Step 7: Commit only the benchmark source** + +```bash +git add dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp +git diff --cached --name-status +git diff --cached --check +git commit -m "perf(xccy): extend reset-aware benchmark coverage" +``` + +--- + +### Task 5: Run `xccy_perf` in Linux and Windows benchmark smoke jobs + +**Files:** +- Modify: `.github/workflows/cmake-linux.yml` +- Modify: `.github/workflows/cmake-windows.yml` +- Must remain unchanged: `.github/scripts/check_benchmark_regressions.py` + +**Interfaces:** +- Consumes: installed/built `xccy_perf` from Task 4 +- Produces: `### xccy_perf` output in both benchmark job summaries; no regression-gate registration + +- [ ] **Step 1: Add the target to both head-side arrays** + +Append after `curve_calibration_perf` in each relevant Bash array: + +```yaml + curve_calibration_perf + xccy_perf +``` + +Locations: Linux `Run head benchmarks`; Windows `Run benchmarks`. Do not alter the Linux base build or paired comparison commands. + +- [ ] **Step 2: Verify exact workflow scope** + +```bash +test "$(rg -c '^ xccy_perf$' .github/workflows/cmake-linux.yml)" -eq 1 +test "$(rg -c '^ xccy_perf$' .github/workflows/cmake-windows.yml)" -eq 1 +if rg -n 'xccy_perf' .github/scripts/check_benchmark_regressions.py; then exit 1; fi +git diff --check +``` + +Expected: one array entry per workflow, no regression-script entry, clean diff. + +- [ ] **Step 3: Commit only CI wiring** + +```bash +git add .github/workflows/cmake-linux.yml .github/workflows/cmake-windows.yml +git diff --cached --name-status +git diff --cached --check +git commit -m "ci: run XCCY benchmark smoke coverage" +``` + +--- + +### Task 6: Full verification, DAL review, and PR #230 update + +**Files:** +- Verify all files changed since `4523ff5a8bd1d02c0b48afc85f4a1cacad5a3c66` +- Do not create process/history documentation outside `.codex/artifacts/` + +**Interfaces:** +- Consumes: Tasks 1-5 commits and controlling spec +- Produces: verified exact PR head, updated PR body, no blocking DAL findings + +- [ ] **Step 1: Run the full clean workflow** + +```bash +UV_CACHE_DIR=/tmp/dal-pr230-uv-cache NUM_CORES=4 bash ./build_linux.sh --full +``` + +Expected: core/public CTest, direct core/public tests, Python tests, portable Excel smoke, examples, benchmarks, Machinist drift, and documentation checks all succeed. + +- [ ] **Step 2: Re-run the new observable surfaces explicitly** + +```bash +build/Release-linux/dal-cpp/examples/xccy_reset_pricing/xccy_reset_pricing +PYTHONPATH="$PWD/build/stage/Release-linux" \ + dal-python/.venv/bin/python dal-python/examples/007.xccy_joint_calibration.py +PYTHONPATH="$PWD/build/stage/Release-linux" \ + dal-python/.venv/bin/python -O dal-python/examples/007.xccy_joint_calibration.py +DAL_NUM_THREADS=4 build/dal-cpp/benchmarks/xccy_perf/xccy_perf > /tmp/xccy_perf-final.txt +python3 .github/scripts/check_docs.py +git diff --check +``` + +Expected: both examples exit 0, optimized Python checks remain active, benchmark emits 24 unique rows, docs/diff checks pass. + +- [ ] **Step 3: Run the DAL reviewer gate** + +Review `4523ff5a8bd1d02c0b48afc85f4a1cacad5a3c66..HEAD` against every FR/NFR and check: + +```text +Domestic-spread sign and domestic annuity are independent and correct. +No production code or binding changed. +C++ example uses explicit state and consumes its snapshot. +Python example uses only bound names and no bare assert. +All 21 old benchmark labels remain unchanged; total is 24 unique rows. +No paired-regression script change exists. +Both workflow arrays contain xccy_perf once. +Docs are current-state only and generated files are untouched. +``` + +Any blocking finding returns to the owning task, followed by focused and full re-verification. + +- [ ] **Step 4: Verify scope and commit state** + +```bash +git status --short --branch +git diff --check origin/codex/xccy-resettable-mtm...HEAD +git log --oneline 4523ff5a8bd1d02c0b48afc85f4a1cacad5a3c66..HEAD +``` + +Expected: clean tree and five scoped implementation commits. + +- [ ] **Step 5: Push and update PR metadata** + +Fetch first and require the remote PR head to be an ancestor of local `HEAD`; then push with the known SSH-config bypass if the system SSH wrapper fails. Update the PR body through `gh api` if `gh pr edit` hits the deprecated Projects Classic GraphQL error. Include the new examples, 24-row `xccy_perf`, Linux/Windows smoke execution, domestic-spread coverage, and fresh verification totals. + +- [ ] **Step 6: Verify exact-head CI and review state** + +```bash +head_sha=$(gh pr view 230 --json headRefOid --jq .headRefOid) +git rev-parse HEAD +gh api "repos/wegamekinglc/Derivatives-Algorithms-Lib/commits/${head_sha}/check-runs?per_page=100" +gh pr view 230 --json url,state,isDraft,mergeable,reviewDecision,headRefOid +``` + +Expected: local, remote branch, and PR head SHAs match; all exact-head check runs complete successfully; PR remains open, ready, and mergeable. Do not merge unless the user explicitly requests it. From ba931652740d8dd56dcf117e1fdc353ea372b52c Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Wed, 15 Jul 2026 02:50:29 +0800 Subject: [PATCH 32/38] test(xccy): cover domestic-leg par spread --- dal-cpp/tests/curve/test_xccypricing.cpp | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/dal-cpp/tests/curve/test_xccypricing.cpp b/dal-cpp/tests/curve/test_xccypricing.cpp index d9642c3a6..4219fd17a 100644 --- a/dal-cpp/tests/curve/test_xccypricing.cpp +++ b/dal-cpp/tests/curve/test_xccypricing.cpp @@ -415,6 +415,45 @@ namespace { }; } // namespace +TEST(XccyPricingTest, TestDomesticLegParSpreadMatchesHandCalculation) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED); + config.convention_.initialNotionalExchange_ = false; + config.convention_.finalNotionalExchange_ = false; + config.convention_.spreadOnForeignLeg_ = false; + + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 7, 4), config); + ASSERT_EQ(plan.domesticPeriods_.size(), 2U); + ASSERT_EQ(plan.foreignPeriods_.size(), 2U); + + constexpr double domesticDf = 0.96; + constexpr double foreignDf = 0.99; + constexpr double basisDf = 0.98; + constexpr double spot = 1.10; + const CustomPricingMarket_ curves(domesticDf, foreignDf, basisDf, spot); + const auto market = curves.View(DateTime_(Date_(2024, 1, 1), 12, 0)); + + double domesticPv = 0.0; + double domesticAnnuity = 0.0; + for (const auto& period : plan.domesticPeriods_) { + const double annuity = config.domesticNotional_ * period.accrual_.dcf_ * domesticDf; + const double forwardRate = (1.0 / domesticDf - 1.0) / period.accrual_.dcf_; + domesticPv += forwardRate * annuity; + domesticAnnuity += annuity; + } + + const double foreignConversion = spot * foreignDf / basisDf; + double foreignPv = 0.0; + for (const auto& period : plan.foreignPeriods_) { + const double annuity = config.foreignNotional_ * period.accrual_.dcf_ * foreignConversion; + const double forwardRate = (1.0 / foreignDf - 1.0) / period.accrual_.dcf_; + foreignPv += forwardRate * annuity; + } + + const double expectedParSpread = (foreignPv - domesticPv) / domesticAnnuity; + ASSERT_LT(expectedParSpread, 0.0); + ASSERT_NEAR(PriceXccyParSpread(plan, market, MarketFixingSnapshot_()), expectedParSpread, 1.0e-12); +} + TEST(XccyPricingTest, TestFxResetAtValuationUsesSuppliedFixing) { const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 4), Date_(2024, 7, 4), MakeQuarterlyConfig(XccyNotionalMode_::Value_::MARK_TO_MARKET)); MarketFixingSnapshot_::values_t values; From f992bef01ea7e3e5f768d62998669b2b3ef95ac9 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Wed, 15 Jul 2026 02:58:19 +0800 Subject: [PATCH 33/38] feat(xccy): add reset pricing example --- dal-cpp/examples/CMakeLists.txt | 2 + .../xccy_reset_pricing/CMakeLists.txt | 19 ++ .../xccy_reset_pricing/xccy_reset_pricing.cpp | 257 ++++++++++++++++++ docs/methodology/xccy_calibration.md | 19 +- 4 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 dal-cpp/examples/xccy_reset_pricing/CMakeLists.txt create mode 100644 dal-cpp/examples/xccy_reset_pricing/xccy_reset_pricing.cpp diff --git a/dal-cpp/examples/CMakeLists.txt b/dal-cpp/examples/CMakeLists.txt index a2da371a8..8b4175586 100644 --- a/dal-cpp/examples/CMakeLists.txt +++ b/dal-cpp/examples/CMakeLists.txt @@ -7,6 +7,7 @@ add_subdirectory(interpolate_curve) add_subdirectory(joint_multi_curve_calibration) add_subdirectory(xccy_curve_calibration) add_subdirectory(xccy_mtm_calibration) +add_subdirectory(xccy_reset_pricing) add_subdirectory(european_mc) add_subdirectory(european_fd) add_subdirectory(snowball) @@ -33,6 +34,7 @@ set(DAL_EXAMPLE_TARGETS joint_multi_curve_calibration xccy_curve_calibration xccy_mtm_calibration + xccy_reset_pricing european_mc european_fd snowball diff --git a/dal-cpp/examples/xccy_reset_pricing/CMakeLists.txt b/dal-cpp/examples/xccy_reset_pricing/CMakeLists.txt new file mode 100644 index 000000000..a4cb6ef37 --- /dev/null +++ b/dal-cpp/examples/xccy_reset_pricing/CMakeLists.txt @@ -0,0 +1,19 @@ +file(GLOB_RECURSE XCCY_RESET_PRICING_FILES CONFIGURE_DEPENDS "*.hpp" "*.cpp") +add_executable(xccy_reset_pricing ${XCCY_RESET_PRICING_FILES}) +target_link_libraries(xccy_reset_pricing dal_library) + +if(DAL_USE_XAD_AAD) + target_link_libraries(xccy_reset_pricing XAD::xad) +elseif(DAL_USE_CODIPACK_AAD) + target_link_libraries(xccy_reset_pricing CoDiPack) +elseif(DAL_USE_ADEPT_AAD) + target_link_libraries(xccy_reset_pricing adept) +endif() + +if(NOT MSVC) + target_link_libraries(xccy_reset_pricing pthread) +endif() + +install(TARGETS xccy_reset_pricing + RUNTIME DESTINATION bin + PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE) diff --git a/dal-cpp/examples/xccy_reset_pricing/xccy_reset_pricing.cpp b/dal-cpp/examples/xccy_reset_pricing/xccy_reset_pricing.cpp new file mode 100644 index 000000000..26e42e800 --- /dev/null +++ b/dal-cpp/examples/xccy_reset_pricing/xccy_reset_pricing.cpp @@ -0,0 +1,257 @@ +// +// Created by Codex on 2026/7/15. +// + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Dal; + +namespace { + const CollateralType_ OIS(CollateralType_::Value_::OIS); + const PeriodLength_ THREE_MONTHS("3M"); + const Date_ today(2025, 1, 16); + const Vector_ knots = { + Date::AddMonths(today, 6), + Date::AddMonths(today, 18), + Date::AddMonths(today, 36), + }; + + struct ModeResult_ { + const char* label_; + int periodCount_ = 0; + int resetCount_ = 0; + int mtmDeltaCount_ = 0; + double nextDomesticNotional_ = 0.0; + double parQuote_ = 0.0; + }; + + Handle_ Pwc(const String_& name, const Ccy_& ccy, const Vector_<>& values) { + return Handle_(NewDiscountPWC(name, ccy.String(), PiecewiseConstant_(knots, values))); + } + + Handle_ Block(const String_& name, const Ccy_& ccy, const Vector_<>& oisValues, const Vector_<>& threeMonthValues) { + return Handle_(new CurveBlock_(name, ccy.String(), {{OIS, Pwc(name + "_ois", ccy, oisValues)}}, + {{THREE_MONTHS, Pwc(name + "_3m", ccy, threeMonthValues)}}, DayBasis_("ACT_365F"))); + } + + struct MarketFixture_ { + Handle_ usd_ = Block("xccy_reset_usd", Ccy_("USD"), {0.015, 0.022, 0.030}, {0.025, 0.035, 0.050}); + Handle_ eur_ = Block("xccy_reset_eur", Ccy_("EUR"), {0.008, 0.012, 0.018}, {0.018, 0.025, 0.032}); + Handle_ basis_ = Pwc("xccy_reset_usd_basis", Ccy_("USD"), {0.001, 0.003, 0.006}); + + CrossCurrencyMarket_ Market(const DateTime_& valuationTime, const Handle_& fixings) const { + CrossCurrencyMarket_ result(usd_, eur_, 1.10, valuationTime, Ccy_("USD"), fixings); + result.SetBasisCurve(basis_); + return result; + } + }; + + RateIndexConvention_ IndexConvention() { + RateIndexConvention_ result; + result.useProjectionCurve_ = true; + result.forecastTenor_ = THREE_MONTHS; + result.dayBasis_ = DayBasis_("ACT_365F"); + result.businessDayConvention_ = BizDayConvention_("Unadjusted"); + result.fixingHolidays_ = Holidays::None(); + result.accrualHolidays_ = Holidays::None(); + result.collateral_ = OIS; + return result; + } + + RateLegConvention_ LegConvention() { + RateLegConvention_ result; + result.paymentFrequency_ = THREE_MONTHS; + result.dayBasis_ = DayBasis_("ACT_365F"); + result.businessDayConvention_ = BizDayConvention_("Unadjusted"); + result.paymentConvention_ = BizDayConvention_("Unadjusted"); + result.accrualHolidays_ = Holidays::None(); + result.paymentHolidays_ = Holidays::None(); + return result; + } + + CrossCurrencySwapConfig_ Config(XccyNotionalMode_ mode) { + CrossCurrencySwapConfig_ result; + result.pair_ = CurrencyPair_(Ccy_("USD"), Ccy_("EUR")); + result.domesticNotional_ = 110.0; + result.foreignNotional_ = 100.0; + result.notionalMode_ = mode; + result.convention_.initialNotionalExchange_ = true; + result.convention_.finalNotionalExchange_ = true; + result.convention_.spreadOnForeignLeg_ = true; + result.convention_.domesticIndex_ = IndexConvention(); + result.convention_.foreignIndex_ = IndexConvention(); + result.convention_.domesticLeg_ = LegConvention(); + result.convention_.foreignLeg_ = LegConvention(); + result.fxReset_.fixingLag_ = 0; + result.fxReset_.fixingHolidays_ = Holidays::None(); + result.fxReset_.fixingConvention_ = BizDayConvention_("Unadjusted"); + result.fxReset_.fixingHour_ = 10; + result.fxReset_.fixingMinute_ = 30; + result.domesticRateFixing_ = {"USD-XCCY-RESET-3M", 11, 0}; + result.foreignRateFixing_ = {"EUR-XCCY-RESET-3M", 11, 0}; + return result; + } + + const char* ModeLabel(XccyNotionalMode_ mode) { + if (mode == XccyNotionalMode_::Value_::FIXED) + return "FIXED"; + if (mode == XccyNotionalMode_::Value_::RESETTABLE) + return "RESETTABLE"; + if (mode == XccyNotionalMode_::Value_::MARK_TO_MARKET) + return "MARK_TO_MARKET"; + THROW("Unknown XCCY notional mode"); + } + + ModeResult_ EvaluateFutureMode(const MarketFixture_& fixture, XccyNotionalMode_ mode) { + const DateTime_ valuationTime(today, 9, 0); + const Date_ start = Date::AddMonths(today, 3); + const Date_ maturity = Date::AddMonths(start, 24); + const CrossCurrencySwapConfig_ config = Config(mode); + const XccyCashflowPlan_ plan = BuildXccyCashflowPlan(start, maturity, config); + const CrossCurrencySwap_ swap(start, start, maturity, 0.0, config); + const Handle_ fixings(new MarketFixingSnapshot_()); + const CrossCurrencyMarket_ market = fixture.Market(valuationTime, fixings); + + const auto& convention = plan.config_.convention_; + Tape::JointCurveBlock_ domestic; + Tape::JointCurveBlock_ foreign; + domestic.discountCurves.emplace(convention.domesticIndex_.collateral_, &market.DomesticDiscountCurve(convention.domesticIndex_.collateral_)); + foreign.discountCurves.emplace(convention.foreignIndex_.collateral_, &market.ForeignDiscountCurve(convention.foreignIndex_.collateral_)); + if (convention.domesticIndex_.useProjectionCurve_) { + domestic.forwardCurves.emplace( + convention.domesticIndex_.forecastTenor_, + &market.DomesticForwardCurve(convention.domesticIndex_.forecastTenor_, convention.domesticIndex_.collateral_)); + } + if (convention.foreignIndex_.useProjectionCurve_) { + foreign.forwardCurves.emplace(convention.foreignIndex_.forecastTenor_, + &market.ForeignForwardCurve(convention.foreignIndex_.forecastTenor_, convention.foreignIndex_.collateral_)); + } + + XccyMarketView_ view; + view.valuationTime_ = market.ValuationTime(); + view.pair_ = CurrencyPair_(market.DomesticCcy(), market.ForeignCcy()); + view.collateralCurrency_ = market.CollateralCurrency(); + view.fxSpot_ = market.FxSpot(); + view.domestic_ = &domestic; + view.foreign_ = &foreign; + view.basis_ = market.BasisCurve(); + + const XccyResolvedNotionals_ notionals = ResolveXccyNotionals(plan, view, *fixings); + ModeResult_ result; + result.label_ = ModeLabel(mode); + result.periodCount_ = static_cast(plan.domesticPeriods_.size()); + result.resetCount_ = static_cast(plan.resets_.size()); + result.mtmDeltaCount_ = static_cast(notionals.mtmDeltas_.size()); + REQUIRE(notionals.domesticNotionals_.size() > 1, "Future XCCY example requires at least two domestic periods"); + result.nextDomesticNotional_ = notionals.domesticNotionals_[1]; + result.parQuote_ = (*swap.Precompute())(market); + const double directQuote = PriceXccyParSpread(plan, view, *fixings); + REQUIRE(std::fabs(result.parQuote_ - directQuote) < 1.0e-14, "Precomputed and direct XCCY par quotes must agree"); + return result; + } + + void PrintMode(const ModeResult_& result) { + std::cout << result.label_ << " periods=" << result.periodCount_ << " resets=" << result.resetCount_ + << " mtm_deltas=" << result.mtmDeltaCount_ << " next_domestic_notional=" << result.nextDomesticNotional_ + << " par_quote_bp=" << 1.0e4 * result.parQuote_ << '\n'; + } + + Handle_ SnapshotFor(const XccyCashflowPlan_& plan, const DateTime_& valuationTime) { + const CrossCurrencySwapConfig_& config = plan.config_; + const String_ fxIndex = FxIndexName(config.pair_); + const Vector_ requests = RequiredHistoricalFixings(plan, valuationTime); + REQUIRE(!requests.empty(), "Started MTM example requires historical fixing requests"); + + bool hasDomestic = false; + bool hasForeign = false; + bool hasFx = false; + MarketFixingSnapshot_::values_t values; + for (const auto& request : requests) { + std::cout << "historical_fixing index=" << request.indexName_ << " time=" << DateTime::ToString(request.fixingTime_) << '\n'; + if (request.indexName_ == config.domesticRateFixing_.indexName_) { + values["USD-XCCY-RESET-3M"][request.fixingTime_] = 0.040; + hasDomestic = true; + } else if (request.indexName_ == config.foreignRateFixing_.indexName_) { + values["EUR-XCCY-RESET-3M"][request.fixingTime_] = 0.030; + hasForeign = true; + } else if (request.indexName_ == fxIndex) { + values[fxIndex][request.fixingTime_] = 1.20; + hasFx = true; + } else { + THROW("Unknown historical XCCY fixing identity: " + request.indexName_); + } + } + REQUIRE(hasDomestic && hasForeign && hasFx, "Started MTM example requires USD, EUR, and FX historical fixing requests"); + return Handle_(new MarketFixingSnapshot_(values)); + } + + void RunStartedMtm(const MarketFixture_& fixture) { + const DateTime_ valuationTime(today, 12, 0); + const Date_ start = Date::AddMonths(today, -3); + const Date_ maturity = Date::AddMonths(start, 24); + const CrossCurrencySwapConfig_ config = Config(XccyNotionalMode_::Value_::MARK_TO_MARKET); + const XccyCashflowPlan_ plan = BuildXccyCashflowPlan(start, maturity, config); + const Handle_ fixings = SnapshotFor(plan, valuationTime); + const CrossCurrencyMarket_ market = fixture.Market(valuationTime, fixings); + REQUIRE(market.Fixings().get() == fixings.get(), "Started MTM market must retain its immutable fixing snapshot"); + const CrossCurrencySwap_ swap(start, start, maturity, 0.0, config); + const double parQuote = (*swap.Precompute())(market); + REQUIRE(std::isfinite(parQuote), "Started MTM par quote must be finite"); + std::cout << "STARTED_MARK_TO_MARKET par_quote_bp=" << 1.0e4 * parQuote << '\n'; + } + + bool RunExample() { + std::cout << std::fixed << std::setprecision(8); + const MarketFixture_ fixture; + const ModeResult_ fixed = EvaluateFutureMode(fixture, XccyNotionalMode_::Value_::FIXED); + const ModeResult_ resettable = EvaluateFutureMode(fixture, XccyNotionalMode_::Value_::RESETTABLE); + const ModeResult_ mtm = EvaluateFutureMode(fixture, XccyNotionalMode_::Value_::MARK_TO_MARKET); + PrintMode(fixed); + PrintMode(resettable); + PrintMode(mtm); + + REQUIRE(fixed.resetCount_ == 0 && fixed.mtmDeltaCount_ == 0, "FIXED mode must have zero resets and zero MTM deltas"); + REQUIRE(resettable.resetCount_ == resettable.periodCount_ - 1 && resettable.mtmDeltaCount_ == 0, + "RESETTABLE mode must reset after every first period and have zero MTM deltas"); + REQUIRE(mtm.resetCount_ == mtm.periodCount_ - 1 && mtm.mtmDeltaCount_ == mtm.periodCount_ - 1, + "MARK_TO_MARKET mode must reset and exchange a notional delta after every first period"); + REQUIRE(std::fabs(fixed.parQuote_ - resettable.parQuote_) > 1.0e-12, "FIXED and RESETTABLE par quotes must differ"); + REQUIRE(std::fabs(fixed.parQuote_ - mtm.parQuote_) > 1.0e-12, "FIXED and MARK_TO_MARKET par quotes must differ"); + REQUIRE(std::fabs(resettable.parQuote_ - mtm.parQuote_) > 1.0e-12, "RESETTABLE and MARK_TO_MARKET par quotes must differ"); + REQUIRE(std::isfinite(fixed.parQuote_) && std::isfinite(resettable.parQuote_) && std::isfinite(mtm.parQuote_), + "Future XCCY par quotes must be finite"); + REQUIRE(std::isfinite(fixed.nextDomesticNotional_) && std::isfinite(resettable.nextDomesticNotional_) && + std::isfinite(mtm.nextDomesticNotional_), + "Future XCCY next notionals must be finite"); + + RunStartedMtm(fixture); + return true; + } +} // namespace + +int main() { + try { + RegisterAll_::Init(); + return RunExample() ? 0 : 1; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/docs/methodology/xccy_calibration.md b/docs/methodology/xccy_calibration.md index 0747ece8b..a655f03f7 100644 --- a/docs/methodology/xccy_calibration.md +++ b/docs/methodology/xccy_calibration.md @@ -159,14 +159,17 @@ If any analytic gate fails, the exception identifies the ineligible currency, declaration, instrument, or reset. Select `BUMPED` explicitly to run the same valid residual system without the analytic eligibility requirement. -## End-to-End Example - -`dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp` builds known -domestic, foreign, and basis curves, derives self-consistent quotes, supplies one -immutable snapshot containing historical domestic-rate, foreign-rate, and FX -observations for an already-started MTM swap, and recovers all three parameter -blocks in one call. It prints the convergence residual, Jacobian shape, block -ranges, and parameter-recovery errors. +## Runnable Examples + +- `xccy_reset_pricing` prices future fixed, resettable, and MTM swaps against + piecewise-constant discount, projection, and basis curves, validates their + reset and notional behavior, and prices an already-started MTM swap from an + immutable snapshot of historical domestic-rate, foreign-rate, and FX fixings. +- `xccy_mtm_calibration` builds known domestic, foreign, and basis curves, + derives self-consistent quotes, supplies an immutable fixing snapshot for an + already-started MTM swap, and recovers all three parameter blocks in one joint + calibration. It prints the convergence residual, Jacobian shape, block ranges, + and parameter-recovery errors. ## See Also From 57ff549c1dc5cb545766c95488eb875b2109a63a Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Wed, 15 Jul 2026 03:10:31 +0800 Subject: [PATCH 34/38] feat(python): add joint XCCY example --- dal-python/README.md | 10 + .../examples/007.xccy_joint_calibration.py | 213 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 dal-python/examples/007.xccy_joint_calibration.py diff --git a/dal-python/README.md b/dal-python/README.md index 484848aa9..51cfca03e 100644 --- a/dal-python/README.md +++ b/dal-python/README.md @@ -451,6 +451,16 @@ inverse, and named `parameter_ranges` / `residual_ranges`. Pass `JointXccyCalibrationOptions_` to select `ANALYTIC` or `BUMPED` and to disable either diagnostic matrix. +The runnable [joint XCCY calibration example](examples/007.xccy_joint_calibration.py) +uses an explicit fixing snapshot for a started MTM trade. It prints convergence, +the maximum absolute residual, Jacobian dimensions, named parameter and residual +half-open ranges, and every FX-forward date and value. With the `dal` package +installed in the active environment, run it from the repository root: + +```bash +python dal-python/examples/007.xccy_joint_calibration.py +``` + ## Troubleshooting ### "Cannot find DAL::public" during build diff --git a/dal-python/examples/007.xccy_joint_calibration.py b/dal-python/examples/007.xccy_joint_calibration.py new file mode 100644 index 000000000..c27a8b278 --- /dev/null +++ b/dal-python/examples/007.xccy_joint_calibration.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Calibrate USD, EUR, and XCCY basis curves in one joint solve.""" + +import math + +import dal + + +TODAY = dal.Date_(2025, 6, 20) +START = dal.Date_(2025, 3, 20) +XCCY_MATURITY = dal.Date_(2026, 3, 20) +CURVE_MATURITY = dal.Date_(2026, 6, 20) +VALUATION_TIME = dal.DateTime_(TODAY, 12, 0) +TOLERANCE = 1.0e-9 + + +def require(condition: bool, message: str) -> None: + """Raise an optimization-proof validation error.""" + if not condition: + raise RuntimeError(message) + + +def make_snapshot(): + """Create the authoritative observations for the started MTM trade.""" + start_fixing = dal.DateTime_(START, 11, 0) + today_fixing = dal.DateTime_(TODAY, 11, 0) + return dal.MarketFixingSnapshot_New( + { + "USD-JOINT-3M": {start_fixing: 0.040, today_fixing: 0.041}, + "EUR-JOINT-3M": {start_fixing: 0.030, today_fixing: 0.031}, + "FX[EUR/USD]": {dal.DateTime_(TODAY, 10, 30): 1.20}, + } + ) + + +def make_currency_spec(curve_name: str, ccy: str, market_rate: float): + """Build one OIS discount-curve declaration.""" + index = dal.RateIndexConvention_New( + dal.PeriodLength_New("3M"), + dal.DayBasis_New("ACT_365F"), + dal.CollateralType_OIS(), + ) + + declaration = dal.JointCurveDeclaration_() + declaration.curve_name = curve_name + declaration.instruments = [ + dal.Deposit_New(TODAY, TODAY, CURVE_MATURITY, market_rate, index) + ] + declaration.knot_dates = [CURVE_MATURITY] + declaration.target_collateral = dal.CollateralType_OIS() + declaration.calibrate_discount_curve = True + declaration.parameterization = dal.CurveParameterization.PIECEWISE_CONSTANT_FWD + declaration.log_df_scheme = dal.LogDfScheme.LOG_LINEAR + declaration.initial_guess_per_node = [market_rate] + + result = dal.JointCurrencyCurveSpec_() + result.ccy = dal.Ccy_(ccy) + result.libor_basis = dal.DayBasis_New("ACT_365F") + result.curves = [declaration] + return result + + +def make_mtm_instrument(): + """Build the started mark-to-market cross-currency swap.""" + domestic_leg = dal.RateLegConvention_New( + dal.PeriodLength_New("3M"), dal.DayBasis_New("ACT_365F") + ) + foreign_leg = dal.RateLegConvention_New( + dal.PeriodLength_New("3M"), dal.DayBasis_New("ACT_365F") + ) + domestic_index = dal.RateIndexConvention_New( + dal.PeriodLength_New("3M"), + dal.DayBasis_New("ACT_365F"), + dal.CollateralType_OIS(), + ) + foreign_index = dal.RateIndexConvention_New( + dal.PeriodLength_New("3M"), + dal.DayBasis_New("ACT_365F"), + dal.CollateralType_OIS(), + ) + + convention = dal.CrossCurrencyConvention_() + convention.initial_notional_exchange = True + convention.final_notional_exchange = True + convention.spread_on_foreign_leg = True + convention.domestic_leg = domestic_leg + convention.domestic_index = domestic_index + convention.foreign_leg = foreign_leg + convention.foreign_index = foreign_index + + domestic_fixing = dal.FixingIdentity_() + domestic_fixing.index_name = "USD-JOINT-3M" + domestic_fixing.fixing_hour = 11 + domestic_fixing.fixing_minute = 0 + + foreign_fixing = dal.FixingIdentity_() + foreign_fixing.index_name = "EUR-JOINT-3M" + foreign_fixing.fixing_hour = 11 + foreign_fixing.fixing_minute = 0 + + config = dal.CrossCurrencySwapConfigBuilder_() + config.pair = dal.CurrencyPair_New("USD", "EUR") + config.domestic_notional = 110.0 + config.foreign_notional = 100.0 + config.convention = convention + config.notional_mode = dal.XccyNotionalMode.MARK_TO_MARKET + config.fx_reset = dal.FxResetConvention_New( + 0, dal.Holidays_(""), dal.BizDayConvention_.FOLLOWING, 10, 30 + ) + config.domestic_rate_fixing = domestic_fixing + config.foreign_rate_fixing = foreign_fixing + + return dal.CrossCurrencySwap_New( + TODAY, START, XCCY_MATURITY, 0.001, config.build() + ) + + +def make_spec(): + """Assemble the simultaneous domestic, foreign, and basis solve.""" + basis = dal.XccyBasisCurveDeclaration_() + basis.curve_name = "usd_eur_basis" + basis.instruments = [make_mtm_instrument()] + basis.knot_dates = [XCCY_MATURITY] + basis.parameterization = dal.CurveParameterization.PIECEWISE_CONSTANT_FWD + basis.smoothing_weight = 1.0 + basis.initial_guess_per_node = [0.001] + + builder = dal.JointXccyCalibrationSpecBuilder_() + builder.valuation_time = VALUATION_TIME + builder.pair = dal.CurrencyPair_New("USD", "EUR") + builder.collateral_currency = dal.Ccy_("USD") + builder.fx_spot = 1.10 + builder.domestic = make_currency_spec("usd_ois", "USD", 0.040) + builder.foreign = make_currency_spec("eur_ois", "EUR", 0.030) + builder.basis = basis + builder.fixings = make_snapshot() + builder.solver_options.initial_guess = 0.01 + builder.solver_options.tolerance = TOLERANCE + builder.solver_options.max_evaluations = 400 + return builder.build() + + +def validate_ranges(ranges, total: int, label: str) -> None: + """Require named blocks to be non-empty, contiguous, and exhaustive.""" + expected_offset = 0 + for block in ranges: + require( + block.offset == expected_offset, + f"{label} range {block.name} is not contiguous", + ) + require(block.size > 0, f"{label} range {block.name} is empty") + expected_offset += block.size + require( + expected_offset == total, + f"{label} ranges cover {expected_offset}, expected {total}", + ) + + +def validate_result(result) -> None: + """Validate the joint calibration result under normal and optimized Python.""" + require(result.converged, "joint XCCY calibration did not converge") + require( + len(result.residuals) == result.jacobian_at_solution.rows(), + "residual/Jacobian row mismatch", + ) + validate_ranges(result.residual_ranges, len(result.residuals), "residual") + validate_ranges( + result.parameter_ranges, result.jacobian_at_solution.cols(), "parameter" + ) + forwards = result.fx_forward_curve.forwards + require( + len(forwards) == len(result.fx_forward_curve.dates) and len(forwards) > 0, + "invalid FX forward layout", + ) + require(all(math.isfinite(value) for value in forwards), "non-finite FX forward") + require( + math.isfinite(result.joint_max_abs_residual), + "non-finite maximum residual", + ) + require( + result.joint_max_abs_residual <= TOLERANCE, + "maximum residual exceeds tolerance", + ) + + +def print_ranges(label: str, ranges) -> None: + """Print named half-open calibration blocks.""" + print(f"{label} ranges:") + for block in ranges: + print(f" {block.name}: [{block.offset}, {block.offset + block.size})") + + +def main() -> int: + """Run and validate the installed-surface joint calibration example.""" + result = dal.CalibrateJointXccyMarket(make_spec()) + validate_result(result) + + jacobian = result.jacobian_at_solution + print(f"Converged: {result.converged}") + print(f"Maximum absolute residual: {result.joint_max_abs_residual:.12g}") + print(f"Jacobian dimensions: {jacobian.rows()}x{jacobian.cols()}") + print_ranges("Parameter", result.parameter_ranges) + print_ranges("Residual", result.residual_ranges) + print("FX forwards:") + for date, forward in zip( + result.fx_forward_curve.dates, result.fx_forward_curve.forwards + ): + print(f" {date}: {forward:.12g}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c52925c10779d7d67fa60bc2079bb0942640249e Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Wed, 15 Jul 2026 03:20:15 +0800 Subject: [PATCH 35/38] perf(xccy): extend reset-aware benchmark coverage --- dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp | 110 +++++++++++++++++---- 1 file changed, 93 insertions(+), 17 deletions(-) diff --git a/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp b/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp index 0a2da3000..47f8ce0bc 100644 --- a/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp +++ b/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp @@ -189,6 +189,31 @@ namespace { return result; } + double HistoricalFixingValue(const FixingRequest_& request, const CrossCurrencySwapConfig_& config) { + if (request.indexName_ == config.domesticRateFixing_.indexName_) + return 0.04; + if (request.indexName_ == config.foreignRateFixing_.indexName_) + return 0.03; + REQUIRE(request.indexName_ == FxIndexName(config.pair_), "Unexpected XCCY benchmark fixing identity"); + return 1.20; + } + + Handle_ MakeHistoricalFixingSnapshot(const std::vector& instruments, + const DateTime_& valuationTime, + const CrossCurrencySwapConfig_& config) { + MarketFixingSnapshot_::values_t values; + for (const auto& instrument : instruments) { + const auto span = instrument->TimeSpan(); + const XccyCashflowPlan_ plan = BuildXccyCashflowPlan(span.first, span.second, instrument->Config()); + for (const auto& request : RequiredHistoricalFixings(plan, valuationTime)) { + const double value = HistoricalFixingValue(request, config); + const auto inserted = values[request.indexName_].emplace(request.fixingTime_, value); + REQUIRE(inserted.second || inserted.first->second == value, "Conflicting XCCY benchmark historical fixing value"); + } + } + return Handle_(new MarketFixingSnapshot_(values)); + } + PricingCase_ MakePricingCase(const Fixture_& fixture, const std::string& label, XccyNotionalMode_ mode) { PricingCase_ result{label, mode == XccyNotionalMode_::Value_::FIXED ? MakeFixedSwap(fixture, 120) : MakeResetSwap(fixture, 120, mode), RateHandle_(), std::vector(), MakeMarket(fixture, 0.0020)}; @@ -200,26 +225,18 @@ namespace { PricingCase_ MakeInProgressMtmCase(const Fixture_& fixture) { const DateTime_ valuationTime(fixture.today_, 12, 0); const Date_ start = Date::AddMonths(fixture.today_, -3); - const Date_ maturity = Date::AddMonths(start, 120); const CrossCurrencySwapConfig_ config = MakeConfig(fixture, XccyNotionalMode_::Value_::MARK_TO_MARKET); - const SwapHandle_ swap(new CrossCurrencySwap_(start, start, maturity, 0.0, config)); - const XccyCashflowPlan_ plan = BuildXccyCashflowPlan(start, maturity, config); - - MarketFixingSnapshot_::values_t values; - for (const auto& request : RequiredHistoricalFixings(plan, valuationTime)) { - if (request.indexName_ == config.domesticRateFixing_.indexName_) - values[request.indexName_][request.fixingTime_] = 0.04; - else if (request.indexName_ == config.foreignRateFixing_.indexName_) - values[request.indexName_][request.fixingTime_] = 0.03; - else - values[request.indexName_][request.fixingTime_] = 1.20; - } - const Handle_ fixings(new MarketFixingSnapshot_(values)); + std::vector instruments; + instruments.reserve(10); + for (int year = 1; year <= 10; ++year) + instruments.push_back(SwapHandle_(new CrossCurrencySwap_(start, start, Date::AddMonths(start, 12 * year), 0.0, config))); + const Handle_ fixings = MakeHistoricalFixingSnapshot(instruments, valuationTime, config); CrossCurrencyMarket_ market(fixture.domesticBlock_, fixture.foreignBlock_, kFxSpot, valuationTime, Ccy_("USD"), fixings); const Vector_<> basisValues(fixture.basisKnots_.size(), 0.0020); market.SetBasisCurve( Handle_(NewDiscountPWC("xccy_perf_started_basis", "USD", PiecewiseConstant_(fixture.basisKnots_, basisValues)))); - return PricingCase_{"in-progress MTM", swap, swap->Precompute(), std::vector(), market}; + const std::vector rates = PrecomputeAll(instruments); + return PricingCase_{"in-progress MTM", instruments.back(), rates.back(), rates, market}; } CrossCurrencyCalibrationSpec_ MakeCalibrationSpec(const Fixture_& fixture) { @@ -245,6 +262,51 @@ namespace { return result; } + CrossCurrencyCalibrationSpec_ MakeResetAwareCalibrationSpec(const Fixture_& fixture) { + const DateTime_ valuationTime(fixture.today_, 12, 0); + const Date_ startedStart = Date::AddMonths(fixture.today_, -3); + const Date_ futureStart = Date::AddMonths(fixture.today_, 1); + const CrossCurrencySwapConfig_ startedConfig = MakeConfig(fixture, XccyNotionalMode_::Value_::MARK_TO_MARKET); + std::vector prototypes; + prototypes.reserve(15); + prototypes.push_back(SwapHandle_(new CrossCurrencySwap_(startedStart, startedStart, Date::AddMonths(startedStart, 12), 0.0, startedConfig))); + + const Vector_ futureMaturities = {12, 18, 24, 30, 36, 42, 48, 54, 60, 72, 84, 96, 108, 120}; + for (int i = 0; i < static_cast(futureMaturities.size()); ++i) { + const XccyNotionalMode_ mode = i % 2 == 0 ? XccyNotionalMode_::Value_::RESETTABLE : XccyNotionalMode_::Value_::MARK_TO_MARKET; + prototypes.push_back(SwapHandle_(new CrossCurrencySwap_(fixture.today_, futureStart, Date::AddMonths(fixture.today_, futureMaturities[i]), + 0.0, MakeConfig(fixture, mode)))); + } + + const Handle_ fixings = MakeHistoricalFixingSnapshot(prototypes, valuationTime, startedConfig); + CrossCurrencyMarket_ quoteMarket(fixture.domesticBlock_, fixture.foreignBlock_, kFxSpot, valuationTime, Ccy_("USD"), fixings); + const Vector_<> basisValues(fixture.basisKnots_.size(), 0.0020); + quoteMarket.SetBasisCurve( + Handle_(NewDiscountPWC("xccy_perf_reset_aware_basis", "USD", PiecewiseConstant_(fixture.basisKnots_, basisValues)))); + + CrossCurrencyCalibrationSpec_ result; + result.today_ = fixture.today_; + result.valuationTime_ = valuationTime; + result.collateralCurrency_ = Ccy_("USD"); + result.fixings_ = fixings; + result.basisPair_ = startedConfig.pair_; + result.domesticCurveBlock_ = fixture.domesticBlock_; + result.foreignCurveBlock_ = fixture.foreignBlock_; + result.fxSpot_ = kFxSpot; + result.knotDates_ = fixture.basisKnots_; + result.tolerance_ = 1.0e-8; + result.fitTolerance_ = 1.0e-7; + result.initialGuess_ = 0.0; + result.instruments_.reserve(prototypes.size()); + for (int i = 0; i < static_cast(prototypes.size()); ++i) { + const auto span = prototypes[i]->TimeSpan(); + const Date_ tradeDate = i == 0 ? startedStart : fixture.today_; + const double quote = (*prototypes[i]->Precompute())(quoteMarket); + result.instruments_.push_back(SwapHandle_(new CrossCurrencySwap_(tradeDate, span.first, span.second, quote, prototypes[i]->Config()))); + } + return result; + } + Handle_ QuotedDeposit(const Date_& today, const Date_& maturity, const RateIndexConvention_& index, const CurveBlock_& market) { const Handle_ prototype(new Deposit_(today, today, maturity, 0.0, index)); const double quote = (*prototype->Precompute(Handle_()))(market); @@ -393,8 +455,19 @@ namespace { const auto result = CalibrateCrossCurrencyMarket(spec, options); REQUIRE(std::isfinite(result.diagnostics_.maxAbsResidual_) && result.diagnostics_.maxAbsResidual_ <= spec.tolerance_, "Basis-only XCCY calibration benchmark must reprice inside its configured tolerance"); - REQUIRE(result.diagnostics_.residuals_.size() == spec.instruments_.size(), - "Basis-only XCCY calibration benchmark must report every instrument residual"); + const int instrumentCount = static_cast(spec.instruments_.size()); + const int parameterCount = static_cast(spec.knotDates_.size()); + REQUIRE(instrumentCount == 15 && parameterCount == 5, "Basis-only XCCY calibration benchmark requires exactly 15 instruments and 5 knots"); + REQUIRE(result.diagnostics_.instrumentNames_.size() == 15 && result.diagnostics_.marketRates_.size() == 15 && + result.diagnostics_.modelRates_.size() == 15 && result.diagnostics_.residuals_.size() == 15, + "Basis-only XCCY calibration benchmark must report exactly 15 instrument diagnostics"); + if (options.computeForwardJacobian_ && options.jacobianMode_ == CurveJacobianMode_::Value_::ANALYTIC) + REQUIRE(result.diagnostics_.jacobian_.Rows() == instrumentCount && result.diagnostics_.jacobian_.Cols() == parameterCount, + "Basis-only XCCY analytic forward Jacobian must be instruments by parameters"); + if (options.computeEffJacobianInverse_) + REQUIRE(result.diagnostics_.effJacobianInverse_.Rows() == parameterCount && + result.diagnostics_.effJacobianInverse_.Cols() == instrumentCount, + "Basis-only XCCY effective Jacobian inverse must be parameters by instruments"); REQUIRE(!result.fxForwardCurve_.forwards_.empty() && std::isfinite(result.fxForwardCurve_.forwards_.back()) && std::fabs(result.fxForwardCurve_.forwards_.back()) > 0.0, "Basis-only XCCY calibration benchmark requires a finite, non-zero dry-run checksum"); @@ -519,6 +592,7 @@ int main() { const CrossCurrencyCalibrationOptions_ basisAnalyticSolve = BasisOptions(CurveJacobianMode_::Value_::ANALYTIC, false); const CrossCurrencyCalibrationOptions_ basisAnalyticDiagnostics = BasisOptions(CurveJacobianMode_::Value_::ANALYTIC, true); const CrossCurrencyCalibrationOptions_ basisBumpedDiagnostics = BasisOptions(CurveJacobianMode_::Value_::BUMPED, true); + const CrossCurrencyCalibrationSpec_ resetAwareBasisSpec = MakeResetAwareCalibrationSpec(fixture); const JointXccyCalibrationSpec_ jointSpec = MakeJointCalibrationSpec(fixture); const JointXccyCalibrationOptions_ jointAnalyticSolve = JointOptions(CurveJacobianMode_::Value_::ANALYTIC, false); @@ -532,6 +606,7 @@ int main() { ValidateBasisCalibration(basisSpec, basisAnalyticSolve); ValidateBasisCalibration(basisSpec, basisAnalyticDiagnostics); ValidateBasisCalibration(basisSpec, basisBumpedDiagnostics); + ValidateBasisCalibration(resetAwareBasisSpec, basisAnalyticDiagnostics); ValidateJointCalibration(jointSpec, jointAnalyticSolve); ValidateJointCalibration(jointSpec, jointAnalyticDiagnostics); ValidateJointCalibration(jointSpec, jointBumpedDiagnostics); @@ -543,6 +618,7 @@ int main() { RunBasisCalibration("XCCY basis-only CALIBRATION (15 instruments, 5 knots)", basisSpec, basisAnalyticDiagnostics); RunBasisCalibration("XCCY basis-only ANALYTIC SOLVE (15 instruments, 5 knots)", basisSpec, basisAnalyticSolve); RunBasisCalibration("XCCY basis-only BUMPED +DIAG (15 instruments, 5 knots)", basisSpec, basisBumpedDiagnostics); + RunBasisCalibration("XCCY reset-aware basis ANALYTIC +DIAG (15 instruments, 5 knots)", resetAwareBasisSpec, basisAnalyticDiagnostics); RunJointCalibration("XCCY joint ANALYTIC SOLVE (15 XCCY, 3x5 knots)", jointSpec, jointAnalyticSolve); RunJointCalibration("XCCY joint ANALYTIC +DIAG (15 XCCY, 3x5 knots)", jointSpec, jointAnalyticDiagnostics); RunJointCalibration("XCCY joint BUMPED +DIAG (15 XCCY, 3x5 knots)", jointSpec, jointBumpedDiagnostics); From 0f452bb1b18dfb316f020a70148256bec5e9d741 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Wed, 15 Jul 2026 03:25:45 +0800 Subject: [PATCH 36/38] ci: run XCCY benchmark smoke coverage --- .github/workflows/cmake-linux.yml | 1 + .github/workflows/cmake-windows.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/cmake-linux.yml b/.github/workflows/cmake-linux.yml index dba2f50e8..4583377e2 100644 --- a/.github/workflows/cmake-linux.yml +++ b/.github/workflows/cmake-linux.yml @@ -417,6 +417,7 @@ jobs: script_mc_perf ycinstrument_perf curve_calibration_perf + xccy_perf ) mkdir -p benchmark-results diff --git a/.github/workflows/cmake-windows.yml b/.github/workflows/cmake-windows.yml index 01aca4d70..1337b4ece 100644 --- a/.github/workflows/cmake-windows.yml +++ b/.github/workflows/cmake-windows.yml @@ -254,6 +254,7 @@ jobs: script_mc_perf ycinstrument_perf curve_calibration_perf + xccy_perf ) mkdir -p benchmark-results From 1c537f8f12ca84c499fda97bc58e75b96952b8b1 Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Wed, 15 Jul 2026 03:47:50 +0800 Subject: [PATCH 37/38] refactor(xccy): split benchmark validation checks --- dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp | 23 ++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp b/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp index 47f8ce0bc..84865f975 100644 --- a/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp +++ b/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp @@ -451,16 +451,17 @@ namespace { REQUIRE(std::isfinite(checksum) && std::fabs(checksum) > 0.0, "XCCY basket checksum must be finite and non-zero"); } - void ValidateBasisCalibration(const CrossCurrencyCalibrationSpec_& spec, const CrossCurrencyCalibrationOptions_& options) { - const auto result = CalibrateCrossCurrencyMarket(spec, options); - REQUIRE(std::isfinite(result.diagnostics_.maxAbsResidual_) && result.diagnostics_.maxAbsResidual_ <= spec.tolerance_, - "Basis-only XCCY calibration benchmark must reprice inside its configured tolerance"); - const int instrumentCount = static_cast(spec.instruments_.size()); - const int parameterCount = static_cast(spec.knotDates_.size()); + void ValidateBasisDiagnostics(const CrossCurrencyCalibrationResult_& result, int instrumentCount, int parameterCount) { REQUIRE(instrumentCount == 15 && parameterCount == 5, "Basis-only XCCY calibration benchmark requires exactly 15 instruments and 5 knots"); REQUIRE(result.diagnostics_.instrumentNames_.size() == 15 && result.diagnostics_.marketRates_.size() == 15 && result.diagnostics_.modelRates_.size() == 15 && result.diagnostics_.residuals_.size() == 15, "Basis-only XCCY calibration benchmark must report exactly 15 instrument diagnostics"); + } + + void ValidateBasisJacobians(const CrossCurrencyCalibrationResult_& result, + const CrossCurrencyCalibrationOptions_& options, + int instrumentCount, + int parameterCount) { if (options.computeForwardJacobian_ && options.jacobianMode_ == CurveJacobianMode_::Value_::ANALYTIC) REQUIRE(result.diagnostics_.jacobian_.Rows() == instrumentCount && result.diagnostics_.jacobian_.Cols() == parameterCount, "Basis-only XCCY analytic forward Jacobian must be instruments by parameters"); @@ -468,6 +469,16 @@ namespace { REQUIRE(result.diagnostics_.effJacobianInverse_.Rows() == parameterCount && result.diagnostics_.effJacobianInverse_.Cols() == instrumentCount, "Basis-only XCCY effective Jacobian inverse must be parameters by instruments"); + } + + void ValidateBasisCalibration(const CrossCurrencyCalibrationSpec_& spec, const CrossCurrencyCalibrationOptions_& options) { + const auto result = CalibrateCrossCurrencyMarket(spec, options); + REQUIRE(std::isfinite(result.diagnostics_.maxAbsResidual_) && result.diagnostics_.maxAbsResidual_ <= spec.tolerance_, + "Basis-only XCCY calibration benchmark must reprice inside its configured tolerance"); + const int instrumentCount = static_cast(spec.instruments_.size()); + const int parameterCount = static_cast(spec.knotDates_.size()); + ValidateBasisDiagnostics(result, instrumentCount, parameterCount); + ValidateBasisJacobians(result, options, instrumentCount, parameterCount); REQUIRE(!result.fxForwardCurve_.forwards_.empty() && std::isfinite(result.fxForwardCurve_.forwards_.back()) && std::fabs(result.fxForwardCurve_.forwards_.back()) > 0.0, "Basis-only XCCY calibration benchmark requires a finite, non-zero dry-run checksum"); From 2915823e0a5f664ea8ddccddb65f4d8b8203129a Mon Sep 17 00:00:00 2001 From: wegamekinglc Date: Wed, 15 Jul 2026 04:45:42 +0800 Subject: [PATCH 38/38] docs(public-api): align API listing tables to the markdown style rule Reflow the four pipe tables in docs/public-api.md so each column is padded to its longest cell (content + 2) with compact separator rows, matching the markdown table style rule and the already-aligned methodology docs. Cell content is unchanged; only padding and separator widths differ. Addresses the Copilot review comment flagging the unaligned public-api.md table. --- docs/public-api.md | 78 +++++++++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/docs/public-api.md b/docs/public-api.md index 09d700574..5e2fbe45b 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -6,12 +6,12 @@ exhaustive reference for every core numerical type. ## API Layers -| Layer | Intended use | Compatibility contract | -|-------|--------------|------------------------| -| `DAL::cpp` | Direct access to quantitative algorithms and core types | Source-level core API; advanced consumers track core changes | +| Layer | Intended use | Compatibility contract | +|---------------|------------------------------------------------------------------------------------------|---------------------------------------------------------------------------| +| `DAL::cpp` | Direct access to quantitative algorithms and core types | Source-level core API; advanced consumers track core changes | | `DAL::public` | Construction, calibration, scripted valuation, random generation, and repository helpers | Convenience facade; exposes core types and does not promise ABI isolation | -| Python `dal` | Python-friendly wrappers over the public facade | Supported names are those exported by `_dal` and `dal/api.py` | -| Excel XLL | Worksheet functions and repository handles | Supported worksheet names come from generated registrations | +| Python `dal` | Python-friendly wrappers over the public facade | Supported names are those exported by `_dal` and `dal/api.py` | +| Excel XLL | Worksheet functions and repository handles | Supported worksheet names come from generated registrations | Installed C++ consumers should link imported targets instead of copying library paths. See the [installation guide](installation.md#installed-cmake-packages). @@ -37,20 +37,20 @@ on other toolchains. ### Public facade headers -| Header | Main entry points | -|--------|-------------------| -| `` | `InitGlobalData`, `SetEvaluationDate`, `GetEvaluationDate` | -| `` | `NewScriptProduct`, `DebugScriptProduct` | -| `` | `NewBSModelData`, `NewDupireModelData` | -| `` | `ValueByMonteCarlo` | -| `` | Pseudo/Sobol constructors and uniform/normal matrix fills | -| `` | Day-basis, tenor, collateral, rate-leg/index, currency-pair, FX-reset, and fixing-snapshot builders | -| `` | Deposit, FRA, future, swap, OIS, basis-swap, and fixed/resettable/MTM cross-currency-swap builders | -| `` | Piecewise-linear-forward, zero-rate, and curve-block builders | -| `` | `CurveCalibrationSpecBuilder_`, `CalibrateSingleCurve`, `CalibrateMultiCurveBundle` | -| `` | Staged and joint XCCY spec builders, calibration, and joint-result accessors | -| `` | Linear one-dimensional interpolation builder | -| `` | Repository find, erase, and size helpers for a configured host environment | +| Header | Main entry points | +|----------------------------------------|-----------------------------------------------------------------------------------------------------| +| `` | `InitGlobalData`, `SetEvaluationDate`, `GetEvaluationDate` | +| `` | `NewScriptProduct`, `DebugScriptProduct` | +| `` | `NewBSModelData`, `NewDupireModelData` | +| `` | `ValueByMonteCarlo` | +| `` | Pseudo/Sobol constructors and uniform/normal matrix fills | +| `` | Day-basis, tenor, collateral, rate-leg/index, currency-pair, FX-reset, and fixing-snapshot builders | +| `` | Deposit, FRA, future, swap, OIS, basis-swap, and fixed/resettable/MTM cross-currency-swap builders | +| `` | Piecewise-linear-forward, zero-rate, and curve-block builders | +| `` | `CurveCalibrationSpecBuilder_`, `CalibrateSingleCurve`, `CalibrateMultiCurveBundle` | +| `` | Staged and joint XCCY spec builders, calibration, and joint-result accessors | +| `` | Linear one-dimensional interpolation builder | +| `` | Repository find, erase, and size helpers for a configured host environment | The installed include path intentionally retains `dal-public/src/`. The facade also uses core `Handle_`, `Date_`, curve, model, and diagnostics types directly. @@ -173,17 +173,17 @@ import dal ### Common workflows -| Workflow | Python entry points | -|----------|---------------------| -| Dates/global state | `Date_`, `Year`, `Month`, `Day`, `EvaluationDate_Set`, `EvaluationDate_Get` | -| Script products | `Product_New`, `Product_Debug` | -| Models | `BSModelData_New`, `DupireModelData_New` | -| Valuation | `MonteCarlo_Value` | -| Random generation | `PseudoRSG_New`, `SobolRSG_New`, `*_Get_Uniform`, `*_Get_Normal` | -| Calendar operations | `Holidays_`, `Is_BizDay`, `NextBizDay`, `PrevBizDay`, `Adjust` | -| Curves | `DiscountZeroRate_New`, convention/instrument builders, `CurveCalibrationSpecBuilder_`, `CalibrateSingleCurve`, `CalibrateMultiCurveBundle`, `CalibrateXccyMarket`, `CalibrateJointXccyMarket` | -| XCCY reset data | `FixingIdentity_`, `FxResetConvention_`, `MarketFixingSnapshot_New`, `CrossCurrencySwapConfigBuilder_`, `XccyNotionalMode` | -| Convenience calibration | `calibrate_curve` from `dal/api.py` | +| Workflow | Python entry points | +|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Dates/global state | `Date_`, `Year`, `Month`, `Day`, `EvaluationDate_Set`, `EvaluationDate_Get` | +| Script products | `Product_New`, `Product_Debug` | +| Models | `BSModelData_New`, `DupireModelData_New` | +| Valuation | `MonteCarlo_Value` | +| Random generation | `PseudoRSG_New`, `SobolRSG_New`, `*_Get_Uniform`, `*_Get_Normal` | +| Calendar operations | `Holidays_`, `Is_BizDay`, `NextBizDay`, `PrevBizDay`, `Adjust` | +| Curves | `DiscountZeroRate_New`, convention/instrument builders, `CurveCalibrationSpecBuilder_`, `CalibrateSingleCurve`, `CalibrateMultiCurveBundle`, `CalibrateXccyMarket`, `CalibrateJointXccyMarket` | +| XCCY reset data | `FixingIdentity_`, `FxResetConvention_`, `MarketFixingSnapshot_New`, `CrossCurrencySwapConfigBuilder_`, `XccyNotionalMode` | +| Convenience calibration | `calibrate_curve` from `dal/api.py` | The basic valuation shape is: @@ -300,15 +300,15 @@ correction; leaving both optional flags `FALSE` selects the Acklam-only default. Primary worksheet families are: -| Purpose | Worksheet functions | -|---------|---------------------| -| Conventions | `PERIODLENGTH.NEW`, `DAYBASIS.NEW`, `RATELEGCONVENTION.NEW`, `RATEINDEXCONVENTION.NEW`, `COLLATERALTYPE.*` | -| XCCY reset data | `XCCYRESETCONVENTION.NEW`, `MARKETFIXINGSNAPSHOT.NEW` | -| Instruments | `DEPOSIT.NEW`, `FRA.NEW`, `FUTURE.NEW`, `SWAP.NEW`, `OISSWAP.NEW`, `BASISSWAP.NEW`, `CROSSCURRENCYSWAP.NEW`, `CROSSCURRENCYSWAPCONFIG.NEW`, `CROSSCURRENCYSWAP.CONFIG.NEW` | -| Direct curves | `DISCOUNTPWLF.NEW`, `DISCOUNTZERORATE.NEW`, `CURVEBLOCK.NEW.SIMPLE` | -| Calibration | `CALIBRATE.SINGLECURVE`, `CALIBRATE.XCCYMARKET`, `CALIBRATE.JOINTXCCY` | -| Results | `CALIBRATIONRESULT.GET`, `CALIBRATIONRESULT.GET.CURVE`, `XCCYCALIBRATIONRESULT.*`, `JOINTXCCYCALIBRATIONRESULT.GET*` | -| Repository | `REPOSITORY.FIND`, `REPOSITORY.ERASE`, `REPOSITORY.SIZE` | +| Purpose | Worksheet functions | +|-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Conventions | `PERIODLENGTH.NEW`, `DAYBASIS.NEW`, `RATELEGCONVENTION.NEW`, `RATEINDEXCONVENTION.NEW`, `COLLATERALTYPE.*` | +| XCCY reset data | `XCCYRESETCONVENTION.NEW`, `MARKETFIXINGSNAPSHOT.NEW` | +| Instruments | `DEPOSIT.NEW`, `FRA.NEW`, `FUTURE.NEW`, `SWAP.NEW`, `OISSWAP.NEW`, `BASISSWAP.NEW`, `CROSSCURRENCYSWAP.NEW`, `CROSSCURRENCYSWAPCONFIG.NEW`, `CROSSCURRENCYSWAP.CONFIG.NEW` | +| Direct curves | `DISCOUNTPWLF.NEW`, `DISCOUNTZERORATE.NEW`, `CURVEBLOCK.NEW.SIMPLE` | +| Calibration | `CALIBRATE.SINGLECURVE`, `CALIBRATE.XCCYMARKET`, `CALIBRATE.JOINTXCCY` | +| Results | `CALIBRATIONRESULT.GET`, `CALIBRATIONRESULT.GET.CURVE`, `XCCYCALIBRATIONRESULT.*`, `JOINTXCCYCALIBRATIONRESULT.GET*` | +| Repository | `REPOSITORY.FIND`, `REPOSITORY.ERASE`, `REPOSITORY.SIZE` | `DISCOUNTZERORATE.NEW` takes name, currency, anchor, future dates, and continuously compounded decimal zero rates, with optional day count, log-DF scheme, and base handle.