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. 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. 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 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/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..84865f975 --- /dev/null +++ b/dal-cpp/benchmarks/xccy_perf/xccy_perf.cpp @@ -0,0 +1,638 @@ +#include +#include + +#include +#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_; + 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) { + 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))); + } + + 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_ = useProjectionCurve; + 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); + // 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_ = { + 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(); + 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); + result.SetBasisCurve(Handle_(NewDiscountPWC("xccy_perf_basis", "USD", PiecewiseConstant_(fixture.basisKnots_, values)))); + } + return result; + } + + 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_)); + } + + 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(mode == XccyNotionalMode_::Value_::FIXED ? MakeFixedSwap(fixture, 12 * year) : MakeResetSwap(fixture, 12 * year, mode)); + } + 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; + } + + 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)}; + 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 CrossCurrencySwapConfig_ config = MakeConfig(fixture, XccyNotionalMode_::Value_::MARK_TO_MARKET); + 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)))); + const std::vector rates = PrecomputeAll(instruments); + return PricingCase_{"in-progress MTM", instruments.back(), rates.back(), rates, 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}; + + 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 = MakeFixedSwap(fixture, maturity); + const double quote = (*prototype->Precompute())(quoteMarket); + result.instruments_.push_back(MakeFixedSwap(fixture, maturity, quote)); + } + 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); + 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 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_); + 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 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; + 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 : 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, "XCCY basket checksum must be finite and non-zero"); + } + + 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"); + 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"); + } + + 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"); + } + + 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 PricingCase_& pricing) { + RateHandle_ sink = pricing.rate_; + const std::string rawName = "XCCY " + pricing.label_ + " 10Y PRECOMPUTE"; + const auto raw = Bench::Run( + rawName, + [&]() { + for (int i = 0; i < kPrecomputeBatch; ++i) + sink = pricing.swap_->Precompute(); + }, + kWarmups, kRepeats); + Bench::DoNotOptimize(sink.get()); + REQUIRE(sink, "XCCY PRECOMPUTE sink must retain a rate handle"); + Bench::Print(Normalize(raw, rawName + " / operation", kPrecomputeBatch)); + } + + void RunPrice(const PricingCase_& pricing) { + double checksum = 0.0; + const std::string rawName = "XCCY " + pricing.label_ + " 10Y PRICE"; + const auto raw = Bench::Run( + rawName, + [&]() { + for (int i = 0; i < kPriceBatch; ++i) + checksum += (*pricing.rate_)(pricing.market_); + }, + kWarmups, kRepeats); + Bench::DoNotOptimize(&checksum); + 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 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( + rawName, + [&]() { + for (int pass = 0; pass < kBasketPasses; ++pass) + for (const auto& rate : pricing.basket_) + checksum += (*rate)(pricing.market_); + }, + kWarmups, kRepeats); + Bench::DoNotOptimize(&checksum); + 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 RunBasisCalibration(const char* name, const CrossCurrencyCalibrationSpec_& spec, const CrossCurrencyCalibrationOptions_& options) { + double checksum = 0.0; + const auto timing = Bench::Run( + name, + [&]() { + const auto calibration = CalibrateCrossCurrencyMarket(spec, options); + checksum += calibration.diagnostics_.maxAbsResidual_; + checksum += calibration.fxForwardCurve_.forwards_.back(); + }, + 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(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 + +int main() { + RegisterAll_::Init(); + const Fixture_ fixture = MakeFixture(); + XGLOBAL::SetEvaluationDate(fixture.today_); + + 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 CrossCurrencyCalibrationSpec_ resetAwareBasisSpec = MakeResetAwareCalibrationSpec(fixture); + + 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; + + for (const auto& pricing : pricingCases) + ValidatePricing(pricing); + ValidateBasisCalibration(basisSpec, basisAnalyticSolve); + ValidateBasisCalibration(basisSpec, basisAnalyticDiagnostics); + ValidateBasisCalibration(basisSpec, basisBumpedDiagnostics); + ValidateBasisCalibration(resetAwareBasisSpec, basisAnalyticDiagnostics); + ValidateJointCalibration(jointSpec, jointAnalyticSolve); + ValidateJointCalibration(jointSpec, jointAnalyticDiagnostics); + ValidateJointCalibration(jointSpec, jointBumpedDiagnostics); + ValidateJointCalibration(jointApproximateSpec, jointAnalyticDiagnostics); + + Bench::PrintHeader(); + 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); + 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); + RunJointCalibration("XCCY joint ANALYTIC APPROXIMATE (15 XCCY, 3x5 knots)", jointApproximateSpec, jointAnalyticDiagnostics); + return 0; +} diff --git a/dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp b/dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp index ab15d6ddf..d724d09bb 100644 --- a/dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp +++ b/dal-cpp/dal/auto/MG_BizDayConvention_enum.hpp @@ -9,6 +9,7 @@ class BizDayConvention_ Unadjusted, Following, 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 1a325612e..4a208ef60 100644 --- a/dal-cpp/dal/auto/MG_BizDayConvention_enum.inc +++ b/dal-cpp/dal/auto/MG_BizDayConvention_enum.inc @@ -11,6 +11,7 @@ Vector_ BizDayConventionListAll() { TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::Unadjusted); TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::Following); TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::ModifiedFollowing); + TheBizDayConventionList().emplace_back(BizDayConvention_::Value_::Preceding); } return TheBizDayConventionList(); } @@ -28,6 +29,8 @@ const char* BizDayConvention_::String() const { return "Following"; case Value_::ModifiedFollowing: return "ModifiedFollowing"; + case Value_::Preceding: + return "Preceding"; }} @@ -50,6 +53,9 @@ struct ReadStringBizDayConvention_ 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/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..bdba72811 100644 --- a/dal-cpp/dal/auto/java/BizDayConvention.java +++ b/dal-cpp/dal/auto/java/BizDayConvention.java @@ -8,6 +8,7 @@ public enum Value UNADJUSTED, FOLLOWING, MODIFIEDFOLLOWING, + PRECEDING, 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/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/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..3f96ebe96 --- /dev/null +++ b/dal-cpp/dal/curve/jointcalibration_internal.hpp @@ -0,0 +1,443 @@ +// +// 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 +#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"; + bool requireCurveNames_ = false; + }; + + 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 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"); + 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]; + 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 '" + + 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"); + 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"); + 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_(); + } + + 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_ 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, + 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"; + const String_ modeReason = XccyNotionalModeAnalyticIneligibilityReason(plan); + if (!modeReason.empty()) + return modeReason; + + 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(); }, + [](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/xccybasiscalibration.cpp b/dal-cpp/dal/curve/xccybasiscalibration.cpp new file mode 100644 index 000000000..96baad19e --- /dev/null +++ b/dal-cpp/dal/curve/xccybasiscalibration.cpp @@ -0,0 +1,320 @@ +// +// 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_; + } + + 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) { + 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"); + 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"); + 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 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), + plans_(plans), basisDefinition_(basisDefinition), jacobianMode_(jacobianMode) { + marketRates_.reserve(spec.instruments_.size()); + for (const auto& instrument : spec.instruments_) + 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)); + } + }; + + 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, + 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; + } + + 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); + 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_; + 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, 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/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 81228c5a5..e8db2461b 100644 --- a/dal-cpp/dal/curve/xccycalibration.hpp +++ b/dal-cpp/dal/curve/xccycalibration.hpp @@ -4,16 +4,20 @@ #pragma once -#include -#include #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_ { @@ -23,24 +27,33 @@ 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); + 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_; } [[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; @@ -52,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; @@ -68,6 +80,9 @@ namespace Dal { struct CrossCurrencyCalibrationSpec_ { Date_ today_; + DateTime_ valuationTime_; + Ccy_ collateralCurrency_; + Handle_ fixings_; CurrencyPair_ basisPair_; Handle_ domesticCurveBlock_; Handle_ foreignCurveBlock_; @@ -84,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_; @@ -98,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 de6b50d0f..8b174816e 100644 --- a/dal-cpp/dal/curve/xccyinstrument.cpp +++ b/dal-cpp/dal/curve/xccyinstrument.cpp @@ -4,180 +4,99 @@ #include #include -#include -#include + +#include #include #include -#include -#include +#include #include -#include namespace Dal { - 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; - } +#include - 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_; + namespace { + 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_ = 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(); + 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()) + 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_ result; + result.pair_ = pair; + result.domesticNotional_ = domesticNotional; + result.foreignNotional_ = foreignNotional; + result.convention_ = convention; + result.notionalMode_ = XccyNotionalMode_::Value_::FIXED; + 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(), + "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"); + ValidateResetConfig(config); + } } // namespace CurrencyPair_::CurrencyPair_() : domestic_(Ccy_::Value_::USD), foreign_(Ccy_::Value_::EUR) {} @@ -194,9 +113,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, @@ -206,16 +123,12 @@ namespace Dal { double domesticNotional, double foreignNotional, const CrossCurrencyConvention_& convention) - : 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"); + : 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) { + ValidateConfig(tradeDate_, start_, maturity_, marketRate_, config_); } String_ CrossCurrencySwap_::Name() const { return "CrossCurrencySwap"; } @@ -223,26 +136,7 @@ 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"); - const auto domesticPeriods = BuildLegPeriods(start_, - maturity_, - convention_.domesticLeg_, - convention_.domesticIndex_.fixingLag_, - convention_.domesticIndex_.fixingHolidays_); - const auto foreignPeriods = BuildLegPeriods(start_, - maturity_, - convention_.foreignLeg_, - convention_.foreignIndex_.fixingLag_, - convention_.foreignIndex_.fixingHolidays_); - return Handle_(new CrossCurrencySwapRate_(maturity_, - pair_, - domesticNotional_, - foreignNotional_, - domesticPeriods, - foreignPeriods, - convention_.domesticIndex_, - convention_.foreignIndex_, - convention_)); + const auto plan = BuildXccyCashflowPlan(start_, maturity_, config_); + return Handle_(new CrossCurrencySwapKernelRate_(plan)); } } // namespace Dal diff --git a/dal-cpp/dal/curve/xccyinstrument.hpp b/dal-cpp/dal/curve/xccyinstrument.hpp index e3e7d262c..da8d7b2de 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,9 +77,12 @@ 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_; } + [[nodiscard]] const CrossCurrencySwapConfig_& Config() const { return config_; } [[nodiscard]] Handle_ Precompute() const; }; } // namespace Dal diff --git a/dal-cpp/dal/curve/xccyjointcalibration.cpp b/dal-cpp/dal/curve/xccyjointcalibration.cpp new file mode 100644 index 000000000..2c9749c6a --- /dev/null +++ b/dal-cpp/dal/curve/xccyjointcalibration.cpp @@ -0,0 +1,489 @@ +// +// 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"); + result.requireCurveNames_ = true; + 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 JointXccyCalibrationSpec_& spec, const JointLayout_& layout, const Vector_& plans) { + String_ result = JointCalibrationInternal::AnalyticIneligibilityReason(layout.domesticCollection_, layout.domesticSlots_); + if (!result.empty()) + return result; + 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_ { + const JointXccyCalibrationSpec_* spec_; + const JointLayout_* layout_; + const Vector_* plans_; + Handle_ fixings_; + CurveJacobianMode_ jacobianMode_; + int* evaluationCount_; + + 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, + int* evaluationCount) + : spec_(&spec), layout_(&layout), plans_(&plans), fixings_(fixings), jacobianMode_(jacobianMode), evaluationCount_(evaluationCount) {} + + [[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)); + } + }; + + 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; + } + + 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(int evaluationCount, SolveResult_* solve, JointXccyCalibrationResult_* result) { + const ResidualStats_ stats = ResidualStats(result->residuals_); + result->jointMaxAbsResidual_ = stats.maxAbsResidual_; + result->jointRmsResidual_ = stats.rmsResidual_; + result->solverEvaluations_ = 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, + const int* evaluationCount, + 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_); + 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(*evaluationCount, solve, &result); + PopulateRanges(spec, layout, &result); + 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(spec, layout, plans); + REQUIRE(reason.empty(), "Joint XCCY analytic Jacobian is ineligible: " + reason); + } + + const Vector_<> guess = BuildInitialGuess(spec, layout); + const std::unique_ptr smoothing = BuildSmoothing(layout); + 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, &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_) + + " 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/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/curve/xccypricing.cpp b/dal-cpp/dal/curve/xccypricing.cpp new file mode 100644 index 000000000..0d1c3dcd7 --- /dev/null +++ b/dal-cpp/dal/curve/xccypricing.cpp @@ -0,0 +1,421 @@ +// +// Created by Codex on 2026/7/13. +// + +#include +#include + +#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) { + if (identity.indexName_.empty()) + return; + 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, + const String_& context, + Vector_* requests) { + for (const auto& period : periods) { + 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) { + 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 + 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); + 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"); + 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"); + } + + } // 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, "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"); + if (!requiredNotionals[reset.domesticPeriodIndex_] || !(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; + } + 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 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 && reset.effectiveDate_ >= market.valuationTime_.Date()) + result.mtmDeltas_[i] = 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_); + 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_); + } + + template + T_ CouponRate(const XccyCouponPeriod_& period, + const Tape::DiscountCurve_& forecast, + const XccyMarketView_& market, + const MarketFixingSnapshot_& fixings, + const String_& context) { + 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); }); + } + + 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); + 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.start_; + if (domesticStart >= market.valuationTime_.Date()) + result.domesticPv_ -= DomesticNotional(plan, resolved, fixedNotional, 0) * + DiscountFromValuation(domesticDiscount, market.valuationTime_, domesticStart); + const Date_ foreignStart = plan.start_; + 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 + 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& 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(); + 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_; + + 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(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(foreignCoupons.spreadAnnuity_) > 0.0, "XCCY pricing requires a positive remaining foreign spread annuity"); + return (domesticPv - foreignPv) / foreignCoupons.spreadAnnuity_; + } + 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_&); + 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 new file mode 100644 index 000000000..8077e4a84 --- /dev/null +++ b/dal-cpp/dal/curve/xccypricing.hpp @@ -0,0 +1,62 @@ +// +// Created by Codex on 2026/7/13. +// + +#pragma once + +#include + +#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); + 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/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/math/optimization/underdetermined.cpp b/dal-cpp/dal/math/optimization/underdetermined.cpp index bf226c767..58d929a3b 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; @@ -231,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, @@ -241,59 +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) { - StoreEffectiveJacobianInverse(*j, 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/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/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/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..45730da52 100644 --- a/dal-cpp/dal/time/schedules.hpp +++ b/dal-cpp/dal/time/schedules.hpp @@ -33,6 +33,8 @@ 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/examples/CMakeLists.txt b/dal-cpp/examples/CMakeLists.txt index 8a4834fd3..8b4175586 100644 --- a/dal-cpp/examples/CMakeLists.txt +++ b/dal-cpp/examples/CMakeLists.txt @@ -6,6 +6,8 @@ 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(xccy_reset_pricing) add_subdirectory(european_mc) add_subdirectory(european_fd) add_subdirectory(snowball) @@ -31,6 +33,8 @@ set(DAL_EXAMPLE_TARGETS interpolate_curve 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_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..5e378f541 --- /dev/null +++ b/dal-cpp/examples/xccy_mtm_calibration/xccy_mtm_calibration.cpp @@ -0,0 +1,260 @@ +// +// 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))); + } + + Vector_<> ShiftedInitialGuess(Vector_<> parameters) { + for (auto& parameter : parameters) + parameter += 0.001; + return parameters; + } + + 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; + discount.initialGuessPerNode_ = ShiftedInitialGuess(discountParameters); + 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; + 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_)); + } + 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; + result.initialGuessPerNode_ = ShiftedInitialGuess(basisParameters); + for (int i = 0; i < static_cast(maturities.size()); ++i) { + const Date_ start = i == 0 ? inProgressStart : today; + 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; + } + + 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-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/dal-cpp/tests/currency/test_currencydata.cpp b/dal-cpp/tests/currency/test_currencydata.cpp index a8bcde846..27775b0a4 100644 --- a/dal-cpp/tests/currency/test_currencydata.cpp +++ b/dal-cpp/tests/currency/test_currencydata.cpp @@ -4,6 +4,7 @@ #include #include + #include #include #include @@ -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_joint_analytic_jacobian.cpp b/dal-cpp/tests/curve/test_joint_analytic_jacobian.cpp index 99d4bf16b..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) { @@ -920,6 +954,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..0326b1a45 100644 --- a/dal-cpp/tests/curve/test_joint_calibration.cpp +++ b/dal-cpp/tests/curve/test_joint_calibration.cpp @@ -329,6 +329,38 @@ 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, 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_xccybasisjacobian.cpp b/dal-cpp/tests/curve/test_xccybasisjacobian.cpp new file mode 100644 index 000000000..b407ab457 --- /dev/null +++ b/dal-cpp/tests/curve/test_xccybasisjacobian.cpp @@ -0,0 +1,300 @@ +// +// Created by Codex on 2026/7/13. +// + +#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) { + 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_; + 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); + } + } + + 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); + 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); + 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 = { + 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, 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()); + 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()); + 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; + 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_xccyjointcalibration.cpp b/dal-cpp/tests/curve/test_xccyjointcalibration.cpp new file mode 100644 index 000000000..01661efab --- /dev/null +++ b/dal-cpp/tests/curve/test_xccyjointcalibration.cpp @@ -0,0 +1,450 @@ +// +// 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; + 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; + 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, 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(); + 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, 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(); + 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..28a581b3a --- /dev/null +++ b/dal-cpp/tests/curve/test_xccyjointjacobian.cpp @@ -0,0 +1,394 @@ +// +// 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 { + 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(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)}; + 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 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))); + } + 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); + 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); + } + + 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_); + 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, 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_); + 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))); +} diff --git a/dal-cpp/tests/curve/test_xccymarket.cpp b/dal-cpp/tests/curve/test_xccymarket.cpp index ea9420e9b..09d3cee80 100644 --- a/dal-cpp/tests/curve/test_xccymarket.cpp +++ b/dal-cpp/tests/curve/test_xccymarket.cpp @@ -3,34 +3,30 @@ // #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))); } @@ -83,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 @@ -100,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); } @@ -234,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) { @@ -279,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 @@ -311,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); @@ -344,22 +319,10 @@ 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) { @@ -391,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); @@ -443,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; @@ -503,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); @@ -517,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; @@ -569,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)))}; @@ -615,3 +566,106 @@ 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))); +} +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_); +} diff --git a/dal-cpp/tests/curve/test_xccypricing.cpp b/dal-cpp/tests/curve/test_xccypricing.cpp new file mode 100644 index 000000000..4219fd17a --- /dev/null +++ b/dal-cpp/tests/curve/test_xccypricing.cpp @@ -0,0 +1,715 @@ +// +// Created by Codex on 2026/7/13. +// + +#include + +#include +#include + +#include +#include +#include +#include +#include +#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; + } + + 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_; + + 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 {} + }; + + 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 {} + }; + + 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}; + 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) { + 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, TestFixedPlanLeavesUnconfiguredRateFixingMetadataEmpty) { + auto config = MakeQuarterlyConfig(XccyNotionalMode_::Value_::FIXED); + config.domesticRateFixing_ = FixingIdentity_(); + config.foreignRateFixing_ = FixingIdentity_(); + + const auto plan = BuildXccyCashflowPlan(Date_(2024, 1, 8), Date_(2025, 1, 8), 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)); + + 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, 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; + + 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_); +} +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); +} + +TEST(XccyPricingTest, TestMultipleFutureMtmExchangesAddDomesticPvSequentially) { + 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; + 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 { + 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, 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; + 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, 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, 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); + 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); + 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_); +} diff --git a/dal-cpp/tests/indice/test_fixingsnapshot.cpp b/dal-cpp/tests/indice/test_fixingsnapshot.cpp new file mode 100644 index 000000000..861f62653 --- /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); +} diff --git a/dal-cpp/tests/math/optimization/test_underdetermined.cpp b/dal-cpp/tests/math/optimization/test_underdetermined.cpp index 6aeada694..ac00036c0 100644 --- a/dal-cpp/tests/math/optimization/test_underdetermined.cpp +++ b/dal-cpp/tests/math/optimization/test_underdetermined.cpp @@ -85,6 +85,33 @@ namespace { void SecantUpdate(const Vector_<>&, const Vector_<>&) override {} }; + struct NonlinearSquareGradientState_ { + Vector_> xs_; + Vector_> fs_; + }; + + class NonlinearSquareAnalyticFunc_ : public Underdetermined::Function_ { + 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 { + 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); + } + }; + + 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,11 +330,48 @@ TEST(UnderdeterminedTest, TestFindPopulatesEffectiveJacobianInverse) { ASSERT_NEAR(effJacobianInverse(1, 0), 2.0e-11, 1e-18); } -// 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. +TEST(UnderdeterminedTest, TestEffectiveInverseUsesFreshAnalyticJacobianAndUnscaledResidualAtSolution) { + NonlinearSquareGradientState_ gradientState; + NonlinearSquareAnalyticFunc_ func(&gradientState); + 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(gradientState.xs_.size()); ++i) + if (std::abs(gradientState.xs_[i][0] - solved[0]) < 1.0e-12) + solutionGradient = i; + ASSERT_GE(solutionGradient, 0); + ASSERT_NEAR(gradientState.fs_[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); +} + +// Forward diagnostics preserve the raw analytic Jacobian rather than the tolerance-scaled solver Jacobian. TEST(UnderdeterminedTest, TestFindPopulatesForwardJacobianAtSolution) { MultiResidualFunc_ func; @@ -323,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); @@ -340,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); @@ -361,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); @@ -371,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); @@ -392,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); @@ -408,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)); } diff --git a/dal-cpp/tests/time/test_schedules.cpp b/dal-cpp/tests/time/test_schedules.cpp index e7d599fa1..f9ee0707b 100644 --- a/dal-cpp/tests/time/test_schedules.cpp +++ b/dal-cpp/tests/time/test_schedules.cpp @@ -4,6 +4,7 @@ #include #include + #include #include #include @@ -77,6 +78,17 @@ 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, 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/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/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-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..f8f61f9e5 100644 --- a/dal-excel/src/__curveinstrument.cpp +++ b/dal-excel/src/__curveinstrument.cpp @@ -6,8 +6,10 @@ #include "__platform.hpp" #include "__curve_storable.hpp" +#include "__xccy_test_api.hpp" #include +// clang-format off /*IF-------------------------------------------------------------------------- public Deposit_New Create a deposit instrument for curve calibration @@ -171,6 +173,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 +280,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 +295,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 +312,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 +333,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 +398,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..e3421cc77 100644 --- a/dal-excel/src/__curveprotocol.cpp +++ b/dal-excel/src/__curveprotocol.cpp @@ -6,8 +6,11 @@ #include "__platform.hpp" #include "__curve_storable.hpp" +#include "__xccy_test_api.hpp" +#include #include +// clang-format off /*IF-------------------------------------------------------------------------- public CollateralType_OIS Create an OIS collateral type handle @@ -93,6 +96,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 +145,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 +156,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 +212,8 @@ namespace Dal { #include #include #include +#include +#include #endif -} + // clang-format on +} // namespace Dal 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 12f9d0700..56350d5a8 100644 --- a/dal-excel/src/__xccycalibration.cpp +++ b/dal-excel/src/__xccycalibration.cpp @@ -6,10 +6,13 @@ #include "__platform.hpp" #include "__curve_storable.hpp" -#include +#include "__xccy_test_api.hpp" +#include #include +#include #include +// clang-format off /*IF-------------------------------------------------------------------------- public Calibrate_XccyMarket Calibrate a cross-currency basis market from instruments and settings. @@ -64,6 +67,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 +194,201 @@ 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; + } + + 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 + 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 + 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 + 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 + 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) { + 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 +420,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 +431,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 +451,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..22d503ed1 100644 --- a/dal-excel/tests/test_excel_api.cpp +++ b/dal-excel/tests/test_excel_api.cpp @@ -1,24 +1,128 @@ // // 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 +#include + +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_); + + 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) { + 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) diff --git a/dal-public/src/curveinstrument.hpp b/dal-public/src/curveinstrument.hpp index c8a71cbff..cbe040508 100644 --- a/dal-public/src/curveinstrument.hpp +++ b/dal-public/src/curveinstrument.hpp @@ -12,6 +12,30 @@ namespace Dal { + 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, @@ -69,6 +93,11 @@ namespace Dal { 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, 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 492657ec8..7c80a85ff 100644 --- a/dal-public/src/xccycalibration.cpp +++ b/dal-public/src/xccycalibration.cpp @@ -9,16 +9,69 @@ 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.valuationTime_ = valuationTime_; + result.collateralCurrency_ = collateralCurrency_; + result.fixings_ = fixings_; + 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); + 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; } + 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 aba9db6a8..bdf0e7b86 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,32 @@ 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; + }; + + [[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 706d4b215..518582908 100644 --- a/dal-public/tests/test_curve_instrument.cpp +++ b/dal-public/tests/test_curve_instrument.cpp @@ -8,6 +8,7 @@ #include using Dal::BasisSwapNew; +using Dal::CrossCurrencySwapConfigBuilder_; using Dal::CrossCurrencySwapNew; using Dal::CurrencyPair_New; using Dal::Date_; @@ -22,6 +23,7 @@ using Dal::RateIndexConvention_New; using Dal::RateLegConvention_New; using Dal::SwapNew; using Dal::CollateralType_OIS; +using Dal::XccyNotionalMode_; using Dal::YCInstrument_; using Dal::RateLegConvention_; using Dal::RateIndexConvention_; @@ -156,3 +158,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 582d3f8a1..e8683f120 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; @@ -29,30 +35,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 @@ -70,34 +66,45 @@ 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 { -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 +135,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 +143,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 @@ -157,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_; @@ -175,6 +181,9 @@ TEST(XccyCalibrationTest, TestBuildRoundTripsEveryField) { auto spec = b.Build(); ASSERT_EQ(spec.today_, b.today_); + 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()); @@ -190,3 +199,157 @@ 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)); +} + +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_); +} diff --git a/dal-python/README.md b/dal-python/README.md index 5ae7c107a..51cfca03e 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,47 @@ 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. + +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 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()) 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/src/bindings/curve.cpp b/dal-python/src/bindings/curve.cpp index 6a105191d..490eacf53 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) { @@ -209,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( @@ -233,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( @@ -296,6 +514,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 +700,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); - - 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_; }); - - 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 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); + + 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_; }); + + 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..f607bfe4a --- /dev/null +++ b/dal-python/tests/test_xccy_resettable.py @@ -0,0 +1,139 @@ +"""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_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) + 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()) 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..a655f03f7 100644 --- a/docs/methodology/xccy_calibration.md +++ b/docs/methodology/xccy_calibration.md @@ -1,291 +1,181 @@ -# 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 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 +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. 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 +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. + +## 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 -- [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.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 fb405a8ff..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 @@ -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..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, and currency-pair builders | -| `` | Deposit, FRA, future, swap, OIS, basis-swap, and cross-currency-swap builders | -| `` | Piecewise-linear-forward, zero-rate, and curve-block builders | -| `` | `CurveCalibrationSpecBuilder_`, `CalibrateSingleCurve`, `CalibrateMultiCurveBundle` | -| `` | Cross-currency spec builder and `CalibrateXccyMarket` | -| `` | 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. @@ -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 @@ -161,16 +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` | -| 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: @@ -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. @@ -279,14 +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.*` | -| Instruments | `DEPOSIT.NEW`, `FRA.NEW`, `FUTURE.NEW`, `SWAP.NEW`, `OISSWAP.NEW`, `BASISSWAP.NEW`, `CROSSCURRENCYSWAP.NEW` | -| Direct curves | `DISCOUNTPWLF.NEW`, `DISCOUNTZERORATE.NEW`, `CURVEBLOCK.NEW.SIMPLE` | -| Calibration | `CALIBRATE.SINGLECURVE`, `CALIBRATE.XCCYMARKET` | -| Results | `CALIBRATIONRESULT.GET`, `CALIBRATIONRESULT.GET.CURVE`, `XCCYCALIBRATIONRESULT.*` | -| 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. @@ -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).