diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7e2f7f0..f97f389d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,8 +104,9 @@ jobs: - name: Cache cargo registry uses: Swatinem/rust-cache@v2 + # `excel` profile = panic=unwind; see [profile.excel] in Cargo.toml. - name: Build convex-ffi - run: cargo build --release -p convex-ffi + run: cargo build --profile excel -p convex-ffi - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -115,8 +116,11 @@ jobs: - name: Build Excel Add-in run: dotnet build excel/Convex.Excel/Convex.Excel.csproj --configuration Release + - name: Test Excel Add-in (headless) + run: dotnet test excel/Convex.Excel.Tests/Convex.Excel.Tests.csproj --configuration Release + - name: Copy native DLL - run: copy target\release\convex_ffi.dll excel\Convex.Excel\bin\Release\net472\ + run: copy target\excel\convex_ffi.dll excel\Convex.Excel\bin\Release\net472\ - name: Create Add-in Package shell: pwsh diff --git a/.github/workflows/java-release.yml b/.github/workflows/java-release.yml index 8f53c1fc..a4fb1ca6 100644 --- a/.github/workflows/java-release.yml +++ b/.github/workflows/java-release.yml @@ -43,13 +43,14 @@ jobs: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" + # `excel` profile = panic=unwind; see [profile.excel] in Cargo.toml. - name: Build cdylib - run: cargo build -p convex-ffi --release --target ${{ matrix.target }} + run: cargo build -p convex-ffi --profile excel --target ${{ matrix.target }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ matrix.classifier }} - path: target/${{ matrix.target }}/release/${{ matrix.lib }} + path: target/${{ matrix.target }}/excel/${{ matrix.lib }} if-no-files-found: error # Stage every cdylib into the jar resources and publish to Maven Central. diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 3a613d88..635c4646 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -48,14 +48,15 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@v2 + # `excel` profile = panic=unwind; see [profile.excel] in Cargo.toml. - name: Build convex-ffi cdylib - run: cargo build -p convex-ffi --release + run: cargo build -p convex-ffi --profile excel - name: Stage native library run: | dest=bindings/java/src/main/resources/native/linux-x86_64 mkdir -p "$dest" - cp target/release/libconvex_ffi.so "$dest/" + cp target/excel/libconvex_ffi.so "$dest/" - name: Set up JDK 22 uses: actions/setup-java@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99c78513..98361bc5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,8 +85,9 @@ jobs: - name: Cache cargo registry uses: Swatinem/rust-cache@v2 + # `excel` profile = panic=unwind; see [profile.excel] in Cargo.toml. - name: Build convex-ffi - run: cargo build --release -p convex-ffi + run: cargo build --profile excel -p convex-ffi - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -97,7 +98,7 @@ jobs: run: dotnet build excel/Convex.Excel/Convex.Excel.csproj --configuration Release - name: Copy native DLL - run: copy target\release\convex_ffi.dll excel\Convex.Excel\bin\Release\net472\ + run: copy target\excel\convex_ffi.dll excel\Convex.Excel\bin\Release\net472\ - name: Create Add-in Package shell: pwsh diff --git a/.gitignore b/.gitignore index c80c0735..160c4372 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,8 @@ www/pkg/ npm-debug.log* /excel/Convex.Excel/bin /excel/Convex.Excel/obj +/excel/Convex.Excel.Tests/bin +/excel/Convex.Excel.Tests/obj __pycache__/ *.pyc /data diff --git a/Cargo.toml b/Cargo.toml index 825e2506..24788435 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,6 +141,14 @@ inherits = "release" lto = "thin" codegen-units = 4 +# Host-embedded cdylib profile (Excel add-in, JVM bindings): panics must +# unwind so convex-ffi's catch_unwind guards can turn them into error +# envelopes instead of aborting the host process (Excel/JVM). Build with: +# cargo build --profile excel -p convex-ffi +[profile.excel] +inherits = "release" +panic = "unwind" + # Distribution profile for binaries [profile.dist] inherits = "release" diff --git a/crates/convex-analytics/src/dto.rs b/crates/convex-analytics/src/dto.rs index 616ddd9e..1f90e66e 100644 --- a/crates/convex-analytics/src/dto.rs +++ b/crates/convex-analytics/src/dto.rs @@ -522,6 +522,133 @@ pub struct CurveQueryResponse { pub value: f64, } +// ---- YAS (one-call yield & spread analysis) -------------------------------- + +/// Bloomberg-YAS-style analysis in one request: every yield convention, +/// G/Z/benchmark/ASW spreads, risk metrics, and the settlement invoice. +/// Backed by `crate::yas::YASCalculator`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YasRequest { + pub bond: Handle, + pub settlement: Date, + pub mark: MarkInput, + /// Spot/discount curve (Z-spread; also G-spread unless `govt_curve` set). + pub curve: Handle, + /// Government curve for G-spread and the benchmark spread. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub govt_curve: Option, + /// Swap curve for I-spread. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub swap_curve: Option, + #[serde(default)] + pub quote_frequency: Frequency, +} + +/// Yields in percent, spreads in basis points, prices/amounts per 100 face. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YasResponse { + pub clean_price: f64, + pub dirty_price: f64, + pub accrued: f64, + pub accrued_days: i32, + pub ytm_pct: f64, + pub current_yield_pct: f64, + pub simple_yield_pct: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub money_market_yield_pct: Option, + pub g_spread_bps: f64, + pub z_spread_bps: f64, + pub benchmark_spread_bps: f64, + pub benchmark_tenor: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asw_spread_bps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oas_bps: Option, + pub modified_duration: f64, + pub macaulay_duration: f64, + pub convexity: f64, + pub dv01_per_100: f64, + pub principal_amount: f64, + pub accrued_amount: f64, + pub settlement_amount: f64, +} + +// ---- Scenario analysis ------------------------------------------------------ + +/// One bump component. Tagged JSON: `{ "kind": "parallel" | "steepener" | +/// "flattener" | "key_rate" | "credit_spread", ... }`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ScenarioBumpSpec { + Parallel { + shift_bps: f64, + }, + Steepener { + short_shift_bps: f64, + long_shift_bps: f64, + #[serde(default = "default_pivot_tenor")] + pivot_tenor: f64, + }, + Flattener { + short_shift_bps: f64, + long_shift_bps: f64, + #[serde(default = "default_pivot_tenor")] + pivot_tenor: f64, + }, + KeyRate { + tenor: f64, + shift_bps: f64, + }, + CreditSpread { + shift_bps: f64, + }, +} + +fn default_pivot_tenor() -> f64 { + 5.0 +} + +/// One named scenario = a list of bumps applied together. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScenarioSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub bumps: Vec, +} + +/// Run N curve scenarios against one bond in a single call. Methodology: +/// the mark implies a Z-spread over the base curve; each scenario bumps the +/// curve and reprices holding that Z-spread fixed. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScenarioRequest { + pub bond: Handle, + pub curve: Handle, + pub settlement: Date, + pub mark: MarkInput, + pub scenarios: Vec, + #[serde(default)] + pub quote_frequency: Frequency, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScenarioRow { + pub name: String, + pub clean_price: f64, + pub dirty_price: f64, + /// Clean price change vs the base mark. + pub delta_clean: f64, + /// YTM at the scenario price, decimal. + pub ytm_decimal: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScenarioResponse { + pub base_clean: f64, + pub base_ytm_decimal: f64, + pub z_spread_bps: f64, + pub rows: Vec, +} + // ---- Risk profile / hedge advisor ----------------------------------------- // // `convex_risk_profile` returns a `RiskProfile`; being a serializable value it diff --git a/crates/convex-analytics/src/spreads/zspread.rs b/crates/convex-analytics/src/spreads/zspread.rs index 99df568c..25fda962 100644 --- a/crates/convex-analytics/src/spreads/zspread.rs +++ b/crates/convex-analytics/src/spreads/zspread.rs @@ -197,10 +197,14 @@ impl<'a> ZSpreadCalculator<'a> { } })?; - // Convert to basis points - let z_spread_bps = (result.root * 10_000.0).round(); + // 1e-4 bp rounding: suppresses solver float noise without the + // DV01-scale round-trip price gap that whole-bp rounding caused. + let z_spread_bps = (result.root * 10_000.0 * 10_000.0).round() / 10_000.0; + // from_f64 (not from_f64_retain): the division is not exactly + // representable in binary, and retain would carry that noise into + // the Decimal (51.2346000000000001…). Ok(Spread::new( - Decimal::from_f64_retain(z_spread_bps).unwrap_or_default(), + Decimal::from_f64(z_spread_bps).unwrap_or_default(), SpreadType::ZSpread, )) } @@ -318,10 +322,14 @@ impl<'a> ZSpreadCalculator<'a> { } })?; - // Convert to basis points - let z_spread_bps = (result.root * 10_000.0).round(); + // 1e-4 bp rounding: suppresses solver float noise without the + // DV01-scale round-trip price gap that whole-bp rounding caused. + let z_spread_bps = (result.root * 10_000.0 * 10_000.0).round() / 10_000.0; + // from_f64 (not from_f64_retain): the division is not exactly + // representable in binary, and retain would carry that noise into + // the Decimal (51.2346000000000001…). Ok(Spread::new( - Decimal::from_f64_retain(z_spread_bps).unwrap_or_default(), + Decimal::from_f64(z_spread_bps).unwrap_or_default(), SpreadType::ZSpread, )) } diff --git a/crates/convex-ffi/src/build.rs b/crates/convex-ffi/src/build.rs index 25d23924..c93584ee 100644 --- a/crates/convex-ffi/src/build.rs +++ b/crates/convex-ffi/src/build.rs @@ -34,13 +34,14 @@ pub fn bond_from_json(json: &str) -> Handle { return INVALID_HANDLE; } }; + let hash = content_hash(json); match spec { - BondSpec::FixedRate(s) => build_fixed_rate(s), - BondSpec::Callable(s) => build_callable(s), - BondSpec::FloatingRate(s) => build_floating_rate(s), - BondSpec::ZeroCoupon(s) => build_zero_coupon(s), - BondSpec::SinkingFund(s) => build_sinking_fund(s), + BondSpec::FixedRate(s) => build_fixed_rate(s, hash), + BondSpec::Callable(s) => build_callable(s, hash), + BondSpec::FloatingRate(s) => build_floating_rate(s, hash), + BondSpec::ZeroCoupon(s) => build_zero_coupon(s, hash), + BondSpec::SinkingFund(s) => build_sinking_fund(s, hash), } } @@ -52,13 +53,28 @@ pub fn curve_from_json(json: &str) -> Handle { return INVALID_HANDLE; } }; + let hash = content_hash(json); match spec { - CurveSpec::Discrete(s) => build_discrete_curve(s), - CurveSpec::Bootstrap(s) => build_bootstrap_curve(s), + CurveSpec::Discrete(s) => build_discrete_curve(s, hash), + CurveSpec::Bootstrap(s) => build_bootstrap_curve(s, hash), } } +/// Idempotency hash over the canonicalized `serde_json::Value` +/// re-serialization (BTreeMap-backed, so keys sort): logically-identical +/// specs hash equal regardless of field order, which raw-byte hashing +/// would not guarantee. +fn content_hash(json: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + match serde_json::from_str::(json) { + Ok(v) => v.to_string().hash(&mut h), + Err(_) => json.hash(&mut h), + } + h.finish() +} + // ---- Bond builders -------------------------------------------------------- fn name_from_id(id: &BondIdentifier) -> Option { @@ -71,6 +87,18 @@ fn name_from_id(id: &BondIdentifier) -> Option { .or_else(|| id.name.clone()) } +/// Human identifiers to register as lookup aliases (ticker-style referencing). +fn aliases_from_id(id: &BondIdentifier) -> Vec { + let mut out = Vec::new(); + for s in [&id.cusip, &id.isin, &id.name].into_iter().flatten() { + let s = s.trim(); + if !s.is_empty() && !out.iter().any(|e| e == s) { + out.push(s.to_string()); + } + } + out +} + /// Attaches whichever identifier the spec carries to a `FixedRateBondBuilder`. /// `FixedRateBondBuilder::build()` requires *something* identifying — falling /// back to `cusip_unchecked(name)` when only a free-form name is supplied @@ -147,7 +175,7 @@ impl CouponBuilder for convex_bonds::instruments::FixedRateBondBuilder { } } -fn build_fixed_rate(spec: FixedRateSpec) -> Handle { +fn build_fixed_rate(spec: FixedRateSpec, hash: u64) -> Handle { let mut b = FixedRateBond::builder(); b = apply_identifier_fixed(b, &spec.id); b = apply_coupon(b, &spec.coupon); @@ -162,10 +190,12 @@ fn build_fixed_rate(spec: FixedRateSpec) -> Handle { bond, ObjectKind::Bond(BondKind::FixedRate), name_from_id(&spec.id), + &aliases_from_id(&spec.id), + hash, ) } -fn build_callable(spec: CallableSpec) -> Handle { +fn build_callable(spec: CallableSpec, hash: u64) -> Handle { // Build the underlying fixed bond first, then attach the schedule. let mut fb = FixedRateBond::builder(); fb = apply_identifier_fixed(fb, &spec.id); @@ -204,10 +234,12 @@ fn build_callable(spec: CallableSpec) -> Handle { bond, ObjectKind::Bond(BondKind::Callable), name_from_id(&spec.id), + &aliases_from_id(&spec.id), + hash, ) } -fn build_floating_rate(spec: FloatingRateSpec) -> Handle { +fn build_floating_rate(spec: FloatingRateSpec, hash: u64) -> Handle { use convex_curves::multicurve::RateIndex; let index = match spec.rate_index { RateIndexCode::Sofr => RateIndex::Sofr, @@ -254,10 +286,12 @@ fn build_floating_rate(spec: FloatingRateSpec) -> Handle { bond, ObjectKind::Bond(BondKind::FloatingRate), name_from_id(&spec.id), + &aliases_from_id(&spec.id), + hash, ) } -fn build_zero_coupon(spec: ZeroCouponSpec) -> Handle { +fn build_zero_coupon(spec: ZeroCouponSpec, hash: u64) -> Handle { use convex_bonds::instruments::Compounding as ZcbComp; use convex_core::types::Compounding as CoreComp; let comp = match spec.compounding { @@ -289,6 +323,8 @@ fn build_zero_coupon(spec: ZeroCouponSpec) -> Handle { bond, ObjectKind::Bond(BondKind::ZeroCoupon), name_from_id(&spec.id), + &aliases_from_id(&spec.id), + hash, ), Err(e) => { set_last_error(format!("ZeroCouponBond build failed: {e}")); @@ -297,7 +333,7 @@ fn build_zero_coupon(spec: ZeroCouponSpec) -> Handle { } } -fn build_sinking_fund(spec: SinkingFundSpec) -> Handle { +fn build_sinking_fund(spec: SinkingFundSpec, hash: u64) -> Handle { if spec.schedule.is_empty() { set_last_error("sinking_fund schedule must have at least one payment"); return INVALID_HANDLE; @@ -330,6 +366,8 @@ fn build_sinking_fund(spec: SinkingFundSpec) -> Handle { bond, ObjectKind::Bond(BondKind::SinkingFund), name_from_id(&spec.id), + &aliases_from_id(&spec.id), + hash, ) } @@ -344,7 +382,7 @@ fn map_interp(code: InterpolationMethodCode) -> InterpolationMethod { } } -fn build_discrete_curve(spec: DiscreteCurveSpec) -> Handle { +fn build_discrete_curve(spec: DiscreteCurveSpec, hash: u64) -> Handle { if spec.tenors.len() != spec.values.len() { set_last_error("tenors and values must have the same length"); return INVALID_HANDLE; @@ -369,18 +407,39 @@ fn build_discrete_curve(spec: DiscreteCurveSpec) -> Handle { return INVALID_HANDLE; } }; - let reg_name = spec.registry_key.or(spec.name); - registry::register(RateCurve::new(curve), ObjectKind::Curve, reg_name) + // The cell key is the eviction slot; the curve's own name stays + // resolvable as an alias. + let (reg_name, aliases) = curve_name_and_aliases(spec.registry_key, spec.name); + registry::register( + RateCurve::new(curve), + ObjectKind::Curve, + reg_name, + &aliases, + hash, + ) } -fn build_bootstrap_curve(spec: convex_analytics::dto::BootstrapSpec) -> Handle { +fn curve_name_and_aliases( + registry_key: Option, + name: Option, +) -> (Option, Vec) { + let aliases: Vec = name + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + let reg_name = registry_key.or(name); + (reg_name, aliases) +} + +fn build_bootstrap_curve(spec: convex_analytics::dto::BootstrapSpec, hash: u64) -> Handle { use convex_analytics::dto::BootstrapMethod; if spec.instruments.is_empty() { set_last_error("bootstrap requires at least one instrument"); return INVALID_HANDLE; } - let reg_name = spec.registry_key.clone().or_else(|| spec.name.clone()); + let (reg_name, aliases) = curve_name_and_aliases(spec.registry_key.clone(), spec.name.clone()); let mut set = InstrumentSet::new(); for inst in &spec.instruments { @@ -423,9 +482,13 @@ fn build_bootstrap_curve(spec: convex_analytics::dto::BootstrapSpec) -> Handle { BootstrapMethod::GlobalFit => { let fitter = GlobalFitter::new().interpolation(map_interp(spec.interpolation)); match fitter.fit(spec.ref_date, &set) { - Ok(result) => { - registry::register(RateCurve::new(result.curve), ObjectKind::Curve, reg_name) - } + Ok(result) => registry::register( + RateCurve::new(result.curve), + ObjectKind::Curve, + reg_name, + &aliases, + hash, + ), Err(e) => { set_last_error(format!("GlobalFit error: {e}")); INVALID_HANDLE diff --git a/crates/convex-ffi/src/dispatch.rs b/crates/convex-ffi/src/dispatch.rs index 55d412ca..506f4ced 100644 --- a/crates/convex-ffi/src/dispatch.rs +++ b/crates/convex-ffi/src/dispatch.rs @@ -18,7 +18,8 @@ use convex_analytics::dto::{ CashflowEntry, CashflowRequest, CashflowResponse, CompareRequest, CompareResponse, CurveQueryKind, CurveQueryRequest, CurveQueryResponse, HedgeRequest, HedgeStrategyKind, KeyRate, MakeWholeRequest, MakeWholeResponse, MarkInput, PricingRequest, PricingResponse, - RiskProfileRequest, RiskRequest, RiskResponse, SpreadRequest, SpreadResponse, + RiskProfileRequest, RiskRequest, RiskResponse, ScenarioBumpSpec, ScenarioRequest, + ScenarioResponse, ScenarioRow, SpreadRequest, SpreadResponse, YasRequest, YasResponse, }; use convex_analytics::pricing::price_from_mark; use convex_analytics::risk::{ @@ -103,6 +104,73 @@ fn to_envelope(r: Result) -> String { } } +// ---- Ticker-style reference resolution ------------------------------------- +// +// Requests may reference bonds/curves by handle (`"bond": 101`) or by name +// (`"bond": "912828YK0"`). Only this crate owns the registry, so names are +// resolved in the request JSON before deserialization, keeping the DTOs' +// `Handle` fields untouched. Unknown names map to `invalid_handle`. + +/// Field paths that carry registry references, per request shape. +const PRICE_REFS: &[&[&str]] = &[&["bond"], &["curve"], &["forward_curve"]]; +const RISK_REFS: &[&[&str]] = &[&["bond"], &["curve"], &["forward_curve"]]; +const SPREAD_REFS: &[&[&str]] = &[ + &["bond"], + &["curve"], + &["params", "forward_curve"], + &["params", "govt_curve"], +]; +const CASHFLOWS_REFS: &[&[&str]] = &[&["bond"]]; +const CURVE_QUERY_REFS: &[&[&str]] = &[&["curve"]]; +const MAKE_WHOLE_REFS: &[&[&str]] = &[&["bond"]]; +const RISK_PROFILE_REFS: &[&[&str]] = &[&["bond"], &["curve"]]; +const HEDGE_REFS: &[&[&str]] = &[&["curve"]]; +const YAS_REFS: &[&[&str]] = &[&["bond"], &["curve"], &["govt_curve"], &["swap_curve"]]; +const SCENARIO_REFS: &[&[&str]] = &[&["bond"], &["curve"]]; + +fn field_mut<'a>( + value: &'a mut serde_json::Value, + path: &[&str], +) -> Option<&'a mut serde_json::Value> { + let mut cur = value; + for seg in path { + cur = cur.get_mut(*seg)?; + } + Some(cur) +} + +/// Parse the request once, rewriting string references at `paths` to +/// resolved handles; the value feeds the handler via `from_value`, so a +/// request costs one parse. +fn resolve_refs(request_json: &str, paths: &[&[&str]]) -> Result { + let mut v: serde_json::Value = serde_json::from_str(request_json) + .map_err(|e| DispatchError::input(format!("request: {e}")))?; + for path in paths { + if let Some(slot) = field_mut(&mut v, path) { + if let serde_json::Value::String(name) = &*slot { + match registry::resolve_alias(name) { + Some(h) => *slot = serde_json::Value::from(h), + None => { + return Err(DispatchError::handle(format!( + "no bond or curve registered under '{name}' — build it first \ + or check the identifier" + ))) + } + } + } + } + } + Ok(v) +} + +fn with_resolved_refs( + request_json: &str, + paths: &[&[&str]], + inner: impl FnOnce(serde_json::Value) -> Result, +) -> String { + to_envelope(resolve_refs(request_json, paths).and_then(inner)) +} + // ---- describe ------------------------------------------------------------ /// Returns enough JSON for an Object Browser to actually inform the user: @@ -183,11 +251,11 @@ fn extend_fixed(payload: &mut serde_json::Value, b: &FixedRateBond) { // ---- price --------------------------------------------------------------- pub fn price(request_json: &str) -> String { - to_envelope(price_inner(request_json)) + with_resolved_refs(request_json, PRICE_REFS, price_inner) } -fn price_inner(request_json: &str) -> Result { - let req: PricingRequest = serde_json::from_str(request_json) +fn price_inner(request: serde_json::Value) -> Result { + let req: PricingRequest = serde_json::from_value(request) .map_err(|e| DispatchError::input(format!("PricingRequest: {e}")))?; let mark = parse_mark(&req.mark)?; @@ -304,11 +372,11 @@ fn price_zero(req: &PricingRequest, mark: &Mark) -> Result String { - to_envelope(risk_inner(request_json)) + with_resolved_refs(request_json, RISK_REFS, risk_inner) } -fn risk_inner(request_json: &str) -> Result { - let req: RiskRequest = serde_json::from_str(request_json) +fn risk_inner(request: serde_json::Value) -> Result { + let req: RiskRequest = serde_json::from_value(request) .map_err(|e| DispatchError::input(format!("RiskRequest: {e}")))?; let mark = parse_mark(&req.mark)?; @@ -489,11 +557,11 @@ fn compute_krd( // ---- spread -------------------------------------------------------------- pub fn spread(request_json: &str) -> String { - to_envelope(spread_inner(request_json)) + with_resolved_refs(request_json, SPREAD_REFS, spread_inner) } -fn spread_inner(request_json: &str) -> Result { - let req: SpreadRequest = serde_json::from_str(request_json) +fn spread_inner(request: serde_json::Value) -> Result { + let req: SpreadRequest = serde_json::from_value(request) .map_err(|e| DispatchError::input(format!("SpreadRequest: {e}")))?; let mark = parse_mark(&req.mark)?; @@ -701,11 +769,11 @@ fn mark_to_frn_dirty(req: &SpreadRequest, frn: &FloatingRateNote) -> Result String { - to_envelope(cashflows_inner(request_json)) + with_resolved_refs(request_json, CASHFLOWS_REFS, cashflows_inner) } -fn cashflows_inner(request_json: &str) -> Result { - let req: CashflowRequest = serde_json::from_str(request_json) +fn cashflows_inner(request: serde_json::Value) -> Result { + let req: CashflowRequest = serde_json::from_value(request) .map_err(|e| DispatchError::input(format!("CashflowRequest: {e}")))?; let to_entries = |bond: &dyn Bond| -> CashflowResponse { @@ -732,11 +800,11 @@ fn cashflows_inner(request_json: &str) -> Result String { - to_envelope(make_whole_inner(request_json)) + with_resolved_refs(request_json, MAKE_WHOLE_REFS, make_whole_inner) } -fn make_whole_inner(request_json: &str) -> Result { - let req: MakeWholeRequest = serde_json::from_str(request_json) +fn make_whole_inner(request: serde_json::Value) -> Result { + let req: MakeWholeRequest = serde_json::from_value(request) .map_err(|e| DispatchError::input(format!("MakeWholeRequest: {e}")))?; if !req.treasury_rate.is_finite() { @@ -775,11 +843,11 @@ fn make_whole_inner(request_json: &str) -> Result String { - to_envelope(risk_profile_inner(request_json)) + with_resolved_refs(request_json, RISK_PROFILE_REFS, risk_profile_inner) } -fn risk_profile_inner(request_json: &str) -> Result { - let req: RiskProfileRequest = serde_json::from_str(request_json) +fn risk_profile_inner(request: serde_json::Value) -> Result { + let req: RiskProfileRequest = serde_json::from_value(request) .map_err(|e| DispatchError::input(format!("RiskProfileRequest: {e}")))?; let mark = parse_mark(&req.mark)?; let curve = clone_typed_curve(req.curve)?; @@ -839,11 +907,11 @@ fn risk_profile_inner(request_json: &str) -> Result /// Propose a hedge for a position profile. Response is a `HedgeProposal`. pub fn hedge(request_json: &str) -> String { - to_envelope(hedge_inner(request_json)) + with_resolved_refs(request_json, HEDGE_REFS, hedge_inner) } -fn hedge_inner(request_json: &str) -> Result { - let req: HedgeRequest = serde_json::from_str(request_json) +fn hedge_inner(request: serde_json::Value) -> Result { + let req: HedgeRequest = serde_json::from_value(request) .map_err(|e| DispatchError::input(format!("HedgeRequest: {e}")))?; let curve = clone_typed_curve(req.curve)?; // cost_feed = None → strategies fall back to the built-in heuristic model. @@ -914,12 +982,216 @@ fn compare_inner(request_json: &str) -> Result { // ---- curve_query -------------------------------------------------------- +// ---- YAS ------------------------------------------------------------------- + +pub fn yas(request_json: &str) -> String { + with_resolved_refs(request_json, YAS_REFS, yas_inner) +} + +/// One-call YAS analysis: the mark resolves to a clean price via the same +/// `price_from_mark` path `convex_price` uses, then the YAS engine produces +/// yields, spreads, risk, and the settlement invoice in one computation. +fn yas_inner(request: serde_json::Value) -> Result { + use convex_analytics::yas::YASCalculator; + + let req: YasRequest = serde_json::from_value(request) + .map_err(|e| DispatchError::input(format!("YasRequest: {e}")))?; + let mark = parse_mark(&req.mark)?; + + let spot = clone_typed_curve(req.curve)?; + let govt = match req.govt_curve { + Some(h) if h != 0 => Some(clone_typed_curve(h)?), + _ => None, + }; + let swap = match req.swap_curve { + Some(h) if h != 0 => Some(clone_typed_curve(h)?), + _ => None, + }; + + // A spread mark is quoted over its own benchmark: G over the government + // curve, I over the swap curve (falling back to spot when not supplied, + // matching the YasRequest::curve doc). + let price_curve: &dyn RateCurveDyn = match &mark { + Mark::Spread { value, .. } if value.spread_type() == SpreadType::GSpread => govt + .as_ref() + .map(|g| g as &dyn RateCurveDyn) + .unwrap_or(&spot), + Mark::Spread { value, .. } if value.spread_type() == SpreadType::ISpread => swap + .as_ref() + .map(|s| s as &dyn RateCurveDyn) + .unwrap_or(&spot), + _ => &spot, + }; + + with_fixed_bond!(req.bond, bond, { + let priced = price_from_mark( + bond, + req.settlement, + &mark, + Some(price_curve), + req.quote_frequency, + )?; + let clean = Decimal::from_f64_retain(priced.clean_price_per_100) + .ok_or_else(|| DispatchError::analytics("non-finite clean price"))?; + + let mut calc = match &govt { + Some(g) => YASCalculator::with_curves(g, &spot), + None => YASCalculator::new(&spot), + }; + if let Some(s) = &swap { + calc = calc.with_swap_curve(s); + } + calc = calc.with_frequency(req.quote_frequency.periods_per_year()); + + let r = calc + .analyze(bond, req.settlement.into(), clean) + .map_err(|e| DispatchError::analytics(format!("YAS: {e}")))?; + + Ok(YasResponse { + clean_price: priced.clean_price_per_100, + dirty_price: dec_to_f64(r.invoice.dirty_price), + accrued: dec_to_f64(r.invoice.accrued_interest), + accrued_days: r.invoice.accrued_days, + ytm_pct: dec_to_f64(r.ytm), + current_yield_pct: dec_to_f64(r.current_yield), + simple_yield_pct: dec_to_f64(r.simple_yield), + money_market_yield_pct: r.money_market_yield.map(dec_to_f64), + g_spread_bps: dec_to_f64(r.g_spread.as_bps()), + z_spread_bps: dec_to_f64(r.z_spread.as_bps()), + benchmark_spread_bps: dec_to_f64(r.benchmark_spread.as_bps()), + benchmark_tenor: r.benchmark_tenor.clone(), + asw_spread_bps: r.asw_spread.map(|s| dec_to_f64(s.as_bps())), + oas_bps: r.oas.map(|s| dec_to_f64(s.as_bps())), + modified_duration: dec_to_f64(r.risk.modified_duration.years()), + macaulay_duration: dec_to_f64(r.risk.macaulay_duration.years()), + convexity: dec_to_f64(r.risk.convexity.value()), + dv01_per_100: dec_to_f64(r.risk.dv01_per_100.value()), + principal_amount: dec_to_f64(r.invoice.principal_amount), + accrued_amount: dec_to_f64(r.invoice.accrued_amount), + settlement_amount: dec_to_f64(r.invoice.settlement_amount), + }) + }) +} + +// ---- Scenarios -------------------------------------------------------------- + +pub fn scenario(request_json: &str) -> String { + with_resolved_refs(request_json, SCENARIO_REFS, scenario_inner) +} + +/// One base pricing + Z solve, then one reprice per scenario against the +/// bumped curve holding Z fixed (the KRD methodology, generalized). +fn scenario_inner(request: serde_json::Value) -> Result { + use convex_curves::bumping::{Scenario, ScenarioBump}; + + let req: ScenarioRequest = serde_json::from_value(request) + .map_err(|e| DispatchError::input(format!("ScenarioRequest: {e}")))?; + if req.scenarios.is_empty() { + return Err(DispatchError::input_field( + "scenarios", + "at least one scenario is required", + )); + } + let mark = parse_mark(&req.mark)?; + let base_inner = + registry::with_object::, _, _>(req.curve, |c| c.inner().clone()) + .ok_or_else(|| DispatchError::handle(format!("curve handle {} not found", req.curve)))?; + let base_wrapper = RateCurve::new(base_inner.clone()); + + with_fixed_bond!(req.bond, bond, { + let priced = price_from_mark( + bond, + req.settlement, + &mark, + Some(&base_wrapper), + req.quote_frequency, + )?; + let dirty = Decimal::from_f64_retain(priced.dirty_price_per_100) + .ok_or_else(|| DispatchError::analytics("non-finite dirty price"))?; + let z = ZSpreadCalculator::new(&base_wrapper).calculate(bond, dirty, req.settlement)?; + let z_decimal = dec_to_f64(z.as_decimal()); + let accrued = priced.dirty_price_per_100 - priced.clean_price_per_100; + + // Anchor the ladder at the mark: absorb the sub-cent Z-solve residual + // so the zero-shift row reproduces the marked price exactly. + let dirty0 = ZSpreadCalculator::new(&base_wrapper).price_with_spread( + bond, + z_decimal, + req.settlement, + ); + let basis = priced.dirty_price_per_100 - dirty0; + + let mut rows = Vec::with_capacity(req.scenarios.len()); + for (i, spec) in req.scenarios.iter().enumerate() { + let mut sc = Scenario::new( + spec.name + .clone() + .unwrap_or_else(|| format!("scenario {}", i + 1)), + ); + for b in &spec.bumps { + sc = sc.with_bump(match *b { + ScenarioBumpSpec::Parallel { shift_bps } => ScenarioBump::parallel(shift_bps), + ScenarioBumpSpec::Steepener { + short_shift_bps, + long_shift_bps, + pivot_tenor, + } => ScenarioBump::steepener(short_shift_bps, long_shift_bps, pivot_tenor), + ScenarioBumpSpec::Flattener { + short_shift_bps, + long_shift_bps, + pivot_tenor, + } => ScenarioBump::flattener(short_shift_bps, long_shift_bps, pivot_tenor), + ScenarioBumpSpec::KeyRate { tenor, shift_bps } => { + ScenarioBump::key_rate(tenor, shift_bps) + } + ScenarioBumpSpec::CreditSpread { shift_bps } => { + ScenarioBump::credit_spread(shift_bps) + } + }); + } + let bumped = RateCurve::new(sc.apply(&base_inner)); + let dirty_s = + ZSpreadCalculator::new(&bumped).price_with_spread(bond, z_decimal, req.settlement) + + basis; + let clean_s = dirty_s - accrued; + // Yield at the scenario price, for the ladder display. + let ytm_s = price_from_mark( + bond, + req.settlement, + &Mark::Price { + value: Decimal::from_f64_retain(clean_s) + .ok_or_else(|| DispatchError::analytics("non-finite scenario price"))?, + kind: convex_core::types::PriceKind::Clean, + }, + None, + req.quote_frequency, + ) + .map(|p| p.ytm_decimal) + .unwrap_or(f64::NAN); + rows.push(ScenarioRow { + name: sc.name().to_string(), + clean_price: clean_s, + dirty_price: dirty_s, + delta_clean: clean_s - priced.clean_price_per_100, + ytm_decimal: ytm_s, + }); + } + + Ok(ScenarioResponse { + base_clean: priced.clean_price_per_100, + base_ytm_decimal: priced.ytm_decimal, + z_spread_bps: dec_to_f64(z.as_bps()), + rows, + }) + }) +} + pub fn curve_query(request_json: &str) -> String { - to_envelope(curve_query_inner(request_json)) + with_resolved_refs(request_json, CURVE_QUERY_REFS, curve_query_inner) } -fn curve_query_inner(request_json: &str) -> Result { - let req: CurveQueryRequest = serde_json::from_str(request_json) +fn curve_query_inner(request: serde_json::Value) -> Result { + let req: CurveQueryRequest = serde_json::from_value(request) .map_err(|e| DispatchError::input(format!("CurveQueryRequest: {e}")))?; let result = registry::with_object::, _, _>(req.curve, |c| match req diff --git a/crates/convex-ffi/src/lib.rs b/crates/convex-ffi/src/lib.rs index 9c0fe7c8..920394e8 100644 --- a/crates/convex-ffi/src/lib.rs +++ b/crates/convex-ffi/src/lib.rs @@ -41,6 +41,59 @@ use libc::c_char; pub use registry::{Handle, INVALID_HANDLE}; +// ============================================================================ +// Panic guards +// ============================================================================ +// +// Every export runs inside `catch_unwind` so a stray panic becomes an error +// envelope / INVALID_HANDLE instead of killing the host process (Excel/JVM). +// Only effective under panic=unwind — ship cdylibs with `[profile.excel]` +// (see the root Cargo.toml). Handlers are still written to never panic; this +// is the backstop, not the error path. + +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "unknown panic".to_string() + } +} + +/// Run `f`, converting a panic into `INVALID_HANDLE` + last-error. +fn guard_handle(f: impl FnOnce() -> Handle) -> Handle { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { + Ok(h) => h, + Err(p) => { + error::set_last_error(format!("panic: {}", panic_message(&*p))); + INVALID_HANDLE + } + } +} + +/// Run `f`, converting a panic into a `{"ok":"false",...}` error envelope. +fn guard_envelope(f: impl FnOnce() -> *mut c_char) -> *mut c_char { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { + Ok(p) => p, + Err(p) => to_owned_c(err_envelope( + "panic", + &format!("internal error: {}", panic_message(&*p)), + )), + } +} + +/// Run `f`, swallowing a panic and returning `default`. +fn guard_value(default: R, f: impl FnOnce() -> R) -> R { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { + Ok(r) => r, + Err(p) => { + error::set_last_error(format!("panic: {}", panic_message(&*p))); + default + } + } +} + // ============================================================================ // Boundary helpers // ============================================================================ @@ -83,13 +136,13 @@ pub extern "C" fn convex_version() -> *const c_char { /// Build a bond from a `BondSpec` JSON. Returns `0` on failure. #[no_mangle] pub unsafe extern "C" fn convex_bond_from_json(spec_json: *const c_char) -> Handle { - with_str(spec_json, build::bond_from_json) + guard_handle(|| with_str(spec_json, build::bond_from_json)) } /// Build a curve from a `CurveSpec` JSON. Returns `0` on failure. #[no_mangle] pub unsafe extern "C" fn convex_curve_from_json(spec_json: *const c_char) -> Handle { - with_str(spec_json, build::curve_from_json) + guard_handle(|| with_str(spec_json, build::curve_from_json)) } /// Returns a JSON description of the registered object. @@ -97,25 +150,31 @@ pub unsafe extern "C" fn convex_curve_from_json(spec_json: *const c_char) -> Han /// Free the returned pointer with [`convex_string_free`]. #[no_mangle] pub extern "C" fn convex_describe(handle: Handle) -> *mut c_char { - to_owned_c(dispatch::describe(handle)) + guard_envelope(|| to_owned_c(dispatch::describe(handle))) } /// Releases an object by handle. No-op on invalid handle. #[no_mangle] pub extern "C" fn convex_release(handle: Handle) { - registry::release(handle); + guard_value((), || { + registry::release(handle); + }); } /// Number of registered objects. #[no_mangle] pub extern "C" fn convex_object_count() -> i32 { - registry::object_count() as i32 + guard_value(-1, || registry::object_count() as i32) } /// Returns a JSON array of `{handle,kind,name?}` entries for every registered /// object. Caller frees with [`convex_string_free`]. #[no_mangle] pub extern "C" fn convex_list_objects() -> *mut c_char { + guard_envelope(convex_list_objects_inner) +} + +fn convex_list_objects_inner() -> *mut c_char { let entries: Vec<_> = registry::list(None) .into_iter() .map(|(h, kind, name)| { @@ -133,7 +192,15 @@ pub extern "C" fn convex_list_objects() -> *mut c_char { /// Clears all registered objects. #[no_mangle] pub extern "C" fn convex_clear_all() { - registry::clear_all() + guard_value((), registry::clear_all) +} + +/// Registry mutation counter (idempotent re-registers do not bump it). +/// Same value across two reads ⇒ every handle resolves to the same object; +/// callers key response caches on it. +#[no_mangle] +pub extern "C" fn convex_generation() -> u64 { + guard_value(0, registry::generation) } // ============================================================================ @@ -181,6 +248,24 @@ pub unsafe extern "C" fn convex_make_whole(request_json: *const c_char) -> *mut rpc(request_json, dispatch::make_whole) } +/// One-call yield & spread analysis (Bloomberg-YAS style). Request: +/// `YasRequest`. Response: `YasResponse` — every yield convention, G/Z/ +/// benchmark/ASW spreads, risk metrics, and the settlement invoice from a +/// single computation. +#[no_mangle] +pub unsafe extern "C" fn convex_yas(request_json: *const c_char) -> *mut c_char { + rpc(request_json, dispatch::yas) +} + +/// Curve scenario ladder in one call. Request: `ScenarioRequest` (N named +/// scenarios of parallel/steepener/flattener/key-rate/credit bumps). +/// Response: `ScenarioResponse` — base pricing + one repriced row per +/// scenario, holding the mark-implied Z-spread fixed. +#[no_mangle] +pub unsafe extern "C" fn convex_scenario(request_json: *const c_char) -> *mut c_char { + rpc(request_json, dispatch::scenario) +} + // ============================================================================ // Hedge advisor (JSON in, JSON out) // ============================================================================ @@ -221,10 +306,12 @@ pub unsafe extern "C" fn convex_compare(request_json: *const c_char) -> *mut c_c /// `CurveQueryRequest`, `CurveQueryResponse`. #[no_mangle] pub unsafe extern "C" fn convex_schema(type_name: *const c_char) -> *mut c_char { - let result = with_str_owned(type_name, schemas::lookup); - to_owned_c(match result { - Ok(json) => format!(r#"{{"ok":"true","result":{json}}}"#), - Err(msg) => err_envelope("schema", &msg), + guard_envelope(|| { + let result = with_str_owned(type_name, schemas::lookup); + to_owned_c(match result { + Ok(json) => format!(r#"{{"ok":"true","result":{json}}}"#), + Err(msg) => err_envelope("schema", &msg), + }) }) } @@ -233,16 +320,18 @@ pub unsafe extern "C" fn convex_schema(type_name: *const c_char) -> *mut c_char /// Returns a JSON envelope; on success, `result` is the canonical `Mark` JSON. #[no_mangle] pub unsafe extern "C" fn convex_mark_parse(text: *const c_char) -> *mut c_char { - let payload = with_str_owned(text, |s| { - s.parse::() - .map_err(|e| e.to_string()) - }); - to_owned_c(match payload { - Ok(mark) => match serde_json::to_string(&mark) { - Ok(json) => format!(r#"{{"ok":"true","result":{json}}}"#), - Err(e) => err_envelope("serialize", &e.to_string()), - }, - Err(e) => err_envelope("invalid_input", &e), + guard_envelope(|| { + let payload = with_str_owned(text, |s| { + s.parse::() + .map_err(|e| e.to_string()) + }); + to_owned_c(match payload { + Ok(mark) => match serde_json::to_string(&mark) { + Ok(json) => format!(r#"{{"ok":"true","result":{json}}}"#), + Err(e) => err_envelope("serialize", &e.to_string()), + }, + Err(e) => err_envelope("invalid_input", &e), + }) }) } @@ -282,11 +371,13 @@ where } unsafe fn rpc(request_json: *const c_char, handler: fn(&str) -> String) -> *mut c_char { - let response = match with_str_owned(request_json, |s| Ok::(handler(s))) { - Ok(r) => r, - Err(e) => err_envelope("invalid_input", &e), - }; - to_owned_c(response) + guard_envelope(|| { + let response = match with_str_owned(request_json, |s| Ok::(handler(s))) { + Ok(r) => r, + Err(e) => err_envelope("invalid_input", &e), + }; + to_owned_c(response) + }) } fn to_owned_c(s: String) -> *mut c_char { @@ -318,3 +409,50 @@ pub(crate) fn ok_envelope(result: &T) -> String { Err(e) => err_envelope("serialize", &e.to_string()), } } + +#[cfg(test)] +mod panic_guard_tests { + use super::*; + use std::ffi::CStr; + + // These run under the test profile (panic = "unwind", the cargo default), + // the same strategy `[profile.excel]` uses for the shipped DLL. + + #[test] + fn guard_handle_converts_panic_to_invalid_handle_and_last_error() { + error::clear_error(); + let h = guard_handle(|| panic!("boom in builder")); + assert_eq!(h, INVALID_HANDLE); + let msg = unsafe { CStr::from_ptr(error::last_error_message()) } + .to_str() + .unwrap(); + assert!(msg.contains("boom in builder"), "got: {msg}"); + } + + #[test] + fn guard_envelope_converts_panic_to_error_envelope() { + let ptr = guard_envelope(|| panic!("boom in rpc")); + assert!(!ptr.is_null()); + let s = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_string(); + unsafe { convex_string_free(ptr) }; + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["ok"], "false"); + assert_eq!(v["error"]["code"], "panic"); + assert!(v["error"]["message"] + .as_str() + .unwrap() + .contains("boom in rpc")); + } + + #[test] + fn guard_value_returns_default_on_panic() { + let n = guard_value(-1, || -> i32 { panic!("boom") }); + assert_eq!(n, -1); + } + + #[test] + fn guard_passes_through_on_success() { + assert_eq!(guard_handle(|| 42), 42); + assert_eq!(guard_value(-1, || 7), 7); + } +} diff --git a/crates/convex-ffi/src/registry.rs b/crates/convex-ffi/src/registry.rs index 817e3ccd..927b57ea 100644 --- a/crates/convex-ffi/src/registry.rs +++ b/crates/convex-ffi/src/registry.rs @@ -59,7 +59,13 @@ impl ObjectKind { struct Entry { kind: ObjectKind, + /// Eviction-slot name (owning cell's registry key, or the spec's own id). name: Option, + /// Human identifiers for [`resolve_alias`]; kept here so eviction can + /// clean the alias map without scanning it. + aliases: Vec, + /// Hash of the canonicalized spec JSON, for idempotent re-registration. + content_hash: u64, /// `Arc` so [`with_object`] can clone it out and drop the read lock before /// running the closure — holding the lock across analytics that look up a /// second object nests a read lock, which `std::sync::RwLock` deadlocks on @@ -67,15 +73,20 @@ struct Entry { object: Arc, } -/// Single lock over both maps so name↔handle updates stay atomic. +/// Single lock over all maps so name/alias↔handle updates stay atomic. #[derive(Default)] struct Tables { objects: HashMap, names: HashMap, + /// CUSIP/ISIN/free-name → handle; last write wins on collision. + aliases: HashMap, } struct Inner { next_handle: AtomicU64, + /// Bumped on every mutation, NOT on idempotent re-registration. Same + /// generation ⇒ every handle resolves to the same object it did before. + generation: AtomicU64, tables: RwLock, } @@ -83,6 +94,7 @@ impl Inner { fn new() -> Self { Self { next_handle: AtomicU64::new(100), + generation: AtomicU64::new(1), tables: RwLock::new(Tables::default()), } } @@ -90,30 +102,76 @@ impl Inner { static REGISTRY: Lazy = Lazy::new(Inner::new); -/// Register an object. If `name` is provided and already exists, the prior -/// handle is released first (so dependent cells see a fresh handle). -pub fn register(object: T, kind: ObjectKind, name: Option) -> Handle { - let handle = REGISTRY.next_handle.fetch_add(1, Ordering::SeqCst); +/// Registry mutation counter. See [`Inner::generation`]. +pub fn generation() -> u64 { + REGISTRY.generation.load(Ordering::SeqCst) +} + +/// Register an object. Idempotent per eviction slot: an unchanged +/// `content_hash` returns the existing handle with no generation bump (so +/// Excel's dependency cutoff stops recalc cascades); changed content evicts +/// the prior object plus its aliases and mints a fresh handle so dependents +/// recompute. +pub fn register( + object: T, + kind: ObjectKind, + name: Option, + aliases: &[String], + content_hash: u64, +) -> Handle { let mut t = REGISTRY.tables.write().unwrap(); + if let Some(ref n) = name { - if let Some(old) = t.names.get(n).copied() { - t.objects.remove(&old); + if let Some(&existing) = t.names.get(n) { + match t.objects.get(&existing) { + Some(e) if e.content_hash == content_hash && e.kind == kind => { + return existing; + } + _ => { + if let Some(old) = t.objects.remove(&existing) { + for a in &old.aliases { + if t.aliases.get(a) == Some(&existing) { + t.aliases.remove(a); + } + } + } + t.names.remove(n); + } + } } } + + let handle = REGISTRY.next_handle.fetch_add(1, Ordering::SeqCst); t.objects.insert( handle, Entry { kind, name: name.clone(), + aliases: aliases.to_vec(), + content_hash, object: Arc::new(object), }, ); if let Some(n) = name { t.names.insert(n, handle); } + for a in aliases { + t.aliases.insert(a.clone(), handle); + } + REGISTRY.generation.fetch_add(1, Ordering::SeqCst); handle } +/// Resolve a human identifier (CUSIP / ISIN / free name) — or, as a fallback, +/// an eviction-slot name — to a handle. +pub fn resolve_alias(name: &str) -> Option { + let t = REGISTRY.tables.read().unwrap(); + t.aliases + .get(name) + .copied() + .or_else(|| t.names.get(name).copied()) +} + /// Run `f` against the typed object behind `handle`, returning `None` if /// the handle is unknown or the type doesn't match. /// @@ -151,12 +209,6 @@ pub fn name_of(handle: Handle) -> Option { .and_then(|e| e.name.clone()) } -/// Look up a handle by name. -#[allow(dead_code)] -pub fn lookup(name: &str) -> Option { - REGISTRY.tables.read().unwrap().names.get(name).copied() -} - /// Drop an object. Returns `true` if it existed. pub fn release(handle: Handle) -> bool { let mut t = REGISTRY.tables.write().unwrap(); @@ -164,6 +216,12 @@ pub fn release(handle: Handle) -> bool { if let Some(name) = entry.name { t.names.remove(&name); } + for a in &entry.aliases { + if t.aliases.get(a) == Some(&handle) { + t.aliases.remove(a); + } + } + REGISTRY.generation.fetch_add(1, Ordering::SeqCst); true } else { false @@ -171,6 +229,8 @@ pub fn release(handle: Handle) -> bool { } /// All objects matching an optional kind filter, as `(handle, kind, name)`. +/// The name is the human identifier (CUSIP/ISIN/free name) when the object +/// has one; the eviction-slot name (cell address) is the fallback. pub fn list(filter: Option) -> Vec<(Handle, ObjectKind, Option)> { REGISTRY .tables @@ -179,7 +239,10 @@ pub fn list(filter: Option) -> Vec<(Handle, ObjectKind, Option = Mutex::new(()); + + fn reg(name: &str, hash: u64) -> Handle { + register( + hash, // any Send+Sync value works as the object + ObjectKind::Bond(BondKind::FixedRate), + Some(name.to_string()), + &[], + hash, + ) + } + + #[test] + fn same_name_same_hash_reuses_handle_without_generation_bump() { + let _g = TEST_LOCK.lock().unwrap(); + let h1 = reg("idem-cell", 42); + let gen_before = generation(); + let h2 = reg("idem-cell", 42); + assert_eq!(h1, h2); + assert_eq!( + generation(), + gen_before, + "idempotent re-register must not bump generation" + ); + release(h1); + } + + #[test] + fn same_name_different_hash_evicts_and_mints_new_handle() { + let _g = TEST_LOCK.lock().unwrap(); + let h1 = reg("edit-cell", 1); + let gen_before = generation(); + let h2 = reg("edit-cell", 2); + assert_ne!(h1, h2); + assert!(generation() > gen_before); + assert!(kind_of(h1).is_none(), "old handle must be evicted"); + assert!(kind_of(h2).is_some()); + release(h2); + } + + #[test] + fn aliases_resolve_and_follow_last_write() { + let _g = TEST_LOCK.lock().unwrap(); + let h1 = register( + 1u64, + ObjectKind::Bond(BondKind::FixedRate), + Some("cell-a".into()), + &["912828YK0".into()], + 10, + ); + assert_eq!(resolve_alias("912828YK0"), Some(h1)); + // Same CUSIP built from a different cell: alias follows the new build. + let h2 = register( + 2u64, + ObjectKind::Bond(BondKind::FixedRate), + Some("cell-b".into()), + &["912828YK0".into()], + 11, + ); + assert_eq!(resolve_alias("912828YK0"), Some(h2)); + // The first cell's own handle still works. + assert!(kind_of(h1).is_some()); + release(h1); + release(h2); + assert_eq!(resolve_alias("912828YK0"), None); + } + + #[test] + fn resolve_alias_falls_back_to_slot_name() { + let _g = TEST_LOCK.lock().unwrap(); + let h = reg("Sheet1!R5C2", 7); + assert_eq!(resolve_alias("Sheet1!R5C2"), Some(h)); + release(h); + } + + #[test] + fn release_and_clear_bump_generation_reads_do_not() { + let _g = TEST_LOCK.lock().unwrap(); + let h = reg("gen-cell", 3); + let g1 = generation(); + let _ = kind_of(h); + let _ = name_of(h); + let _ = list(None); + let _ = object_count(); + let _ = resolve_alias("gen-cell"); + assert_eq!(generation(), g1, "reads must not bump generation"); + release(h); + assert!(generation() > g1); + let g2 = generation(); + clear_all(); + assert!(generation() > g2); + } + + #[test] + fn release_of_stale_alias_owner_keeps_current_owner() { + let _g = TEST_LOCK.lock().unwrap(); + let h1 = register( + 1u64, + ObjectKind::Curve, + Some("cell-1".into()), + &["USD.SOFR".into()], + 20, + ); + let h2 = register( + 2u64, + ObjectKind::Curve, + Some("cell-2".into()), + &["USD.SOFR".into()], + 21, + ); + // h1 no longer owns the alias; releasing it must not remove it. + release(h1); + assert_eq!(resolve_alias("USD.SOFR"), Some(h2)); + release(h2); + } } diff --git a/crates/convex-ffi/src/schemas.rs b/crates/convex-ffi/src/schemas.rs index b242b3d8..96a795c4 100644 --- a/crates/convex-ffi/src/schemas.rs +++ b/crates/convex-ffi/src/schemas.rs @@ -21,6 +21,10 @@ pub fn lookup(name: &str) -> Result { "CurveQueryResponse" => CURVE_QUERY_RESPONSE, "MakeWholeRequest" => MAKE_WHOLE_REQUEST, "MakeWholeResponse" => MAKE_WHOLE_RESPONSE, + "YasRequest" => YAS_REQUEST, + "YasResponse" => YAS_RESPONSE, + "ScenarioRequest" => SCENARIO_REQUEST, + "ScenarioResponse" => SCENARIO_RESPONSE, "RiskProfileRequest" => RISK_PROFILE_REQUEST, "RiskProfile" => RISK_PROFILE_RESPONSE, "HedgeRequest" => HEDGE_REQUEST, @@ -227,6 +231,107 @@ const MAKE_WHOLE_RESPONSE: &str = r##"{ } }"##; +const YAS_REQUEST: &str = r##"{ + "title": "YasRequest", + "description": "One-call yield & spread analysis (Bloomberg-YAS style). References may be numeric handles or registered names (CUSIP/ISIN/curve name).", + "type": "object", + "required": ["bond","settlement","mark","curve"], + "properties": { + "bond": {"type": ["integer","string"], "description": "Fixed-coupon bond handle or ticker"}, + "settlement": {"type": "string", "format": "date"}, + "mark": {"$ref": "#/definitions/Mark"}, + "curve": {"type": ["integer","string"], "description": "Spot/discount curve (Z-spread; G-spread too unless govt_curve set)"}, + "govt_curve": {"type": ["integer","string","null"], "description": "Government curve for G/benchmark spreads"}, + "swap_curve": {"type": ["integer","string","null"], "description": "Swap curve for I-spread"}, + "quote_frequency": {"$ref": "#/definitions/Frequency"} + } +}"##; + +const YAS_RESPONSE: &str = r##"{ + "title": "YasResponse", + "description": "Yields in percent, spreads in basis points, prices/amounts per 100 face.", + "type": "object", + "required": ["clean_price","dirty_price","accrued","accrued_days","ytm_pct","current_yield_pct","simple_yield_pct","g_spread_bps","z_spread_bps","benchmark_spread_bps","benchmark_tenor","modified_duration","macaulay_duration","convexity","dv01_per_100","principal_amount","accrued_amount","settlement_amount"], + "properties": { + "clean_price": {"type": "number"}, + "dirty_price": {"type": "number"}, + "accrued": {"type": "number"}, + "accrued_days": {"type": "integer"}, + "ytm_pct": {"type": "number"}, + "current_yield_pct": {"type": "number"}, + "simple_yield_pct": {"type": "number"}, + "money_market_yield_pct": {"type": ["number","null"]}, + "g_spread_bps": {"type": "number"}, + "z_spread_bps": {"type": "number"}, + "benchmark_spread_bps": {"type": "number"}, + "benchmark_tenor": {"type": "string"}, + "asw_spread_bps": {"type": ["number","null"]}, + "oas_bps": {"type": ["number","null"]}, + "modified_duration": {"type": "number"}, + "macaulay_duration": {"type": "number"}, + "convexity": {"type": "number"}, + "dv01_per_100": {"type": "number"}, + "principal_amount": {"type": "number"}, + "accrued_amount": {"type": "number"}, + "settlement_amount": {"type": "number"} + } +}"##; + +const SCENARIO_REQUEST: &str = r##"{ + "title": "ScenarioRequest", + "description": "Run N curve scenarios against one bond in a single call; each reprices holding the mark-implied Z-spread fixed. References may be numeric handles or registered names.", + "type": "object", + "required": ["bond","curve","settlement","mark","scenarios"], + "properties": { + "bond": {"type": ["integer","string"]}, + "curve": {"type": ["integer","string"]}, + "settlement": {"type": "string", "format": "date"}, + "mark": {"$ref": "#/definitions/Mark"}, + "scenarios": {"type": "array", "items": { + "type": "object", + "required": ["bumps"], + "properties": { + "name": {"type": ["string","null"]}, + "bumps": {"type": "array", "items": { + "type": "object", + "required": ["kind"], + "properties": { + "kind": {"enum": ["parallel","steepener","flattener","key_rate","credit_spread"]}, + "shift_bps": {"type": "number"}, + "short_shift_bps": {"type": "number"}, + "long_shift_bps": {"type": "number"}, + "pivot_tenor": {"type": "number", "description": "Years; default 5"}, + "tenor": {"type": "number", "description": "key_rate only"} + } + }} + } + }}, + "quote_frequency": {"$ref": "#/definitions/Frequency"} + } +}"##; + +const SCENARIO_RESPONSE: &str = r##"{ + "title": "ScenarioResponse", + "type": "object", + "required": ["base_clean","base_ytm_decimal","z_spread_bps","rows"], + "properties": { + "base_clean": {"type": "number"}, + "base_ytm_decimal": {"type": "number"}, + "z_spread_bps": {"type": "number"}, + "rows": {"type": "array", "items": { + "type": "object", + "required": ["name","clean_price","dirty_price","delta_clean","ytm_decimal"], + "properties": { + "name": {"type": "string"}, + "clean_price": {"type": "number"}, + "dirty_price": {"type": "number"}, + "delta_clean": {"type": "number", "description": "vs the base mark"}, + "ytm_decimal": {"type": "number"} + } + }} + } +}"##; + const RISK_PROFILE_REQUEST: &str = r##"{ "title": "RiskProfileRequest", "description": "Build a position RiskProfile from a bond + discount curve. Response is the RiskProfile itself (round-trips into HedgeRequest/CompareRequest).", diff --git a/crates/convex-ffi/tests/smoke.rs b/crates/convex-ffi/tests/smoke.rs index 332a4a36..6e186375 100644 --- a/crates/convex-ffi/tests/smoke.rs +++ b/crates/convex-ffi/tests/smoke.rs @@ -843,13 +843,24 @@ fn registry_key_scopes_eviction_per_cell() { assert_eq!(rpc(convex_ffi::convex_price, &req(a))["ok"], "true"); assert_eq!(rpc(convex_ffi::convex_price, &req(b))["ok"], "true"); - // Rebuilding cell A (same key) evicts only A's prior object; B survives. - let a2 = build_handle(spec(&key_a)); + // Rebuilding cell A with an UNCHANGED spec is idempotent: same handle + // back — Excel dependents see no change. (The matching "no generation + // bump" property is asserted in registry.rs's serialized unit tests; + // this integration test runs in parallel with others that legitimately + // bump the global generation, so it can't be asserted here.) + let a_same = build_handle(spec(&key_a)); + assert_eq!(a_same, a, "unchanged rebuild must reuse the handle"); + + // Rebuilding cell A with an EDITED spec (different coupon) evicts only + // A's prior object and mints a new handle; B survives. + let mut edited = spec(&key_a); + edited["coupon_rate"] = json!(0.06); + let a2 = build_handle(edited); assert_ne!(a2, a); assert_eq!( rpc(convex_ffi::convex_price, &req(a))["ok"], "false", - "A's old handle must be evicted by its own cell's rebuild" + "A's old handle must be evicted by its own cell's edited rebuild" ); assert_eq!( rpc(convex_ffi::convex_price, &req(b))["ok"], @@ -859,3 +870,248 @@ fn registry_key_scopes_eviction_per_cell() { assert_eq!(rpc(convex_ffi::convex_price, &req(a2))["ok"], "true"); } } + +// CX.SCENARIO backing verb: whole ladder in one call, Z held fixed. +#[test] +fn scenario_ladder_reprices_under_bumps() { + unsafe { + let h = build_handle(fixed_rate_5pct()); + let curve_spec = json!({ + "type": "discrete", + "name": uid("SCNCRV."), + "ref_date": "2025-01-15", + "tenors": [0.5, 1.0, 2.0, 5.0, 10.0, 30.0], + "values": [0.04, 0.04, 0.04, 0.04, 0.04, 0.04], + "value_kind": "zero_rate", + "interpolation": "linear", + "day_count": "Act365Fixed", + "compounding": "Continuous" + }); + let cs = CString::new(curve_spec.to_string()).unwrap(); + let ch = convex_ffi::convex_curve_from_json(cs.as_ptr()); + assert_ne!(ch, convex_ffi::INVALID_HANDLE); + + let req = json!({ + "bond": h, + "curve": ch, + "settlement": "2025-04-15", + "mark": "99.5C", + "scenarios": [ + {"name": "-50bp", "bumps": [{"kind": "parallel", "shift_bps": -50.0}]}, + {"name": "base", "bumps": [{"kind": "parallel", "shift_bps": 0.0}]}, + {"name": "+50bp", "bumps": [{"kind": "parallel", "shift_bps": 50.0}]}, + {"name": "steep", "bumps": [{"kind": "steepener", "short_shift_bps": 25.0, "long_shift_bps": 25.0}]} + ] + }) + .to_string(); + let resp = rpc(convex_ffi::convex_scenario, &req); + assert_eq!(resp["ok"], "true", "resp: {resp}"); + let r = &resp["result"]; + assert!((r["base_clean"].as_f64().unwrap() - 99.5).abs() < 1e-9); + let rows = r["rows"].as_array().unwrap(); + assert_eq!(rows.len(), 4); + let down = rows[0]["clean_price"].as_f64().unwrap(); + let base = rows[1]["clean_price"].as_f64().unwrap(); + let up = rows[2]["clean_price"].as_f64().unwrap(); + // Rates down → price up; zero shift reproduces the base price. + assert!(down > base, "down {down} vs base {base}"); + assert!(up < base, "up {up} vs base {base}"); + assert!( + (base - 99.5).abs() < 1e-6, + "zero-shift row must match base, got {base}" + ); + assert!((rows[1]["delta_clean"].as_f64().unwrap()).abs() < 1e-6); + } +} + +// A G-spread mark must price against the supplied government curve, not the +// spot curve (the quote's benchmark is the govt curve). +#[test] +fn yas_g_spread_mark_prices_against_govt_curve() { + unsafe { + let h = build_handle(fixed_rate_5pct()); + let curve = |name: String, level: f64| { + json!({ + "type": "discrete", + "name": name, + "ref_date": "2025-01-15", + "tenors": [0.5, 1.0, 2.0, 5.0, 10.0, 30.0], + "values": [level, level, level, level, level, level], + "value_kind": "zero_rate", + "interpolation": "linear", + "day_count": "Act365Fixed", + "compounding": "Continuous" + }) + }; + let spot = build_curve(curve(uid("SPOT."), 0.045)); + let govt = build_curve(curve(uid("GOVT."), 0.030)); + + let yas = |govt_curve: Option| { + let mut req = json!({ + "bond": h, + "settlement": "2025-04-15", + "mark": "125 G@USD.TSY", + "curve": spot + }); + if let Some(g) = govt_curve { + req["govt_curve"] = json!(g); + } + rpc(convex_ffi::convex_yas, &req.to_string()) + }; + + let with_govt = yas(Some(govt)); + let without = yas(None); + assert_eq!(with_govt["ok"], "true", "resp: {with_govt}"); + assert_eq!(without["ok"], "true", "resp: {without}"); + let p_govt = with_govt["result"]["clean_price"].as_f64().unwrap(); + let p_spot = without["result"]["clean_price"].as_f64().unwrap(); + // 150bp of benchmark difference must move the marked price materially; + // identical prices would mean the govt curve was ignored. + assert!( + (p_govt - p_spot).abs() > 1.0, + "govt-curve pricing ignored: {p_govt} vs {p_spot}" + ); + } +} + +// CX.YAS backing verb: one call returns yields, spreads, risk and invoice. +#[test] +fn yas_returns_full_analysis_in_one_call() { + unsafe { + let h = build_handle(fixed_rate_5pct()); + let curve_spec = json!({ + "type": "discrete", + "name": uid("YASCRV."), + "ref_date": "2025-01-15", + "tenors": [0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0], + "values": [0.040, 0.040, 0.041, 0.042, 0.043, 0.044, 0.045], + "value_kind": "zero_rate", + "interpolation": "linear", + "day_count": "Act365Fixed", + "compounding": "Continuous" + }); + let cs = CString::new(curve_spec.to_string()).unwrap(); + let ch = convex_ffi::convex_curve_from_json(cs.as_ptr()); + assert_ne!(ch, convex_ffi::INVALID_HANDLE); + + let req = json!({ + "bond": h, + "settlement": "2025-04-15", + "mark": "99.5C", + "curve": ch + }) + .to_string(); + let resp = rpc(convex_ffi::convex_yas, &req); + assert_eq!(resp["ok"], "true", "resp: {resp}"); + let r = &resp["result"]; + assert!((r["clean_price"].as_f64().unwrap() - 99.5).abs() < 1e-9); + let ytm = r["ytm_pct"].as_f64().unwrap(); + assert!(ytm > 4.0 && ytm < 6.5, "ytm_pct = {ytm}"); + assert!(r["z_spread_bps"].as_f64().unwrap().is_finite()); + assert!(r["g_spread_bps"].as_f64().unwrap().is_finite()); + assert!(r["modified_duration"].as_f64().unwrap() > 0.0); + assert!(r["dv01_per_100"].as_f64().unwrap() > 0.0); + assert!(r["benchmark_tenor"].as_str().unwrap().len() >= 2); + assert!(r["settlement_amount"].as_f64().unwrap() > 0.0); + } +} + +// Ticker-style referencing: a request may name a bond by CUSIP (or a curve by +// its name) instead of a numeric handle; dispatch resolves it via the alias +// table. Unknown names come back as invalid_handle. +#[test] +fn requests_resolve_ticker_references() { + unsafe { + let cusip = uid("TCK"); + let curve_name = uid("CRV."); + let bond = json!({ + "type": "fixed_rate", + "cusip": cusip, + "registry_key": uid("cell"), + "coupon_rate": 0.05, + "frequency": "SemiAnnual", + "maturity": "2035-01-15", + "issue": "2025-01-15", + "day_count": "Thirty360US", + "currency": "USD", + "face_value": 100 + }); + let _bh = build_handle(bond); + let curve_spec = json!({ + "type": "discrete", + "name": curve_name, + "registry_key": uid("cell"), + "ref_date": "2025-01-15", + "tenors": [0.5, 1.0, 5.0, 10.0, 30.0], + "values": [0.04, 0.04, 0.04, 0.04, 0.04], + "value_kind": "zero_rate", + "interpolation": "linear", + "day_count": "Act365Fixed", + "compounding": "Continuous" + }); + let cs = CString::new(curve_spec.to_string()).unwrap(); + let ch = convex_ffi::convex_curve_from_json(cs.as_ptr()); + assert_ne!(ch, convex_ffi::INVALID_HANDLE); + + // Price by CUSIP string instead of a handle. + let req = json!({ + "bond": cusip, + "settlement": "2025-04-15", + "mark": "99.5C" + }) + .to_string(); + let resp = rpc(convex_ffi::convex_price, &req); + assert_eq!(resp["ok"], "true", "resp: {resp}"); + + // Spread with both references as strings. + let req = json!({ + "bond": cusip, + "curve": curve_name, + "settlement": "2025-04-15", + "mark": "99.5C", + "spread_type": "ZSpread" + }) + .to_string(); + let resp = rpc(convex_ffi::convex_spread, &req); + assert_eq!(resp["ok"], "true", "resp: {resp}"); + + // Unknown ticker → invalid_handle. + let req = json!({ + "bond": "NO_SUCH_BOND_XYZ", + "settlement": "2025-04-15", + "mark": "99.5C" + }) + .to_string(); + let resp = rpc(convex_ffi::convex_price, &req); + assert_eq!(resp["ok"], "false"); + assert_eq!(resp["error"]["code"], "invalid_handle"); + } +} + +// Field order must not affect the idempotency hash: the content hash is over +// the canonicalized (key-sorted) JSON value, not the raw request bytes. Send +// raw strings straight to the FFI so no client-side re-serialization hides +// an ordering bug. +#[test] +fn idempotency_hash_ignores_json_field_order() { + unsafe { + let key = uid("cell"); + let spec_a = format!( + "{{\"type\":\"fixed_rate\",\"registry_key\":\"{key}\",\"coupon_rate\":0.05,\ + \"frequency\":\"SemiAnnual\",\"maturity\":\"2035-01-15\",\"issue\":\"2025-01-15\",\ + \"day_count\":\"Thirty360US\",\"currency\":\"USD\",\"face_value\":100.0}}" + ); + let spec_b = format!( + "{{\"face_value\":100.0,\"currency\":\"USD\",\"day_count\":\"Thirty360US\",\ + \"issue\":\"2025-01-15\",\"maturity\":\"2035-01-15\",\"frequency\":\"SemiAnnual\",\ + \"coupon_rate\":0.05,\"registry_key\":\"{key}\",\"type\":\"fixed_rate\"}}" + ); + let a = CString::new(spec_a).unwrap(); + let b = CString::new(spec_b).unwrap(); + let h1 = convex_ffi::convex_bond_from_json(a.as_ptr()); + let h2 = convex_ffi::convex_bond_from_json(b.as_ptr()); + assert_ne!(h1, convex_ffi::INVALID_HANDLE); + assert_eq!(h1, h2, "reordered fields must hash identically"); + convex_ffi::convex_release(h1); + } +} diff --git a/excel/Convex.Excel.Tests/Convex.Excel.Tests.csproj b/excel/Convex.Excel.Tests/Convex.Excel.Tests.csproj new file mode 100644 index 00000000..d90190d1 --- /dev/null +++ b/excel/Convex.Excel.Tests/Convex.Excel.Tests.csproj @@ -0,0 +1,21 @@ + + + net472 + x64 + 11.0 + enable + false + + + + + + + + + + + + + diff --git a/excel/Convex.Excel.Tests/CxCacheTests.cs b/excel/Convex.Excel.Tests/CxCacheTests.cs new file mode 100644 index 00000000..e1d887a8 --- /dev/null +++ b/excel/Convex.Excel.Tests/CxCacheTests.cs @@ -0,0 +1,129 @@ +using System; +using Convex.Excel; +using Xunit; + +namespace Convex.Excel.Tests +{ + [Collection("cache")] // CxCache is global state — serialize these tests + public class CxCacheTests : IDisposable + { + private long _generation = 1; + private int _computes; + + public CxCacheTests() + { + CxCache.Clear(); + CxCache.GenerationSource = () => _generation; + } + + public void Dispose() + { + CxCache.GenerationSource = () => unchecked((long)Cx.Generation()); + CxCache.Clear(); + } + + private string Compute() + { + _computes++; + return $"result-{_computes}"; + } + + [Fact] + public void Identical_request_hits_cache_within_a_generation() + { + var r1 = CxCache.GetOrCompute("price", "{\"bond\":100}", Compute); + var r2 = CxCache.GetOrCompute("price", "{\"bond\":100}", Compute); + Assert.Equal(r1, r2); + Assert.Equal(1, _computes); + } + + [Fact] + public void Different_verbs_do_not_collide() + { + CxCache.GetOrCompute("price", "{\"bond\":100}", Compute); + CxCache.GetOrCompute("risk", "{\"bond\":100}", Compute); + Assert.Equal(2, _computes); + } + + [Fact] + public void Generation_bump_invalidates() + { + CxCache.GetOrCompute("price", "{\"bond\":100}", Compute); + _generation++; + CxCache.GetOrCompute("price", "{\"bond\":100}", Compute); + Assert.Equal(2, _computes); + } + + [Fact] + public void Result_computed_across_a_mutation_is_not_cached() + { + // The registry mutates while the compute is in flight: the result + // must be returned but NOT cached (it may reflect either world). + var r = CxCache.GetOrCompute("price", "{\"bond\":\"T5\"}", () => + { + _generation++; // mutation mid-compute + return Compute(); + }); + Assert.Equal("result-1", r); + // Next call recomputes: nothing stale was kept. + CxCache.GetOrCompute("price", "{\"bond\":\"T5\"}", Compute); + Assert.Equal(2, _computes); + } + + [Fact] + public void Concurrent_identical_requests_share_one_compute() + { + // Single-flight: the second caller must block on the first's + // in-flight Lazy instead of invoking the engine again. + var started = new System.Threading.ManualResetEventSlim(); + var release = new System.Threading.ManualResetEventSlim(); + int computes = 0; + string SlowCompute() + { + System.Threading.Interlocked.Increment(ref computes); + started.Set(); + release.Wait(5000); + return "r"; + } + var t1 = System.Threading.Tasks.Task.Run( + () => CxCache.GetOrCompute("price", "{\"bond\":100}", SlowCompute)); + started.Wait(5000); + var t2 = System.Threading.Tasks.Task.Run( + () => CxCache.GetOrCompute("price", "{\"bond\":100}", SlowCompute)); + release.Set(); + Assert.Equal("r", t1.Result); + Assert.Equal("r", t2.Result); + Assert.Equal(1, computes); + } + + [Fact] + public void Entry_from_an_older_generation_is_rejected_on_hit() + { + // A straggler computed under gen 1 must not be served to a gen-2 + // reader even if the wholesale clear has not run for it: entries + // are tagged and validated on every hit. + CxCache.GetOrCompute("price", "{\"bond\":\"T7\"}", Compute); // tag: gen 1 + _generation = 2; + CxCache.GetOrCompute("price", "{\"bond\":\"T7\"}", Compute); + Assert.Equal(2, _computes); + } + + [Fact] + public void Compute_exceptions_are_not_cached() + { + Assert.Throws(() => + CxCache.GetOrCompute("price", "{}", () => throw new InvalidOperationException())); + CxCache.GetOrCompute("price", "{}", Compute); + Assert.Equal(1, _computes); + } + + [Fact] + public void Unreadable_generation_bypasses_cache() + { + CxCache.GenerationSource = () => throw new DllNotFoundException(); + CxCache.GetOrCompute("price", "{}", Compute); + CxCache.GetOrCompute("price", "{}", Compute); + Assert.Equal(2, _computes); + } + } +} diff --git a/excel/Convex.Excel.Tests/CxParseTests.cs b/excel/Convex.Excel.Tests/CxParseTests.cs new file mode 100644 index 00000000..1d5481d0 --- /dev/null +++ b/excel/Convex.Excel.Tests/CxParseTests.cs @@ -0,0 +1,245 @@ +using System; +using System.Globalization; +using System.Threading; +using Convex.Excel; +using ExcelDna.Integration; +using Xunit; + +namespace Convex.Excel.Tests +{ + public class StrictDoublesTests + { + [Fact] + public void Parses_2d_grid_of_numbers_and_numeric_strings() + { + var grid = new object[,] { { 1.0, "2.5" }, { 3.0, 4.0 } }; + Assert.Equal(new[] { 1.0, 2.5, 3.0, 4.0 }, CxParse.AsDoublesStrict(grid, "t")); + } + + [Fact] + public void Trailing_blanks_are_trimmed() + { + var grid = new object[,] { { 1.0 }, { 2.0 }, { ExcelEmpty.Value }, { ExcelEmpty.Value } }; + Assert.Equal(new[] { 1.0, 2.0 }, CxParse.AsDoublesStrict(grid, "t")); + } + + [Fact] + public void Interior_blank_throws_with_position() + { + var grid = new object[,] { { 1.0 }, { ExcelEmpty.Value }, { 3.0 } }; + var ex = Assert.Throws(() => CxParse.AsDoublesStrict(grid, "rates")); + Assert.Contains("rates", ex.Message); + Assert.Contains("row 2", ex.Message); + } + + [Fact] + public void Unparseable_cell_throws_instead_of_being_skipped() + { + var grid = new object[,] { { 1.0 }, { "oops" }, { 3.0 } }; + var ex = Assert.Throws(() => CxParse.AsDoublesStrict(grid, "rates")); + Assert.Contains("oops", ex.Message); + Assert.Contains("row 2", ex.Message); + } + + [Fact] + public void Scalar_double_yields_single_element() + { + Assert.Equal(new[] { 5.0 }, CxParse.AsDoublesStrict(5.0, "t")); + } + + [Fact] + public void Blank_range_yields_empty() + { + Assert.Empty(CxParse.AsDoublesStrict(ExcelMissing.Value, "t")); + } + } + + public class StrictDatesTests : IDisposable + { + private readonly CultureInfo _saved = Thread.CurrentThread.CurrentCulture; + + public void Dispose() => Thread.CurrentThread.CurrentCulture = _saved; + + [Fact] + public void Oadate_and_datetime_cells_pass_through() + { + var date = new DateTime(2030, 6, 15); + var grid = new object[,] { { date.ToOADate() }, { date } }; + Assert.Equal(new[] { date, date }, CxParse.AsDatesStrict(grid, "d")); + } + + [Fact] + public void Iso_strings_parse_regardless_of_machine_culture() + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("en-GB"); // dd/MM + var got = CxParse.AsDatesStrict(new object[,] { { "2025-04-03" } }, "d"); + Assert.Equal(new DateTime(2025, 4, 3), got[0]); + } + + [Fact] + public void Slash_dates_use_invariant_not_machine_culture() + { + // Under en-GB the old code read 03/04/2025 as 3 April; invariant + // reads it as 4 March. Culture must not change the result. + Thread.CurrentThread.CurrentCulture = new CultureInfo("en-GB"); + var got = CxParse.AsDatesStrict(new object[,] { { "03/04/2025" } }, "d"); + Assert.Equal(new DateTime(2025, 3, 4), got[0]); + } + + [Fact] + public void Unparseable_date_throws() + { + var ex = Assert.Throws( + () => CxParse.AsDatesStrict(new object[,] { { "not-a-date" } }, "call dates")); + Assert.Contains("call dates", ex.Message); + } + } + + public class StrictStringsTests + { + [Fact] + public void Strings_are_trimmed_and_extracted() + { + var grid = new object[,] { { " swap " }, { "ois" } }; + Assert.Equal(new[] { "swap", "ois" }, CxParse.AsStringsStrict(grid, "kinds")); + } + + [Fact] + public void Interior_blank_throws() + { + var grid = new object[,] { { "swap" }, { "" }, { "ois" } }; + Assert.Throws(() => CxParse.AsStringsStrict(grid, "kinds")); + } + } + + public class TokenParsingTests + { + [Theory] + [InlineData("SA", "SemiAnnual")] + [InlineData("a", "Annual")] + [InlineData("Q", "Quarterly")] + [InlineData("monthly", "Monthly")] + public void Frequency_tokens_map(string token, string expected) => + Assert.Equal(expected, CxParse.AsFrequency(token)); + + [Theory] + [InlineData(1.0, "Annual")] + [InlineData(2.0, "SemiAnnual")] + [InlineData(4.0, "Quarterly")] + [InlineData(12.0, "Monthly")] + public void Frequency_numerics_map(double n, string expected) => + Assert.Equal(expected, CxParse.AsFrequency(n)); + + [Fact] + public void Unknown_numeric_frequency_throws_unknown_token() + { + var ex = Assert.Throws(() => CxParse.AsFrequency(3.0)); + Assert.Equal(ErrorCodes.UnknownToken, ex.Code); + } + + [Fact] + public void Unknown_string_frequency_throws_unknown_token() + { + var ex = Assert.Throws(() => CxParse.AsFrequency("X")); + Assert.Equal(ErrorCodes.UnknownToken, ex.Code); + } + + [Theory] + [InlineData("Z", "ZSpread")] + [InlineData("asw", "AssetSwapPar")] + [InlineData("OAS", "OAS")] + [InlineData("dm", "DiscountMargin")] + public void Spread_types_map(string token, string expected) => + Assert.Equal(expected, CxParse.AsSpreadType(token)); + + [Fact] + public void Unknown_spread_type_throws_unknown_token() + { + var ex = Assert.Throws(() => CxParse.AsSpreadType("W")); + Assert.Equal(ErrorCodes.UnknownToken, ex.Code); + } + + [Fact] + public void Handle_string_and_numeric_forms_parse() + { + Assert.Equal(101UL, CxParse.AsHandle("#CX#101")); + Assert.Equal(101UL, CxParse.AsHandle(101.0)); + } + + [Fact] + public void Fractional_handle_throws() + { + Assert.Throws(() => CxParse.AsHandle(101.5)); + } + + [Fact] + public void Handle_refs_pass_numbers_and_handle_strings_as_numbers() + { + Assert.Equal(101UL, CxParse.AsHandleRef("#CX#101").ToObject()); + Assert.Equal(101UL, CxParse.AsHandleRef(101.0).ToObject()); + } + + [Fact] + public void Handle_refs_pass_tickers_through_as_strings() + { + Assert.Equal("912828YK0", CxParse.AsHandleRef(" 912828YK0 ").ToObject()); + Assert.Equal("USD.SOFR", CxParse.AsHandleRef("USD.SOFR").ToObject()); + // All-digit CUSIPs are names, not handles — handles only reach + // cells as numbers or "#CX#N" strings. + Assert.Equal("037833100", CxParse.AsHandleRef("037833100").ToObject()); + } + + [Fact] + public void Handle_ref_or_null_treats_blanks_as_null() + { + Assert.Null(CxParse.AsHandleRefOrNull(ExcelMissing.Value)); + Assert.Null(CxParse.AsHandleRefOrNull("")); + Assert.NotNull(CxParse.AsHandleRefOrNull("USD.SOFR")); + } + } + + public class ComboLabelTests + { + [Fact] + public void NameOf_returns_full_name_even_when_it_contains_the_separator() + { + Assert.Equal("T 5s35", Helpers.ComboReload.NameOf("#CX#101 · fixed_rate · T 5s35")); + Assert.Equal("A · B", Helpers.ComboReload.NameOf("#CX#101 · curve · A · B")); + Assert.Null(Helpers.ComboReload.NameOf("#CX#101 · fixed_rate")); + } + + [Fact] + public void TokenOf_returns_leading_handle_token() + { + Assert.Equal("#CX#101", Helpers.ComboReload.TokenOf("#CX#101 · fixed_rate · T 5s35")); + Assert.Null(Helpers.ComboReload.TokenOf(null)); + } + } + + public class ErrorMapperTests + { + [Theory] + [InlineData(ErrorCodes.InvalidInput, ExcelError.ExcelErrorValue)] + [InlineData(ErrorCodes.InvalidHandle, ExcelError.ExcelErrorRef)] + [InlineData(ErrorCodes.Analytics, ExcelError.ExcelErrorNum)] + [InlineData(ErrorCodes.UnknownToken, ExcelError.ExcelErrorName)] + [InlineData(ErrorCodes.Panic, ExcelError.ExcelErrorValue)] + [InlineData("serialize", ExcelError.ExcelErrorValue)] + public void Convex_exception_codes_map_to_excel_errors(string code, ExcelError expected) => + Assert.Equal(expected, ErrorMapper.ToExcelError(new ConvexException("m", code))); + + [Fact] + public void Native_load_failures_map_to_na() + { + Assert.Equal(ExcelError.ExcelErrorNA, ErrorMapper.ToExcelError(new DllNotFoundException())); + Assert.Equal(ExcelError.ExcelErrorNA, ErrorMapper.ToExcelError(new EntryPointNotFoundException())); + Assert.Equal(ExcelError.ExcelErrorNA, ErrorMapper.ToExcelError(new BadImageFormatException())); + } + + [Fact] + public void Unclassified_exceptions_map_to_value() + { + Assert.Equal(ExcelError.ExcelErrorValue, ErrorMapper.ToExcelError(new InvalidOperationException())); + } + } +} diff --git a/excel/Convex.Excel/Convex.Excel.csproj b/excel/Convex.Excel/Convex.Excel.csproj index b19235e4..2e7ae785 100644 --- a/excel/Convex.Excel/Convex.Excel.csproj +++ b/excel/Convex.Excel/Convex.Excel.csproj @@ -4,6 +4,7 @@ x64 11.0 enable + true Convex.Excel Convex.Excel false @@ -11,12 +12,14 @@ $(NuGetPackageRoot)exceldna.addin\1.7.0\tools\ExcelDnaPack.exe - ..\..\target\release\convex_ffi.dll + + ..\..\target\excel\convex_ffi.dll + @@ -33,8 +36,17 @@ - + + + <_Parameter1>Convex.Excel.Tests + + + + + diff --git a/excel/Convex.Excel/Convex.Excel.dna b/excel/Convex.Excel/Convex.Excel.dna index 03f7b28f..c969698a 100644 --- a/excel/Convex.Excel/Convex.Excel.dna +++ b/excel/Convex.Excel/Convex.Excel.dna @@ -6,7 +6,12 @@ DLL with no transitive dependencies. Packed into the .xll. --> - + + + + + @@ -27,8 +32,11 @@