From 591b1704d800779118b18175002c4a62a2eb6a7d Mon Sep 17 00:00:00 2001 From: sujitn Date: Fri, 29 May 2026 21:49:27 +0100 Subject: [PATCH 1/5] feat(ffi): hedge-advisor RPCs + concurrency and handle-lifecycle fixes - Add convex_risk_profile / convex_hedge / convex_compare wrapping the risk/hedging module (compute_position_risk, the strategy builders, compare_hedges, narrate). RiskProfile round-trips back into the hedge and compare calls, so no server-side session is needed. - registry: clone the object's Arc out under the read lock and run the caller's closure after releasing it. Holding the lock across analytics nested a second read lock (e.g. KRD re-fetching the curve) and could deadlock under concurrent access. - registry: add an optional registry_key to BondIdentifier and the curve specs; when set it is the eviction slot (preferred over cusip/isin/name), letting a host scope objects per owning cell instead of per security. - Generate the C ABI header via cbindgen (build.rs + cbindgen.toml). - Smoke tests for the advisor flow and per-key eviction. --- crates/convex-analytics/src/dto.rs | 116 ++++++++++++++- crates/convex-ffi/build.rs | 39 +++++ crates/convex-ffi/cbindgen.toml | 26 ++++ crates/convex-ffi/src/build.rs | 11 +- crates/convex-ffi/src/dispatch.rs | 153 +++++++++++++++++++- crates/convex-ffi/src/lib.rs | 46 +++++- crates/convex-ffi/src/registry.rs | 25 ++-- crates/convex-ffi/src/schemas.rs | 51 +++++++ crates/convex-ffi/tests/smoke.rs | 222 ++++++++++++++++++++++++++++- 9 files changed, 665 insertions(+), 24 deletions(-) create mode 100644 crates/convex-ffi/build.rs create mode 100644 crates/convex-ffi/cbindgen.toml diff --git a/crates/convex-analytics/src/dto.rs b/crates/convex-analytics/src/dto.rs index e6d148d2..616ddd9e 100644 --- a/crates/convex-analytics/src/dto.rs +++ b/crates/convex-analytics/src/dto.rs @@ -30,13 +30,18 @@ pub enum BondSpec { SinkingFund(SinkingFundSpec), } -/// Any one of CUSIP, ISIN, or free-form name suffices. The first non-empty -/// field is used as the registry key. +/// Any one of CUSIP, ISIN, or free-form name identifies the security. When +/// `registry_key` is set it (not the identifier) becomes the registry's +/// eviction slot — a host like Excel passes its owning cell so two cells that +/// reference the same CUSIP don't evict each other. Falls back to +/// cusip → isin → name when absent. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BondIdentifier { pub cusip: Option, pub isin: Option, pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry_key: Option, } /// Fields shared across coupon-bearing bond shapes. @@ -241,6 +246,9 @@ pub enum CurveSpec { pub struct DiscreteCurveSpec { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, + /// Registry eviction slot (e.g. owning cell). Falls back to `name`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry_key: Option, pub ref_date: Date, pub tenors: Vec, pub values: Vec, @@ -298,6 +306,9 @@ pub enum CurveInstrument { pub struct BootstrapSpec { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, + /// Registry eviction slot (e.g. owning cell). Falls back to `name`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry_key: Option, pub ref_date: Date, #[serde(default)] pub method: BootstrapMethod, @@ -511,6 +522,107 @@ pub struct CurveQueryResponse { pub value: f64, } +// ---- Risk profile / hedge advisor ----------------------------------------- +// +// `convex_risk_profile` returns a `RiskProfile`; being a serializable value it +// round-trips back into `convex_hedge` / `convex_compare` with no server-side +// handle. The `crate::risk` types already derive Serialize/Deserialize, so they +// are reused on the wire directly rather than mirrored. + +use crate::risk::{BondFuture, ComparisonReport, Constraints, HedgeProposal, RiskProfile}; + +fn default_curve_id() -> String { + "discount".to_string() +} + +/// Build a position `RiskProfile` from a registered bond + discount curve. +/// +/// Fixed-coupon bonds (fixed-rate, sinking-fund, and the bullet leg of a +/// callable) use the YTM-driven path; a callable additionally requires +/// `volatility` and is routed through the OAS-aware risk path. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RiskProfileRequest { + pub bond: Handle, + pub settlement: Date, + pub mark: MarkInput, + /// Position face amount (e.g. 10000000 for $10mm). Per-100 metrics are + /// scaled to market value by the analytics layer. + pub notional_face: Decimal, + /// Discount curve handle (required). + pub curve: Handle, + /// Stable curve identifier recorded in `RiskProfile::provenance`. + #[serde(default = "default_curve_id")] + pub curve_id: String, + /// Compounding for the derived YTM. Defaults to the bond's frequency. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quote_frequency: Option, + /// KRD ladder in years. Empty → advisor default (`ADVISOR_KEY_RATE_TENORS`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub key_rate_tenors: Vec, + /// Optional position label echoed back on the profile. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position_id: Option, + /// Short-rate volatility (decimal). Required for callable positions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub volatility: Option, +} + +/// Which hedge construction to run. Each maps to a `crate::risk` strategy fn. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HedgeStrategyKind { + /// Single liquid future sized to neutralise position DV01. + DurationFutures, + /// Two futures (front/back tenor) matching level + slope. + BarbellFutures, + /// On-the-run cash sovereign offset. + CashBondPair, + /// Vanilla single-currency interest-rate swap. + InterestRateSwap, + /// Futures ladder matching the full key-rate bucket vector. + KeyRateFutures, +} + +/// Propose a hedge for a previously-computed `RiskProfile`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HedgeRequest { + pub strategy: HedgeStrategyKind, + /// The position to hedge — round-tripped from `convex_risk_profile`. + pub position: RiskProfile, + #[serde(default)] + pub constraints: Constraints, + /// Discount curve handle (used for instrument risk + carry). + pub curve: Handle, + #[serde(default = "default_curve_id")] + pub curve_id: String, + pub settlement: Date, + /// Futures basket overrides for futures strategies. Empty → strategy + /// default (a synthetic at-current-coupon deliverable). Ignored by the + /// cash-bond and swap strategies. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub basket_overrides: Vec, +} + +/// Compare proposals against a position, optionally with a text narrative. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompareRequest { + pub position: RiskProfile, + pub proposals: Vec, + #[serde(default)] + pub constraints: Constraints, + /// When true, include the deterministic narrative in the response. + #[serde(default)] + pub narrate: bool, +} + +/// Comparison report plus an optional human-readable narrative. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompareResponse { + pub report: ComparisonReport, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub narrative: Option, +} + // ---- Top-level envelopes -------------------------------------------------- /// FFI response envelope. Carried as `{ "ok": "true", "result": … }` on diff --git a/crates/convex-ffi/build.rs b/crates/convex-ffi/build.rs new file mode 100644 index 00000000..ff9d068e --- /dev/null +++ b/crates/convex-ffi/build.rs @@ -0,0 +1,39 @@ +//! Generate the C header for the FFI surface via cbindgen. +//! +//! The header is written to `$OUT_DIR/convex.h` on every build (no source-tree +//! churn, no rebuild loop). C consumers can pick it up from the build output; +//! the committed reference copy under `bindings/java/native-headers/convex.h` +//! is refreshed manually with the `cbindgen` CLI (see `cbindgen.toml`). +//! +//! The Java binding does not consume this header — Panama FFM binds to the +//! cdylib by symbol name — but generating it keeps the documented C ABI in +//! lockstep with the `#[no_mangle]` surface and supports other C callers. + +use std::env; +use std::path::PathBuf; + +fn main() { + let crate_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); + let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); + let config = cbindgen::Config::from_root_or_default(&crate_dir); + + // Only the lib's own source defines the ABI; re-run when it changes. + println!("cargo:rerun-if-changed=src/lib.rs"); + println!("cargo:rerun-if-changed=cbindgen.toml"); + + match cbindgen::Builder::new() + .with_crate(&crate_dir) + .with_config(config) + .generate() + { + Ok(bindings) => { + let header = PathBuf::from(&out_dir).join("convex.h"); + bindings.write_to_file(&header); + } + // A header-generation failure must not break the cdylib build that the + // Java binding actually depends on; surface it as a warning instead. + Err(e) => { + println!("cargo:warning=cbindgen header generation skipped: {e}"); + } + } +} diff --git a/crates/convex-ffi/cbindgen.toml b/crates/convex-ffi/cbindgen.toml new file mode 100644 index 00000000..14fec893 --- /dev/null +++ b/crates/convex-ffi/cbindgen.toml @@ -0,0 +1,26 @@ +# cbindgen configuration for the Convex C ABI. +# +# The generated header documents the C surface for non-JVM consumers (the Java +# binding uses Panama FFM and binds by symbol name, so it does not need this +# header). Regenerate the committed reference copy with: +# +# cbindgen --config crates/convex-ffi/cbindgen.toml \ +# --crate convex-ffi \ +# --output bindings/java/native-headers/convex.h + +language = "C" +header = "/* Convex C ABI — generated by cbindgen. Do not edit by hand. */" +include_guard = "CONVEX_FFI_H" +tab_width = 4 +documentation = true +documentation_style = "doxy" +cpp_compat = true + +[export] +prefix = "" + +[parse] +parse_deps = false + +[fn] +sort_by = "None" diff --git a/crates/convex-ffi/src/build.rs b/crates/convex-ffi/src/build.rs index 5e27e506..25d23924 100644 --- a/crates/convex-ffi/src/build.rs +++ b/crates/convex-ffi/src/build.rs @@ -62,8 +62,11 @@ pub fn curve_from_json(json: &str) -> Handle { // ---- Bond builders -------------------------------------------------------- fn name_from_id(id: &BondIdentifier) -> Option { - id.cusip + // `registry_key` (e.g. an Excel cell ref) wins so the eviction slot is + // scoped to the owning cell, not the shared security identifier. + id.registry_key .clone() + .or_else(|| id.cusip.clone()) .or_else(|| id.isin.clone()) .or_else(|| id.name.clone()) } @@ -366,7 +369,8 @@ fn build_discrete_curve(spec: DiscreteCurveSpec) -> Handle { return INVALID_HANDLE; } }; - registry::register(RateCurve::new(curve), ObjectKind::Curve, spec.name) + let reg_name = spec.registry_key.or(spec.name); + registry::register(RateCurve::new(curve), ObjectKind::Curve, reg_name) } fn build_bootstrap_curve(spec: convex_analytics::dto::BootstrapSpec) -> Handle { @@ -376,6 +380,7 @@ fn build_bootstrap_curve(spec: convex_analytics::dto::BootstrapSpec) -> Handle { 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 mut set = InstrumentSet::new(); for inst in &spec.instruments { @@ -419,7 +424,7 @@ fn build_bootstrap_curve(spec: convex_analytics::dto::BootstrapSpec) -> Handle { 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, spec.name) + registry::register(RateCurve::new(result.curve), ObjectKind::Curve, reg_name) } Err(e) => { set_last_error(format!("GlobalFit error: {e}")); diff --git a/crates/convex-ffi/src/dispatch.rs b/crates/convex-ffi/src/dispatch.rs index c59e6762..c68d2620 100644 --- a/crates/convex-ffi/src/dispatch.rs +++ b/crates/convex-ffi/src/dispatch.rs @@ -15,11 +15,17 @@ use std::sync::Arc; use convex_analytics::dto::{ - CashflowEntry, CashflowRequest, CashflowResponse, CurveQueryKind, CurveQueryRequest, - CurveQueryResponse, KeyRate, MakeWholeRequest, MakeWholeResponse, MarkInput, PricingRequest, - PricingResponse, RiskRequest, RiskResponse, SpreadRequest, SpreadResponse, + CashflowEntry, CashflowRequest, CashflowResponse, CompareRequest, CompareResponse, + CurveQueryKind, CurveQueryRequest, CurveQueryResponse, HedgeRequest, HedgeStrategyKind, KeyRate, + MakeWholeRequest, MakeWholeResponse, MarkInput, PricingRequest, PricingResponse, + RiskProfileRequest, RiskRequest, RiskResponse, SpreadRequest, SpreadResponse, }; use convex_analytics::pricing::price_from_mark; +use convex_analytics::risk::{ + barbell_futures, cash_bond_pair, compare_hedges, compute_callable_position_risk, + compute_position_risk, duration_futures, interest_rate_swap, key_rate_futures, narrate, + HedgeProposal, RiskProfile, +}; use convex_analytics::spreads::{ DiscountMarginCalculator, GSpreadCalculator, ISpreadCalculator, OASCalculator, ParParAssetSwap, ProceedsAssetSwap, ZSpreadCalculator, @@ -765,6 +771,147 @@ fn make_whole_inner(request_json: &str) -> Result String { + to_envelope(risk_profile_inner(request_json)) +} + +fn risk_profile_inner(request_json: &str) -> Result { + let req: RiskProfileRequest = serde_json::from_str(request_json) + .map_err(|e| DispatchError::input(format!("RiskProfileRequest: {e}")))?; + let mark = parse_mark(&req.mark)?; + let curve = clone_typed_curve(req.curve)?; + let tenors: Option<&[f64]> = if req.key_rate_tenors.is_empty() { + None + } else { + Some(&req.key_rate_tenors) + }; + + match registry::kind_of(req.bond) { + Some(ObjectKind::Bond(BondKind::Callable)) => { + let vol = req.volatility.ok_or_else(|| { + DispatchError::input_field( + "volatility", + "callable position risk requires short-rate volatility (decimal)", + ) + })?; + with_callable(req.bond, |cb| { + compute_callable_position_risk( + cb, + req.settlement, + &mark, + req.notional_face, + &curve, + &req.curve_id, + req.quote_frequency, + tenors, + req.position_id.clone(), + vol, + ) + .map_err(DispatchError::from) + })? + } + Some(ObjectKind::Bond(BondKind::FloatingRate)) => Err(DispatchError::input( + "position risk is not defined for FRNs (no YTM); use convex_risk for DM-space metrics", + )), + Some(ObjectKind::Bond(BondKind::ZeroCoupon)) => Err(DispatchError::input( + "position risk is not yet wired for zero-coupon bonds", + )), + // FixedRate + SinkingFund (bullet leg). Curve/None handled by the macro. + _ => with_fixed_bond!(req.bond, bond, { + compute_position_risk( + bond, + req.settlement, + &mark, + req.notional_face, + &curve, + &req.curve_id, + req.quote_frequency, + tenors, + req.position_id.clone(), + ) + .map_err(DispatchError::from) + }), + } +} + +/// Propose a hedge for a position profile. Response is a `HedgeProposal`. +pub fn hedge(request_json: &str) -> String { + to_envelope(hedge_inner(request_json)) +} + +fn hedge_inner(request_json: &str) -> Result { + let req: HedgeRequest = serde_json::from_str(request_json) + .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. + let proposal = match req.strategy { + HedgeStrategyKind::DurationFutures => duration_futures( + &req.position, + &req.constraints, + &curve, + &req.curve_id, + req.settlement, + &req.basket_overrides, + None, + ), + HedgeStrategyKind::BarbellFutures => barbell_futures( + &req.position, + &req.constraints, + &curve, + &req.curve_id, + req.settlement, + &req.basket_overrides, + None, + ), + HedgeStrategyKind::KeyRateFutures => key_rate_futures( + &req.position, + &req.constraints, + &curve, + &req.curve_id, + req.settlement, + &req.basket_overrides, + None, + ), + HedgeStrategyKind::CashBondPair => cash_bond_pair( + &req.position, + &req.constraints, + &curve, + &req.curve_id, + req.settlement, + None, + ), + HedgeStrategyKind::InterestRateSwap => interest_rate_swap( + &req.position, + &req.constraints, + &curve, + &req.curve_id, + req.settlement, + None, + ), + }?; + Ok(proposal) +} + +/// Compare proposals against a position. Response is a `CompareResponse`. +pub fn compare(request_json: &str) -> String { + to_envelope(compare_inner(request_json)) +} + +fn compare_inner(request_json: &str) -> Result { + let req: CompareRequest = serde_json::from_str(request_json) + .map_err(|e| DispatchError::input(format!("CompareRequest: {e}")))?; + let report = compare_hedges(&req.position, &req.proposals, &req.constraints)?; + let narrative = if req.narrate { + Some(narrate(&report)) + } else { + None + }; + Ok(CompareResponse { report, narrative }) +} + // ---- curve_query -------------------------------------------------------- pub fn curve_query(request_json: &str) -> String { diff --git a/crates/convex-ffi/src/lib.rs b/crates/convex-ffi/src/lib.rs index 22492724..9c0fe7c8 100644 --- a/crates/convex-ffi/src/lib.rs +++ b/crates/convex-ffi/src/lib.rs @@ -1,13 +1,17 @@ //! # Convex FFI — JSON-RPC boundary //! -//! Twelve C functions cover the entire surface. Five are stateful (build, -//! describe, release, count, clear). Five are stateless analytics RPCs that -//! consume one JSON request and return one JSON response. Two are utilities -//! (schema introspection, mark text parser). +//! A small set of C functions covers the entire surface. Some are stateful +//! (build, describe, release, count, clear). The rest are stateless analytics +//! RPCs that consume one JSON request and return one JSON response — pricing, +//! risk, spreads, cashflows, curve queries, make-whole, and the hedge advisor +//! (risk profile, propose, compare). Two are utilities (schema introspection, +//! mark text parser). //! -//! Adding a new bond shape, a new spread family, or a new analytic does not -//! add a new C symbol. It is a serde enum variant in `convex_analytics::dto` -//! plus a dispatch arm in `dispatch`. +//! Adding a new bond shape, a new spread family, or a new analytic on an +//! existing request shape does not add a new C symbol — it is a serde enum +//! variant in `convex_analytics::dto` plus a dispatch arm in `dispatch`. A +//! genuinely new request/response verb (like the advisor) adds one thin +//! `#[no_mangle]` symbol that delegates to a `dispatch` handler. //! //! ## Memory model //! @@ -177,6 +181,34 @@ pub unsafe extern "C" fn convex_make_whole(request_json: *const c_char) -> *mut rpc(request_json, dispatch::make_whole) } +// ============================================================================ +// Hedge advisor (JSON in, JSON out) +// ============================================================================ + +/// Build a position risk profile. Request: `RiskProfileRequest`. Response: +/// `RiskProfile` (modified/Macaulay duration, convexity, DV01, key-rate +/// buckets, market value, provenance). The profile round-trips back into +/// [`convex_hedge`] and [`convex_compare`]. +#[no_mangle] +pub unsafe extern "C" fn convex_risk_profile(request_json: *const c_char) -> *mut c_char { + rpc(request_json, dispatch::risk_profile) +} + +/// Propose a hedge for a position profile. Request: `HedgeRequest` (carries a +/// `strategy` tag + the `RiskProfile` from [`convex_risk_profile`]). Response: +/// `HedgeProposal` (trades, residual risk, cost bps, tradeoff notes). +#[no_mangle] +pub unsafe extern "C" fn convex_hedge(request_json: *const c_char) -> *mut c_char { + rpc(request_json, dispatch::hedge) +} + +/// Compare hedge proposals against a position. Request: `CompareRequest`. +/// Response: `CompareResponse` (`ComparisonReport` + optional narrative). +#[no_mangle] +pub unsafe extern "C" fn convex_compare(request_json: *const c_char) -> *mut c_char { + rpc(request_json, dispatch::compare) +} + // ============================================================================ // Introspection // ============================================================================ diff --git a/crates/convex-ffi/src/registry.rs b/crates/convex-ffi/src/registry.rs index 25beb5e1..817e3ccd 100644 --- a/crates/convex-ffi/src/registry.rs +++ b/crates/convex-ffi/src/registry.rs @@ -7,7 +7,7 @@ use std::any::Any; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::RwLock; +use std::sync::{Arc, RwLock}; use once_cell::sync::Lazy; @@ -60,7 +60,11 @@ impl ObjectKind { struct Entry { kind: ObjectKind, name: Option, - object: Box, + /// `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 + /// once a writer is queued. + object: Arc, } /// Single lock over both maps so name↔handle updates stay atomic. @@ -101,7 +105,7 @@ pub fn register(object: T, kind: ObjectKind, name: Option< Entry { kind, name: name.clone(), - object: Box::new(object), + object: Arc::new(object), }, ); if let Some(n) = name { @@ -112,12 +116,17 @@ pub fn register(object: T, kind: ObjectKind, name: Option< /// Run `f` against the typed object behind `handle`, returning `None` if /// the handle is unknown or the type doesn't match. +/// +/// The registry read lock is held only long enough to clone the object's +/// `Arc`; `f` runs after the lock is released. This is what makes nested +/// lookups (and concurrent multi-threaded analytics from the bindings) safe — +/// see [`Entry::object`]. pub fn with_object R>(handle: Handle, f: F) -> Option { - let t = REGISTRY.tables.read().unwrap(); - t.objects - .get(&handle) - .and_then(|e| e.object.downcast_ref::()) - .map(f) + let object = { + let t = REGISTRY.tables.read().unwrap(); + Arc::clone(&t.objects.get(&handle)?.object) + }; + object.downcast_ref::().map(f) } /// Returns the kind/tag of an object, or `None` if the handle is unknown. diff --git a/crates/convex-ffi/src/schemas.rs b/crates/convex-ffi/src/schemas.rs index eb86f9cc..4ce4ce55 100644 --- a/crates/convex-ffi/src/schemas.rs +++ b/crates/convex-ffi/src/schemas.rs @@ -21,6 +21,9 @@ pub fn lookup(name: &str) -> Result { "CurveQueryResponse" => CURVE_QUERY_RESPONSE, "MakeWholeRequest" => MAKE_WHOLE_REQUEST, "MakeWholeResponse" => MAKE_WHOLE_RESPONSE, + "RiskProfileRequest" => RISK_PROFILE_REQUEST, + "HedgeRequest" => HEDGE_REQUEST, + "CompareRequest" => COMPARE_REQUEST, other => return Err(format!("unknown schema name {other:?}")), }; Ok(body.to_string()) @@ -220,3 +223,51 @@ const MAKE_WHOLE_RESPONSE: &str = r##"{ "spread_bps": {"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).", + "type": "object", + "required": ["bond","settlement","mark","notional_face","curve"], + "properties": { + "bond": {"type": "integer"}, + "settlement": {"type": "string", "format": "date"}, + "mark": {"$ref": "#/definitions/Mark"}, + "notional_face": {"type": "number", "description": "Position face amount, e.g. 10000000"}, + "curve": {"type": "integer", "description": "Discount curve handle"}, + "curve_id": {"type": "string", "description": "Stable id recorded in provenance (default 'discount')"}, + "quote_frequency": {"$ref": "#/definitions/Frequency"}, + "key_rate_tenors": {"type": "array", "items": {"type": "number"}, "description": "KRD ladder (years); empty → advisor default [2,5,10,30]"}, + "position_id": {"type": ["string","null"]}, + "volatility": {"type": ["number","null"], "description": "Short-rate vol (decimal); required for callable positions"} + } +}"##; + +const HEDGE_REQUEST: &str = r##"{ + "title": "HedgeRequest", + "description": "Propose a hedge for a RiskProfile. Response is a HedgeProposal.", + "type": "object", + "required": ["strategy","position","curve","settlement"], + "properties": { + "strategy": {"enum": ["duration_futures","barbell_futures","cash_bond_pair","interest_rate_swap","key_rate_futures"]}, + "position": {"description": "RiskProfile from convex_risk_profile"}, + "constraints": {"type": "object", "description": "Constraints {max_residual_dv01?, max_cost_bps?, allowed_strategies?, max_residual_per_bucket?}"}, + "curve": {"type": "integer"}, + "curve_id": {"type": "string"}, + "settlement": {"type": "string", "format": "date"}, + "basket_overrides": {"type": "array", "items": {"type": "object"}, "description": "BondFuture[] for futures strategies; ignored by cash_bond_pair / interest_rate_swap"} + } +}"##; + +const COMPARE_REQUEST: &str = r##"{ + "title": "CompareRequest", + "description": "Compare proposals against a position. Response is {report: ComparisonReport, narrative?: string}.", + "type": "object", + "required": ["position","proposals"], + "properties": { + "position": {"description": "RiskProfile"}, + "proposals": {"type": "array", "items": {"type": "object"}, "description": "HedgeProposal[]"}, + "constraints": {"type": "object"}, + "narrate": {"type": "boolean", "description": "Include a deterministic text narrative (default false)"} + } +}"##; diff --git a/crates/convex-ffi/tests/smoke.rs b/crates/convex-ffi/tests/smoke.rs index 4a9ab12c..e822c0e6 100644 --- a/crates/convex-ffi/tests/smoke.rs +++ b/crates/convex-ffi/tests/smoke.rs @@ -5,9 +5,19 @@ //! with a textual mark, call `convex_price`, parse the envelope. use std::ffi::{CStr, CString}; +use std::sync::atomic::{AtomicU64, Ordering}; use serde_json::{json, Value}; +/// Per-build unique id. The FFI registry evicts any prior object sharing a +/// name (spreadsheet-cell semantics), so tests that reused a fixed CUSIP would +/// evict each other's handles when run in parallel. A unique id per build +/// keeps every test's handles independent. +static SEQ: AtomicU64 = AtomicU64::new(0); +fn uid(prefix: &str) -> String { + format!("{prefix}{:07}", SEQ.fetch_add(1, Ordering::SeqCst)) +} + unsafe fn rpc(f: unsafe extern "C" fn(*const i8) -> *mut i8, req: &str) -> Value { let req_c = CString::new(req).unwrap(); let resp_ptr = f(req_c.as_ptr()); @@ -35,7 +45,7 @@ unsafe fn build_handle(spec: Value) -> u64 { fn fixed_rate_5pct() -> Value { json!({ "type": "fixed_rate", - "cusip": "TEST10Y5", + "cusip": uid("FX"), "coupon_rate": 0.05, "frequency": "SemiAnnual", "maturity": "2035-01-15", @@ -632,3 +642,213 @@ fn mark_parse_round_trip() { assert_eq!(v["result"]["benchmark"], "USD.SOFR"); } } + +// ---- hedge advisor ------------------------------------------------------- + +/// Build a $10mm fixed-rate position and return its `RiskProfile` JSON. +unsafe fn build_position_profile() -> Value { + let bond = build_handle(fixed_rate_5pct()); + let curve = build_curve(flat_curve(0.04)); + let resp = rpc( + convex_ffi::convex_risk_profile, + &json!({ + "bond": bond, + "settlement": "2025-04-15", + "mark": "99.5C", + "notional_face": 10_000_000.0, + "curve": curve, + "curve_id": "USD.SOFR", + // Pin the advisor's 4-tenor ladder so KRD buckets are deterministic. + "key_rate_tenors": [2.0, 5.0, 10.0, 30.0] + }) + .to_string(), + ); + assert_eq!(resp["ok"], "true", "risk_profile: {resp}"); + resp["result"].clone() +} + +#[test] +fn risk_profile_has_dv01_and_buckets() { + unsafe { + let p = build_position_profile(); + let dv01 = p["dv01"].as_f64().unwrap(); + let md = p["modified_duration_years"].as_f64().unwrap(); + assert!(dv01.abs() > 0.0, "dv01 should be non-zero: {p}"); + assert!(md > 0.0 && md < 30.0, "modified duration = {md}"); + // Default KRD ladder is [2,5,10,30] → four buckets. + let buckets = p["key_rate_buckets"].as_array().unwrap(); + assert_eq!(buckets.len(), 4, "expected 4 default key-rate buckets: {p}"); + // The market value of a ~par $10mm position is on the order of $10mm. + let mv = p["market_value"].as_f64().unwrap(); + assert!(mv > 9_000_000.0 && mv < 11_000_000.0, "market_value = {mv}"); + } +} + +#[test] +fn duration_futures_proposes_a_hedge() { + unsafe { + let position = build_position_profile(); + let curve = build_curve(flat_curve(0.04)); + let resp = rpc( + convex_ffi::convex_hedge, + &json!({ + "strategy": "duration_futures", + "position": position, + "curve": curve, + "curve_id": "USD.SOFR", + "settlement": "2025-04-15" + }) + .to_string(), + ); + assert_eq!(resp["ok"], "true", "hedge: {resp}"); + let proposal = &resp["result"]; + let strategy = proposal["strategy"].as_str().unwrap(); + assert!( + strategy.to_lowercase().contains("duration"), + "strategy label = {strategy}" + ); + let trades = proposal["trades"].as_array().unwrap(); + assert!(!trades.is_empty(), "expected at least one trade: {proposal}"); + // A duration hedge should shrink |residual DV01| well below the + // position's own DV01. + let pos_dv01 = position["dv01"].as_f64().unwrap().abs(); + let residual = proposal["residual"]["residual_dv01"] + .as_f64() + .unwrap() + .abs(); + assert!( + residual < pos_dv01, + "residual {residual} should be < position dv01 {pos_dv01}" + ); + } +} + +#[test] +fn compare_ranks_proposals_and_narrates() { + unsafe { + let position = build_position_profile(); + let curve = build_curve(flat_curve(0.04)); + + // Two candidate strategies. + let mut proposals = Vec::new(); + for strategy in ["duration_futures", "interest_rate_swap"] { + let r = rpc( + convex_ffi::convex_hedge, + &json!({ + "strategy": strategy, + "position": position, + "curve": curve, + "curve_id": "USD.SOFR", + "settlement": "2025-04-15" + }) + .to_string(), + ); + assert_eq!(r["ok"], "true", "{strategy}: {r}"); + proposals.push(r["result"].clone()); + } + + let resp = rpc( + convex_ffi::convex_compare, + &json!({ + "position": position, + "proposals": proposals, + "narrate": true + }) + .to_string(), + ); + assert_eq!(resp["ok"], "true", "compare: {resp}"); + let result = &resp["result"]; + let rows = result["report"]["rows"].as_array().unwrap(); + assert_eq!(rows.len(), 2, "one row per proposal: {result}"); + // A recommendation must point at a valid row. + let idx = result["report"]["recommendation"]["row_index"] + .as_u64() + .unwrap(); + assert!((idx as usize) < rows.len()); + // Narrative requested → present and non-empty. + let narrative = result["narrative"].as_str().unwrap(); + assert!(!narrative.is_empty(), "narrative should be non-empty"); + } +} + +#[test] +fn callable_position_risk_requires_volatility() { + unsafe { + // A callable bond with no volatility → clear invalid_input error. + let callable = build_handle(json!({ + "type": "callable", + "cusip": uid("CALL"), + "coupon_rate": 0.05, + "frequency": "SemiAnnual", + "maturity": "2035-01-15", + "issue": "2025-01-15", + "day_count": "Thirty360US", + "currency": "USD", + "face_value": 100, + "call_schedule": [{"date": "2030-01-15", "price": 100.0}], + "call_style": "american" + })); + let curve = build_curve(flat_curve(0.04)); + let resp = rpc( + convex_ffi::convex_risk_profile, + &json!({ + "bond": callable, + "settlement": "2025-04-15", + "mark": "99.5C", + "notional_face": 10_000_000.0, + "curve": curve + }) + .to_string(), + ); + assert_eq!(resp["ok"], "false", "expected error: {resp}"); + assert_eq!(resp["error"]["code"], "invalid_input"); + assert_eq!(resp["error"]["field"], "volatility"); + } +} + +// Regression: two cells referencing the SAME security must not evict each +// other. `registry_key` (the owning cell) scopes the registry slot, so a +// shared CUSIP no longer aliases handles — the Excel "random data on recalc" +// bug. Parallel-safe: unique keys, no clear_all, no global-count assertions. +#[test] +fn registry_key_scopes_eviction_per_cell() { + unsafe { + let key_a = uid("cell"); + let key_b = uid("cell"); + let spec = |rk: &str| { + json!({ + "type": "fixed_rate", + "cusip": "DUP000000", // same security in both cells + "registry_key": rk, // distinct owning cell + "coupon_rate": 0.05, + "frequency": "SemiAnnual", + "maturity": "2035-01-15", + "issue": "2025-01-15", + "day_count": "Thirty360US", + "currency": "USD", + "face_value": 100 + }) + }; + let req = |h: u64| { + json!({"bond": h, "settlement": "2025-04-15", "mark": "99.5C"}).to_string() + }; + + let a = build_handle(spec(&key_a)); + let b = build_handle(spec(&key_b)); + assert_ne!(a, b); + + // Same CUSIP, different cells → neither evicted the other. + 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)); + 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" + ); + assert_eq!(rpc(convex_ffi::convex_price, &req(b))["ok"], "true", "B untouched"); + assert_eq!(rpc(convex_ffi::convex_price, &req(a2))["ok"], "true"); + } +} From 56c454f72a7c4689f10a0967927f2cfb5dad6ab7 Mon Sep 17 00:00:00 2001 From: sujitn Date: Fri, 29 May 2026 21:49:35 +0100 Subject: [PATCH 2/5] feat(java): JVM bindings over the FFI via Panama FFM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New single-module Maven project under bindings/java (io.github.sujitn:convex-java, JDK 22+). A typed API — builders, BigDecimal/LocalDate, immutable result records, AutoCloseable handles — over a hidden FFM + Jackson layer; the JSON-RPC boundary is not exposed. - Covers fixed-rate / callable / floating-rate / zero-coupon / sinking-fund bonds, curves, pricing, risk, spreads, cashflows, make-whole, and the hedge advisor. - NativeLoader extracts the platform cdylib bundled in the jar. - CI: java.yml (build cdylib + mvn verify on JDK 22) and java-release.yml (per-platform native matrix + signed deploy to Maven Central). --- .github/workflows/java-release.yml | 86 +++++++ .github/workflows/java.yml | 54 +++++ bindings/java/.gitignore | 7 + bindings/java/README.md | 131 ++++++++++ bindings/java/native-headers/convex.h | 163 +++++++++++++ bindings/java/pom.xml | 142 +++++++++++ .../java/src/main/java/com/convex/Bond.java | 46 ++++ .../src/main/java/com/convex/CallStyle.java | 19 ++ .../main/java/com/convex/CallableBond.java | 110 +++++++++ .../java/com/convex/ComparisonReport.java | 50 ++++ .../src/main/java/com/convex/Compounding.java | 22 ++ .../src/main/java/com/convex/Constraints.java | 64 +++++ .../java/src/main/java/com/convex/Convex.java | 31 +++ .../main/java/com/convex/ConvexAnalytics.java | 185 ++++++++++++++ .../main/java/com/convex/ConvexException.java | 46 ++++ .../src/main/java/com/convex/Currency.java | 11 + .../src/main/java/com/convex/DayCount.java | 24 ++ .../main/java/com/convex/FixedRateBond.java | 52 ++++ .../java/com/convex/FloatingRateNote.java | 78 ++++++ .../src/main/java/com/convex/Frequency.java | 21 ++ .../main/java/com/convex/HedgeAdvisor.java | 146 +++++++++++ .../main/java/com/convex/HedgeProposal.java | 55 +++++ .../main/java/com/convex/Interpolation.java | 19 ++ .../java/src/main/java/com/convex/Mark.java | 63 +++++ .../src/main/java/com/convex/RateIndex.java | 24 ++ .../src/main/java/com/convex/RiskProfile.java | 59 +++++ .../main/java/com/convex/SinkingFundBond.java | 86 +++++++ .../java/src/main/java/com/convex/Specs.java | 54 +++++ .../src/main/java/com/convex/SpreadType.java | 23 ++ .../src/main/java/com/convex/YieldCurve.java | 120 ++++++++++ .../main/java/com/convex/ZeroCouponBond.java | 58 +++++ .../java/com/convex/internal/ConvexFfi.java | 226 ++++++++++++++++++ .../main/java/com/convex/internal/Json.java | 87 +++++++ .../com/convex/internal/NativeLoader.java | 92 +++++++ .../java/com/convex/internal/NativeRef.java | 21 ++ .../java/src/main/resources/native/README.md | 24 ++ .../src/test/java/com/convex/SmokeTest.java | 138 +++++++++++ 37 files changed, 2637 insertions(+) create mode 100644 .github/workflows/java-release.yml create mode 100644 .github/workflows/java.yml create mode 100644 bindings/java/.gitignore create mode 100644 bindings/java/README.md create mode 100644 bindings/java/native-headers/convex.h create mode 100644 bindings/java/pom.xml create mode 100644 bindings/java/src/main/java/com/convex/Bond.java create mode 100644 bindings/java/src/main/java/com/convex/CallStyle.java create mode 100644 bindings/java/src/main/java/com/convex/CallableBond.java create mode 100644 bindings/java/src/main/java/com/convex/ComparisonReport.java create mode 100644 bindings/java/src/main/java/com/convex/Compounding.java create mode 100644 bindings/java/src/main/java/com/convex/Constraints.java create mode 100644 bindings/java/src/main/java/com/convex/Convex.java create mode 100644 bindings/java/src/main/java/com/convex/ConvexAnalytics.java create mode 100644 bindings/java/src/main/java/com/convex/ConvexException.java create mode 100644 bindings/java/src/main/java/com/convex/Currency.java create mode 100644 bindings/java/src/main/java/com/convex/DayCount.java create mode 100644 bindings/java/src/main/java/com/convex/FixedRateBond.java create mode 100644 bindings/java/src/main/java/com/convex/FloatingRateNote.java create mode 100644 bindings/java/src/main/java/com/convex/Frequency.java create mode 100644 bindings/java/src/main/java/com/convex/HedgeAdvisor.java create mode 100644 bindings/java/src/main/java/com/convex/HedgeProposal.java create mode 100644 bindings/java/src/main/java/com/convex/Interpolation.java create mode 100644 bindings/java/src/main/java/com/convex/Mark.java create mode 100644 bindings/java/src/main/java/com/convex/RateIndex.java create mode 100644 bindings/java/src/main/java/com/convex/RiskProfile.java create mode 100644 bindings/java/src/main/java/com/convex/SinkingFundBond.java create mode 100644 bindings/java/src/main/java/com/convex/Specs.java create mode 100644 bindings/java/src/main/java/com/convex/SpreadType.java create mode 100644 bindings/java/src/main/java/com/convex/YieldCurve.java create mode 100644 bindings/java/src/main/java/com/convex/ZeroCouponBond.java create mode 100644 bindings/java/src/main/java/com/convex/internal/ConvexFfi.java create mode 100644 bindings/java/src/main/java/com/convex/internal/Json.java create mode 100644 bindings/java/src/main/java/com/convex/internal/NativeLoader.java create mode 100644 bindings/java/src/main/java/com/convex/internal/NativeRef.java create mode 100644 bindings/java/src/main/resources/native/README.md create mode 100644 bindings/java/src/test/java/com/convex/SmokeTest.java diff --git a/.github/workflows/java-release.yml b/.github/workflows/java-release.yml new file mode 100644 index 00000000..4c8cba75 --- /dev/null +++ b/.github/workflows/java-release.yml @@ -0,0 +1,86 @@ +name: Java release + +# Cut a release by pushing a tag, e.g.: git tag java-v0.13.0 && git push origin java-v0.13.0 +on: + push: + tags: + - 'java-v*' + +env: + CARGO_TERM_COLOR: always + +jobs: + # Build the convex-ffi cdylib for every platform classifier. + natives: + name: cdylib (${{ matrix.classifier }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: true + matrix: + include: + - { runner: ubuntu-latest, target: x86_64-unknown-linux-gnu, classifier: linux-x86_64, lib: libconvex_ffi.so } + - { runner: ubuntu-latest, target: aarch64-unknown-linux-gnu, classifier: linux-aarch64, lib: libconvex_ffi.so, cross: true } + - { runner: macos-latest, target: x86_64-apple-darwin, classifier: darwin-x86_64, lib: libconvex_ffi.dylib } + - { runner: macos-latest, target: aarch64-apple-darwin, classifier: darwin-aarch64, lib: libconvex_ffi.dylib } + - { runner: windows-latest, target: x86_64-pc-windows-msvc, classifier: windows-x86_64, lib: convex_ffi.dll } + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install aarch64 cross linker + if: matrix.cross + run: | + 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" + + - name: Build cdylib + run: cargo build -p convex-ffi --release --target ${{ matrix.target }} + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.classifier }} + path: target/${{ matrix.target }}/release/${{ matrix.lib }} + if-no-files-found: error + + # Stage every cdylib into the jar resources and publish to Maven Central. + deploy: + name: Publish to Maven Central + needs: natives + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Download native libraries + uses: actions/download-artifact@v4 + with: + path: staging + + - name: Stage natives into resources + # download-artifact lays out staging//; mirror that + # straight into the resources/native tree the loader reads. + run: | + mkdir -p bindings/java/src/main/resources/native + cp -r staging/* bindings/java/src/main/resources/native/ + + - name: Set up JDK 22 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '22' + cache: maven + server-id: central + server-username: MAVEN_CENTRAL_USERNAME + server-password: MAVEN_CENTRAL_PASSWORD + gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + + - name: Deploy + working-directory: bindings/java + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: mvn -B -Prelease deploy diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml new file mode 100644 index 00000000..7a623447 --- /dev/null +++ b/.github/workflows/java.yml @@ -0,0 +1,54 @@ +name: Java bindings + +on: + push: + branches: [main] + paths: + - 'bindings/java/**' + - 'crates/convex-ffi/**' + - 'crates/convex-analytics/**' + - '.github/workflows/java.yml' + pull_request: + branches: [main] + paths: + - 'bindings/java/**' + - 'crates/convex-ffi/**' + - 'crates/convex-analytics/**' + - '.github/workflows/java.yml' + +env: + CARGO_TERM_COLOR: always + +jobs: + # Linux x86_64 build + test. The release workflow extends this into a full + # OS/arch matrix and assembles every cdylib into the natives jar. + verify: + name: Build native + mvn verify (linux-x86_64) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Build convex-ffi cdylib + run: cargo build -p convex-ffi --release + + - 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/" + + - name: Set up JDK 22 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '22' + cache: maven + + - name: Maven verify + run: mvn -B -f bindings/java/pom.xml verify diff --git a/bindings/java/.gitignore b/bindings/java/.gitignore new file mode 100644 index 00000000..25939dc2 --- /dev/null +++ b/bindings/java/.gitignore @@ -0,0 +1,7 @@ +# Maven build output +target/ + +# Native libraries are built/staged by CI, not committed. +src/main/resources/native/**/*.so +src/main/resources/native/**/*.dylib +src/main/resources/native/**/*.dll diff --git a/bindings/java/README.md b/bindings/java/README.md new file mode 100644 index 00000000..2a5e00bb --- /dev/null +++ b/bindings/java/README.md @@ -0,0 +1,131 @@ +# Convex Java + +JVM bindings for the [Convex](../../README.md) fixed-income analytics library. + +Java users get the same analytics as the Rust API — bonds, curves, pricing, +risk, spreads, and the hedge advisor — through an idiomatic typed API +(builders, `BigDecimal`/`LocalDate`, immutable result records, `AutoCloseable` +handles). The JSON-RPC boundary to the native library is fully hidden. + +## How it works + +``` +com.convex ← typed public API (builders, ConvexAnalytics, HedgeAdvisor) +com.convex.internal ← Panama FFM binding + Jackson envelope codec (hidden) +crates/convex-ffi ← the native cdylib (C ABI), built from the Rust workspace +``` + +The binding calls the native library through Java's **Foreign Function & Memory +API** (Project Panama). There is **no hand-written JNI / C shim** — Java binds +directly to the cdylib's `char*`-in / `char*`-out C functions by symbol name. + +## Requirements + +- **JDK 22 or newer** (the FFM API is finalized in 22). +- At runtime, native access must be granted: + `--enable-native-access=ALL-UNNAMED` (the Maven build sets this for tests). +- The native `convex-ffi` cdylib for your platform, bundled under + `src/main/resources/native//` (see below). Versions track the Rust + workspace (currently `0.13.0`). + +## Building + +```sh +# 1. Build + stage the native library for your platform classifier +# (linux-x86_64, linux-aarch64, darwin-x86_64, darwin-aarch64, windows-x86_64) +cargo build -p convex-ffi --release +mkdir -p bindings/java/src/main/resources/native/linux-x86_64 +cp target/release/libconvex_ffi.so \ + bindings/java/src/main/resources/native/linux-x86_64/ + +# 2. Build + test the jar +mvn -f bindings/java/pom.xml verify +``` + +`NativeLoader` extracts the cdylib matching the running OS/arch from the jar at +startup, so one published artifact carries every platform. + +## Usage + +```java +import com.convex.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +var settle = LocalDate.of(2025, 4, 15); + +try (Bond bond = FixedRateBond.builder() + .cusip("037833100") + .couponRate(new BigDecimal("0.05")) + .frequency(Frequency.SEMI_ANNUAL) + .issue(LocalDate.of(2025, 1, 15)) + .maturity(LocalDate.of(2035, 1, 15)) + .dayCount(DayCount.THIRTY_360_US) + .currency(Currency.USD) + .build(); + YieldCurve curve = YieldCurve.discrete() + .referenceDate(LocalDate.of(2025, 1, 15)) + .point(2.0, 0.040).point(5.0, 0.042).point(10.0, 0.045) + .interpolation(Interpolation.MONOTONE_CONVEX) + .build()) { + + // Pricing & risk + var price = ConvexAnalytics.price(bond, settle, Mark.cleanPrice(new BigDecimal("99.5"))); + var risk = ConvexAnalytics.risk(bond, settle, Mark.cleanPrice(new BigDecimal("99.5")), curve, 2, 5, 10, 30); + System.out.printf("YTM %.4f, DV01 %.2f%n", price.ytmDecimal(), risk.dv01()); + + // Hedge advisor + RiskProfile pos = HedgeAdvisor.positionRisk() + .bond(bond).settlement(settle).mark(Mark.cleanPrice(new BigDecimal("99.5"))) + .notionalFace(new BigDecimal("10000000")).curve(curve).curveId("USD.SOFR") + .keyRateTenors(2, 5, 10, 30) + .compute(); + + HedgeProposal futures = HedgeAdvisor.durationFutures(pos, curve, settle); + HedgeProposal swap = HedgeAdvisor.swap(pos, curve, settle, Constraints.none()); + ComparisonReport report = HedgeAdvisor.compare(pos, List.of(futures, swap), Constraints.none(), true); + System.out.println(report.narrative().orElse("")); +} +``` + +## Thread-safety + +The native registry and analytics are safe for concurrent use, and the FFM +method handles are immutable, so the API may be called from multiple threads. +(The `convex-ffi` registry clones objects out under the lock before running +analytics, so concurrent calls never deadlock.) + +## Status / not yet wired + +The native FFI does not yet expose every Rust capability. Currently missing +(same DTO + dispatch pattern when added — see +`crates/convex-analytics/src/dto.rs`): + +- Convention/calendar introspection (`get_convention_options` analog) +- YAS settlement invoices, VaR, Hull-White calibration, piecewise bootstrap +- FRN projection / cap-floor analytics + +Packaging note: the binding ships as an automatic module (works on the +classpath). A `module-info.java` is intentionally omitted for now; named-module +consumers should grant `--enable-native-access` to the unnamed module. + +## Releasing to Maven Central + +Publishing is driven by the `release` Maven profile (sources + javadoc jars, +GPG signing, and the Sonatype Central Portal plugin) and the +`.github/workflows/java-release.yml` workflow, triggered by a `java-v*` tag. + +The workflow builds the cdylib for every platform classifier in a matrix, +stages them all under `src/main/resources/native/`, then runs +`mvn -Prelease deploy` so the single jar carries every platform. + +Required repository secrets: + +| Secret | Purpose | +|---|---| +| `MAVEN_CENTRAL_USERNAME` / `MAVEN_CENTRAL_PASSWORD` | Central Portal user token | +| `MAVEN_GPG_PRIVATE_KEY` | ASCII-armored signing key | +| `MAVEN_GPG_PASSPHRASE` | passphrase for that key | + +To cut a release: `git tag java-v0.13.0 && git push origin java-v0.13.0`. diff --git a/bindings/java/native-headers/convex.h b/bindings/java/native-headers/convex.h new file mode 100644 index 00000000..33510e70 --- /dev/null +++ b/bindings/java/native-headers/convex.h @@ -0,0 +1,163 @@ +/* Convex C ABI — generated by cbindgen. Do not edit by hand. */ + +#ifndef CONVEX_FFI_H +#define CONVEX_FFI_H + +#include +#include +#include +#include + +/** + * Opaque object handle. `0` is reserved as INVALID. + */ +typedef uint64_t Handle; + +/** + * Sentinel for "no handle". + */ +#define INVALID_HANDLE 0 + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +/** + * Free a string returned by any FFI function in this crate. + */ +void convex_string_free(char *s); + +/** + * Get the last error message set by a stateful constructor. + * + * The returned pointer is valid until the next call into this library on + * the same thread; do not free it. + */ +const char *convex_last_error(void); + +/** + * Clear the thread-local last-error slot. + */ +void convex_clear_error(void); + +/** + * Library version (`CARGO_PKG_VERSION`). Static; do not free. + */ +const char *convex_version(void); + +/** + * Build a bond from a `BondSpec` JSON. Returns `0` on failure. + */ +Handle convex_bond_from_json(const char *spec_json); + +/** + * Build a curve from a `CurveSpec` JSON. Returns `0` on failure. + */ +Handle convex_curve_from_json(const char *spec_json); + +/** + * Returns a JSON description of the registered object. + * + * Free the returned pointer with [`convex_string_free`]. + */ +char *convex_describe(Handle handle); + +/** + * Releases an object by handle. No-op on invalid handle. + */ +void convex_release(Handle handle); + +/** + * Number of registered objects. + */ +int32_t convex_object_count(void); + +/** + * Returns a JSON array of `{handle,kind,name?}` entries for every registered + * object. Caller frees with [`convex_string_free`]. + */ +char *convex_list_objects(void); + +/** + * Clears all registered objects. + */ +void convex_clear_all(void); + +/** + * Price a bond. Request: `PricingRequest`. Response: `PricingResponse`. + */ +char *convex_price(const char *request_json); + +/** + * Risk metrics. Request: `RiskRequest`. Response: `RiskResponse`. + */ +char *convex_risk(const char *request_json); + +/** + * Spread (Z/G/I/ASW/OAS/DM dispatch). Request: `SpreadRequest`. Response: + * `SpreadResponse`. + */ +char *convex_spread(const char *request_json); + +/** + * Bond cashflow schedule. Request: `CashflowRequest`. Response: `CashflowResponse`. + */ +char *convex_cashflows(const char *request_json); + +/** + * Curve point query. Request: `CurveQueryRequest`. Response: `CurveQueryResponse`. + */ +char *convex_curve_query(const char *request_json); + +/** + * Make-whole call price. Request: `MakeWholeRequest`. Response: `MakeWholeResponse`. + * + * Computes the make-whole call redemption price at a future call date for + * a callable bond carrying a make-whole spread. Discount uses the bond's + * own day count and frequency; treasury rate is supplied by the caller. + */ +char *convex_make_whole(const char *request_json); + +/** + * Build a position risk profile. Request: `RiskProfileRequest`. Response: + * `RiskProfile` (modified/Macaulay duration, convexity, DV01, key-rate + * buckets, market value, provenance). The profile round-trips back into + * [`convex_hedge`] and [`convex_compare`]. + */ +char *convex_risk_profile(const char *request_json); + +/** + * Propose a hedge for a position profile. Request: `HedgeRequest` (carries a + * `strategy` tag + the `RiskProfile` from [`convex_risk_profile`]). Response: + * `HedgeProposal` (trades, residual risk, cost bps, tradeoff notes). + */ +char *convex_hedge(const char *request_json); + +/** + * Compare hedge proposals against a position. Request: `CompareRequest`. + * Response: `CompareResponse` (`ComparisonReport` + optional narrative). + */ +char *convex_compare(const char *request_json); + +/** + * Returns the JSON schema for a named DTO type, or an error envelope. + * + * Type names: `Mark`, `BondSpec`, `CurveSpec`, `PricingRequest`, + * `PricingResponse`, `RiskRequest`, `RiskResponse`, `SpreadRequest`, + * `SpreadResponse`, `CashflowRequest`, `CashflowResponse`, + * `CurveQueryRequest`, `CurveQueryResponse`. + */ +char *convex_schema(const char *type_name); + +/** + * Parse a textual mark (e.g. `"99.5C"`, `"4.65%"`, `"+125bps@USD.SOFR"`). + * + * Returns a JSON envelope; on success, `result` is the canonical `Mark` JSON. + */ +char *convex_mark_parse(const char *text); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#endif /* CONVEX_FFI_H */ diff --git a/bindings/java/pom.xml b/bindings/java/pom.xml new file mode 100644 index 00000000..fddcf3a8 --- /dev/null +++ b/bindings/java/pom.xml @@ -0,0 +1,142 @@ + + + 4.0.0 + + + io.github.sujitn + convex-java + 0.13.0 + jar + + Convex Java + JVM bindings for the Convex fixed-income analytics library (Panama FFM over the convex-ffi native library). + https://github.com/sujitn/convex + + + + MIT License + https://opensource.org/licenses/MIT + + + + + + sujitn + Sujit Nair + https://github.com/sujitn + + + + + scm:git:https://github.com/sujitn/convex.git + scm:git:ssh://git@github.com/sujitn/convex.git + https://github.com/sujitn/convex + + + + 22 + UTF-8 + 2.18.2 + 5.11.3 + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + --enable-native-access=ALL-UNNAMED + + + + + + + + + release + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + jar-no-fork + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.11.2 + + none + + + + attach-javadocs + jar + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.7 + + + sign-artifacts + verify + sign + + + + --pinentry-mode + loopback + + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.7.0 + true + + central + true + published + + + + + + + diff --git a/bindings/java/src/main/java/com/convex/Bond.java b/bindings/java/src/main/java/com/convex/Bond.java new file mode 100644 index 00000000..05287233 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/Bond.java @@ -0,0 +1,46 @@ +package com.convex; + +import com.convex.internal.ConvexFfi; +import com.convex.internal.Json; +import com.convex.internal.NativeRef; + +import java.lang.ref.Cleaner; + +/** + * A bond instrument living in the native registry, referenced by an opaque + * handle. + * + *

Build one with a typed builder ({@link FixedRateBond}, {@link CallableBond}, + * …). It is {@link AutoCloseable} — use try-with-resources to release the native + * object promptly; a {@link Cleaner} is only a GC-time backstop. + */ +public final class Bond implements AutoCloseable { + + private final long handle; + private final Cleaner.Cleanable cleanable; + + private Bond(long handle) { + this.handle = handle; + this.cleanable = NativeRef.releaseOnClean(this, handle); + } + + /** Build from a raw {@code BondSpec} JSON. Prefer the typed builders. */ + static Bond fromSpecJson(String specJson) { + return new Bond(ConvexFfi.buildBond(specJson)); + } + + /** Package-private: the registry handle, for request marshalling. */ + long handle() { + return handle; + } + + /** A JSON description of the registered bond (coupon, maturity, …). */ + public com.fasterxml.jackson.databind.JsonNode describe() { + return Json.unwrap(ConvexFfi.describe(handle)); + } + + @Override + public void close() { + cleanable.clean(); // idempotent; releases the native object + } +} diff --git a/bindings/java/src/main/java/com/convex/CallStyle.java b/bindings/java/src/main/java/com/convex/CallStyle.java new file mode 100644 index 00000000..3206096f --- /dev/null +++ b/bindings/java/src/main/java/com/convex/CallStyle.java @@ -0,0 +1,19 @@ +package com.convex; + +/** Embedded-call exercise style. Wire values match {@code convex_analytics::dto::CallStyle} (snake_case). */ +public enum CallStyle { + AMERICAN("american"), + EUROPEAN("european"), + BERMUDAN("bermudan"), + MAKE_WHOLE("make_whole"); + + private final String wire; + + CallStyle(String wire) { + this.wire = wire; + } + + public String wire() { + return wire; + } +} diff --git a/bindings/java/src/main/java/com/convex/CallableBond.java b/bindings/java/src/main/java/com/convex/CallableBond.java new file mode 100644 index 00000000..3c002257 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/CallableBond.java @@ -0,0 +1,110 @@ +package com.convex; + +import com.convex.internal.Json; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * Builder for a callable bond, mirroring the Rust {@code CallableBond}. + * + *

Add call entries with {@link Builder#call(LocalDate, double)}. For a + * make-whole bond, set {@link Builder#callStyle(CallStyle)} to + * {@link CallStyle#MAKE_WHOLE} and supply {@link Builder#makeWholeSpreadBps(double)}. + */ +public final class CallableBond { + + private CallableBond() {} + + public static Builder builder() { + return new Builder(); + } + + /** A single call (or put) entry: a date, a price per 100, and an optional window end. */ + public record CallEntry(LocalDate date, double pricePer100, LocalDate endDate) { + public CallEntry(LocalDate date, double pricePer100) { + this(date, pricePer100, null); + } + } + + public static final class Builder { + private String cusip; + private String isin; + private String name; + private BigDecimal couponRate; + private Frequency frequency = Frequency.SEMI_ANNUAL; + private LocalDate maturity; + private LocalDate issue; + private DayCount dayCount = DayCount.THIRTY_360_US; + private Currency currency = Currency.USD; + private BigDecimal faceValue = new BigDecimal("100"); + private CallStyle callStyle = CallStyle.AMERICAN; + private Double makeWholeSpreadBps; + private final List calls = new ArrayList<>(); + private final List puts = new ArrayList<>(); + + public Builder cusip(String v) { this.cusip = v; return this; } + public Builder isin(String v) { this.isin = v; return this; } + public Builder name(String v) { this.name = v; return this; } + public Builder couponRate(BigDecimal v) { this.couponRate = v; return this; } + public Builder frequency(Frequency v) { this.frequency = v; return this; } + public Builder maturity(LocalDate v) { this.maturity = v; return this; } + public Builder issue(LocalDate v) { this.issue = v; return this; } + public Builder dayCount(DayCount v) { this.dayCount = v; return this; } + public Builder currency(Currency v) { this.currency = v; return this; } + public Builder faceValue(BigDecimal v) { this.faceValue = v; return this; } + public Builder callStyle(CallStyle v) { this.callStyle = v; return this; } + public Builder makeWholeSpreadBps(double v) { this.makeWholeSpreadBps = v; return this; } + + public Builder call(LocalDate date, double pricePer100) { + calls.add(new CallEntry(date, pricePer100)); + return this; + } + + public Builder call(CallEntry entry) { + calls.add(entry); + return this; + } + + public Builder put(LocalDate date, double pricePer100) { + puts.add(new CallEntry(date, pricePer100)); + return this; + } + + public Bond build() { + if (calls.isEmpty()) { + throw new IllegalArgumentException("callable bond requires at least one call entry"); + } + ObjectNode spec = Specs.fixedRate( + "callable", cusip, isin, name, couponRate, frequency, + issue, maturity, dayCount, currency, faceValue); + spec.put("call_style", callStyle.wire()); + if (makeWholeSpreadBps != null) { + spec.put("make_whole_spread_bps", makeWholeSpreadBps); + } + spec.set("call_schedule", entries(calls)); + if (!puts.isEmpty()) { + spec.set("put_schedule", entries(puts)); + } + return Bond.fromSpecJson(Json.write(spec)); + } + + private static ArrayNode entries(List list) { + ArrayNode arr = Json.mapper().createArrayNode(); + for (CallEntry e : list) { + ObjectNode n = Json.object(); + n.put("date", e.date().toString()); + n.put("price", e.pricePer100()); + if (e.endDate() != null) { + n.put("end_date", e.endDate().toString()); + } + arr.add(n); + } + return arr; + } + } +} diff --git a/bindings/java/src/main/java/com/convex/ComparisonReport.java b/bindings/java/src/main/java/com/convex/ComparisonReport.java new file mode 100644 index 00000000..ea4c0fbd --- /dev/null +++ b/bindings/java/src/main/java/com/convex/ComparisonReport.java @@ -0,0 +1,50 @@ +package com.convex; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.Optional; + +/** + * The result of comparing several {@link HedgeProposal}s against a position — + * one row per proposal plus a recommendation, and an optional deterministic + * narrative. + */ +public final class ComparisonReport { + + private final JsonNode report; + private final String narrative; + + ComparisonReport(JsonNode report, String narrative) { + this.report = report; + this.narrative = narrative; + } + + /** Strategy label of the recommended proposal. */ + public String recommendedStrategy() { + return report.path("recommendation").path("strategy").asText(); + } + + /** Index into the comparison rows (input order) of the recommendation. */ + public int recommendedRowIndex() { + return report.path("recommendation").path("row_index").asInt(); + } + + /** Number of comparison rows (== number of proposals compared). */ + public int rowCount() { + JsonNode rows = report.get("rows"); + return rows == null ? 0 : rows.size(); + } + + public double positionDv01() { + return report.path("position_dv01").asDouble(Double.NaN); + } + + /** The deterministic text narrative, if it was requested. */ + public Optional narrative() { + return Optional.ofNullable(narrative); + } + + public JsonNode raw() { + return report; + } +} diff --git a/bindings/java/src/main/java/com/convex/Compounding.java b/bindings/java/src/main/java/com/convex/Compounding.java new file mode 100644 index 00000000..2765e402 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/Compounding.java @@ -0,0 +1,22 @@ +package com.convex; + +/** Compounding basis. Wire values match {@code convex_core::types::Compounding}. */ +public enum Compounding { + ANNUAL("Annual"), + SEMI_ANNUAL("SemiAnnual"), + QUARTERLY("Quarterly"), + MONTHLY("Monthly"), + CONTINUOUS("Continuous"), + SIMPLE("Simple"), + DAILY("Daily"); + + private final String wire; + + Compounding(String wire) { + this.wire = wire; + } + + public String wire() { + return wire; + } +} diff --git a/bindings/java/src/main/java/com/convex/Constraints.java b/bindings/java/src/main/java/com/convex/Constraints.java new file mode 100644 index 00000000..b5e5cb20 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/Constraints.java @@ -0,0 +1,64 @@ +package com.convex; + +import com.convex.internal.Json; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.ArrayList; +import java.util.List; + +/** + * Hedge constraints, mirroring {@code convex_analytics::risk::Constraints}. + * + *

All limits are optional; an empty {@code Constraints} (the default) imposes + * none. Used to bound residual DV01 / cost and to express per-bucket KRD limits + * for {@link HedgeAdvisor#compare}. + */ +public final class Constraints { + + private Double maxResidualDv01; + private Double maxCostBps; + private final List allowedStrategies = new ArrayList<>(); + private final List perBucketLimits = new ArrayList<>(); // {tenor_years, max_abs_dv01} + + public static Constraints none() { + return new Constraints(); + } + + public Constraints maxResidualDv01(double v) { this.maxResidualDv01 = v; return this; } + public Constraints maxCostBps(double v) { this.maxCostBps = v; return this; } + + public Constraints allowStrategy(String name) { + allowedStrategies.add(name); + return this; + } + + public Constraints maxResidualAtTenor(double tenorYears, double maxAbsDv01) { + perBucketLimits.add(new double[]{tenorYears, maxAbsDv01}); + return this; + } + + ObjectNode toJson() { + ObjectNode n = Json.object(); + if (maxResidualDv01 != null) { + n.put("max_residual_dv01", maxResidualDv01); + } + if (maxCostBps != null) { + n.put("max_cost_bps", maxCostBps); + } + if (!allowedStrategies.isEmpty()) { + ArrayNode arr = n.putArray("allowed_strategies"); + allowedStrategies.forEach(arr::add); + } + if (!perBucketLimits.isEmpty()) { + ArrayNode arr = n.putArray("max_residual_per_bucket"); + for (double[] lim : perBucketLimits) { + ObjectNode e = Json.object(); + e.put("tenor_years", lim[0]); + e.put("max_abs_dv01", lim[1]); + arr.add(e); + } + } + return n; + } +} diff --git a/bindings/java/src/main/java/com/convex/Convex.java b/bindings/java/src/main/java/com/convex/Convex.java new file mode 100644 index 00000000..b041911d --- /dev/null +++ b/bindings/java/src/main/java/com/convex/Convex.java @@ -0,0 +1,31 @@ +package com.convex; + +import com.convex.internal.ConvexFfi; + +/** + * Library entry point: version and registry diagnostics. + * + *

Touching any method here (or any builder) triggers one-time native library + * loading. Bonds and curves are reference-counted native objects — prefer + * try-with-resources; {@link #objectCount()} is handy in tests to assert none + * leaked. + */ +public final class Convex { + + private Convex() {} + + /** The native library version (matches the Rust workspace version). */ + public static String version() { + return ConvexFfi.version(); + } + + /** Number of live native objects (bonds + curves) currently registered. */ + public static int objectCount() { + return ConvexFfi.objectCount(); + } + + /** Release every registered native object. Mainly for test teardown. */ + public static void clearAll() { + ConvexFfi.clearAll(); + } +} diff --git a/bindings/java/src/main/java/com/convex/ConvexAnalytics.java b/bindings/java/src/main/java/com/convex/ConvexAnalytics.java new file mode 100644 index 00000000..c5922dda --- /dev/null +++ b/bindings/java/src/main/java/com/convex/ConvexAnalytics.java @@ -0,0 +1,185 @@ +package com.convex; + +import com.convex.internal.ConvexFfi; +import com.convex.internal.Json; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.OptionalDouble; + +/** + * Stateless bond analytics — pricing, risk, spreads, cashflows, make-whole — + * mirroring the standalone functions in {@code convex_analytics::functions}. + * + *

All methods are pure: they take a {@link Bond} (and optionally a + * {@link YieldCurve}) plus a {@link Mark} and return an immutable result + * record. Prices and accrued are per 100 face; yields/rates are decimals. + */ +public final class ConvexAnalytics { + + private ConvexAnalytics() {} + + // ---- result records ----------------------------------------------------- + + public record PricingResult(double cleanPrice, double dirtyPrice, double accrued, + double ytmDecimal, OptionalDouble zSpreadBps) {} + + public record KeyRate(double tenorYears, double duration) {} + + public record RiskResult(double modifiedDuration, double macaulayDuration, double convexity, + double dv01, OptionalDouble spreadDuration, List keyRates) {} + + public record SpreadResult(double spreadBps, OptionalDouble spreadDv01, OptionalDouble spreadDuration, + OptionalDouble optionValue, OptionalDouble effectiveDuration, + OptionalDouble effectiveConvexity) {} + + public record CashFlow(LocalDate date, double amount, String kind) {} + + public record MakeWholeResult(double price, double discountRate, double spreadBps) {} + + // ---- pricing ------------------------------------------------------------ + + public static PricingResult price(Bond bond, LocalDate settlement, Mark mark) { + return price(bond, settlement, mark, null); + } + + public static PricingResult price(Bond bond, LocalDate settlement, Mark mark, YieldCurve curve) { + ObjectNode req = base(bond, settlement, mark); + if (curve != null) { + req.put("curve", curve.handle()); + } + JsonNode r = Json.unwrap(ConvexFfi.price(Json.write(req))); + return new PricingResult( + Json.dbl(r, "clean_price"), + Json.dbl(r, "dirty_price"), + Json.dbl(r, "accrued"), + Json.dbl(r, "ytm_decimal"), + Json.optDbl(r, "z_spread_bps")); + } + + // ---- risk --------------------------------------------------------------- + + public static RiskResult risk(Bond bond, LocalDate settlement, Mark mark, + YieldCurve curve, double... keyRateTenors) { + ObjectNode req = base(bond, settlement, mark); + if (curve != null) { + req.put("curve", curve.handle()); + } + if (keyRateTenors != null && keyRateTenors.length > 0) { + var arr = req.putArray("key_rate_tenors"); + for (double t : keyRateTenors) { + arr.add(t); + } + } + JsonNode r = Json.unwrap(ConvexFfi.risk(Json.write(req))); + List krd = new ArrayList<>(); + JsonNode rates = r.get("key_rates"); + if (rates != null && rates.isArray()) { + for (JsonNode k : rates) { + krd.add(new KeyRate(k.get("tenor").asDouble(), k.get("duration").asDouble())); + } + } + return new RiskResult( + Json.dbl(r, "modified_duration"), + Json.dbl(r, "macaulay_duration"), + Json.dbl(r, "convexity"), + Json.dbl(r, "dv01"), + Json.optDbl(r, "spread_duration"), + List.copyOf(krd)); + } + + // ---- spreads ------------------------------------------------------------ + + /** Optional per-family spread parameters (OAS volatility, G-spread govt curve, …). */ + public static final class SpreadParams { + private Double volatility; + private YieldCurve govtCurve; + private YieldCurve forwardCurve; + private Double currentIndex; + + public static SpreadParams create() { return new SpreadParams(); } + public SpreadParams volatility(double v) { this.volatility = v; return this; } + public SpreadParams govtCurve(YieldCurve c) { this.govtCurve = c; return this; } + public SpreadParams forwardCurve(YieldCurve c) { this.forwardCurve = c; return this; } + public SpreadParams currentIndex(double v) { this.currentIndex = v; return this; } + + ObjectNode toJson() { + ObjectNode p = Json.object(); + if (volatility != null) p.put("volatility", volatility); + if (govtCurve != null) p.put("govt_curve", govtCurve.handle()); + if (forwardCurve != null) p.put("forward_curve", forwardCurve.handle()); + if (currentIndex != null) p.put("current_index", currentIndex); + return p; + } + } + + public static SpreadResult spread(Bond bond, YieldCurve curve, LocalDate settlement, + Mark mark, SpreadType type) { + return spread(bond, curve, settlement, mark, type, null); + } + + public static SpreadResult spread(Bond bond, YieldCurve curve, LocalDate settlement, + Mark mark, SpreadType type, SpreadParams params) { + ObjectNode req = base(bond, settlement, mark); + req.put("curve", curve.handle()); + req.put("spread_type", type.wire()); + if (params != null) { + req.set("params", params.toJson()); + } + JsonNode r = Json.unwrap(ConvexFfi.spread(Json.write(req))); + return new SpreadResult( + Json.dbl(r, "spread_bps"), + Json.optDbl(r, "spread_dv01"), + Json.optDbl(r, "spread_duration"), + Json.optDbl(r, "option_value"), + Json.optDbl(r, "effective_duration"), + Json.optDbl(r, "effective_convexity")); + } + + // ---- cashflows ---------------------------------------------------------- + + public static List cashflows(Bond bond, LocalDate settlement) { + ObjectNode req = Json.object(); + req.put("bond", bond.handle()); + req.put("settlement", settlement.toString()); + JsonNode r = Json.unwrap(ConvexFfi.cashflows(Json.write(req))); + List flows = new ArrayList<>(); + JsonNode arr = r.get("flows"); + if (arr != null && arr.isArray()) { + for (JsonNode f : arr) { + flows.add(new CashFlow( + LocalDate.parse(f.get("date").asText()), + f.get("amount").asDouble(), + f.get("kind").asText())); + } + } + return List.copyOf(flows); + } + + // ---- make-whole --------------------------------------------------------- + + public static MakeWholeResult makeWhole(Bond callable, LocalDate callDate, double treasuryRate) { + ObjectNode req = Json.object(); + req.put("bond", callable.handle()); + req.put("call_date", callDate.toString()); + req.put("treasury_rate", treasuryRate); + JsonNode r = Json.unwrap(ConvexFfi.makeWhole(Json.write(req))); + return new MakeWholeResult( + Json.dbl(r, "price"), + Json.dbl(r, "discount_rate"), + Json.dbl(r, "spread_bps")); + } + + // ---- shared ------------------------------------------------------------- + + private static ObjectNode base(Bond bond, LocalDate settlement, Mark mark) { + ObjectNode req = Json.object(); + req.put("bond", bond.handle()); + req.put("settlement", settlement.toString()); + req.put("mark", mark.wire()); // MarkInput::Text + return req; + } +} diff --git a/bindings/java/src/main/java/com/convex/ConvexException.java b/bindings/java/src/main/java/com/convex/ConvexException.java new file mode 100644 index 00000000..36450708 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/ConvexException.java @@ -0,0 +1,46 @@ +package com.convex; + +/** + * Raised when the native library reports a failure. + * + *

Mirrors the two Rust error paths behind {@code crates/convex-ffi}: + * constructor failures (a {@code 0} handle plus a thread-local message) and + * RPC error envelopes ({@code {"ok":"false","error":{code,message,field?}}}). + * The {@link #code()} is a stable machine string ({@code invalid_input}, + * {@code invalid_handle}, {@code analytics}, ...); {@link #field()} points at + * the offending request field when the native layer knows it. + */ +public final class ConvexException extends RuntimeException { + + private final String code; + private final String field; + + public ConvexException(String code, String message, String field) { + super(message); + this.code = code; + this.field = field; + } + + public ConvexException(String message) { + this("error", message, null); + } + + /** Stable error code, never {@code null}. */ + public String code() { + return code; + } + + /** Offending request field, or {@code null} if unknown. */ + public String field() { + return field; + } + + @Override + public String getMessage() { + StringBuilder sb = new StringBuilder().append('[').append(code).append("] ").append(super.getMessage()); + if (field != null) { + sb.append(" (field: ").append(field).append(')'); + } + return sb.toString(); + } +} diff --git a/bindings/java/src/main/java/com/convex/Currency.java b/bindings/java/src/main/java/com/convex/Currency.java new file mode 100644 index 00000000..a9694a52 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/Currency.java @@ -0,0 +1,11 @@ +package com.convex; + +/** ISO currency. Wire values match {@code convex_core::types::Currency} variant names. */ +public enum Currency { + USD, EUR, GBP, JPY, CHF, CAD, AUD; + + /** The JSON token the native layer expects (the variant name). */ + public String wire() { + return name(); + } +} diff --git a/bindings/java/src/main/java/com/convex/DayCount.java b/bindings/java/src/main/java/com/convex/DayCount.java new file mode 100644 index 00000000..75007e78 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/DayCount.java @@ -0,0 +1,24 @@ +package com.convex; + +/** Day-count convention. Wire values match {@code convex_core::daycounts::DayCountConvention}. */ +public enum DayCount { + THIRTY_360_US("Thirty360US"), + THIRTY_360_E("Thirty360E"), + THIRTY_360_ICMA("Thirty360Icma"), + ACT_360("Act360"), + ACT_365_FIXED("Act365Fixed"), + ACT_ACT_ICMA("ActActIcma"), + ACT_ACT_ISDA("ActActIsda"), + SIMPLE("Simple"), + DAILY("Daily"); + + private final String wire; + + DayCount(String wire) { + this.wire = wire; + } + + public String wire() { + return wire; + } +} diff --git a/bindings/java/src/main/java/com/convex/FixedRateBond.java b/bindings/java/src/main/java/com/convex/FixedRateBond.java new file mode 100644 index 00000000..6ff0bb05 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/FixedRateBond.java @@ -0,0 +1,52 @@ +package com.convex; + +import com.convex.internal.Json; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * Builder for a fixed-rate (bullet) bond, mirroring {@code FixedRateBond::builder()} + * on the Rust side. Coupon is a decimal (0.05 = 5%); see the module README for + * a usage example. + */ +public final class FixedRateBond { + + private FixedRateBond() {} + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String cusip; + private String isin; + private String name; + private BigDecimal couponRate; + private Frequency frequency = Frequency.SEMI_ANNUAL; + private LocalDate maturity; + private LocalDate issue; + private DayCount dayCount = DayCount.THIRTY_360_US; + private Currency currency = Currency.USD; + private BigDecimal faceValue = new BigDecimal("100"); + + public Builder cusip(String v) { this.cusip = v; return this; } + public Builder isin(String v) { this.isin = v; return this; } + public Builder name(String v) { this.name = v; return this; } + public Builder couponRate(BigDecimal v) { this.couponRate = v; return this; } + public Builder frequency(Frequency v) { this.frequency = v; return this; } + public Builder maturity(LocalDate v) { this.maturity = v; return this; } + public Builder issue(LocalDate v) { this.issue = v; return this; } + public Builder dayCount(DayCount v) { this.dayCount = v; return this; } + public Builder currency(Currency v) { this.currency = v; return this; } + public Builder faceValue(BigDecimal v) { this.faceValue = v; return this; } + + public Bond build() { + ObjectNode spec = Specs.fixedRate( + "fixed_rate", cusip, isin, name, couponRate, frequency, + issue, maturity, dayCount, currency, faceValue); + return Bond.fromSpecJson(Json.write(spec)); + } + } +} diff --git a/bindings/java/src/main/java/com/convex/FloatingRateNote.java b/bindings/java/src/main/java/com/convex/FloatingRateNote.java new file mode 100644 index 00000000..ce63ef5f --- /dev/null +++ b/bindings/java/src/main/java/com/convex/FloatingRateNote.java @@ -0,0 +1,78 @@ +package com.convex; + +import com.convex.internal.Json; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * Builder for a floating-rate note, mirroring the Rust {@code FloatingRateNote}. + * Spread is in basis points over the index; see the module README for usage. + */ +public final class FloatingRateNote { + + private FloatingRateNote() {} + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String cusip; + private String isin; + private String name; + private BigDecimal spreadBps; + private RateIndex rateIndex = RateIndex.SOFR; + private LocalDate maturity; + private LocalDate issue; + private Frequency frequency = Frequency.QUARTERLY; + private DayCount dayCount = DayCount.ACT_360; + private Currency currency = Currency.USD; + private BigDecimal faceValue = new BigDecimal("100"); + private BigDecimal cap; + private BigDecimal floor; + + public Builder cusip(String v) { this.cusip = v; return this; } + public Builder isin(String v) { this.isin = v; return this; } + public Builder name(String v) { this.name = v; return this; } + /** Spread over the index, in basis points. */ + public Builder spreadBps(BigDecimal v) { this.spreadBps = v; return this; } + public Builder rateIndex(RateIndex v) { this.rateIndex = v; return this; } + public Builder maturity(LocalDate v) { this.maturity = v; return this; } + public Builder issue(LocalDate v) { this.issue = v; return this; } + public Builder frequency(Frequency v) { this.frequency = v; return this; } + public Builder dayCount(DayCount v) { this.dayCount = v; return this; } + public Builder currency(Currency v) { this.currency = v; return this; } + public Builder faceValue(BigDecimal v) { this.faceValue = v; return this; } + /** Optional coupon cap (decimal, e.g. 0.06 for 6%). */ + public Builder cap(BigDecimal v) { this.cap = v; return this; } + /** Optional coupon floor (decimal). */ + public Builder floor(BigDecimal v) { this.floor = v; return this; } + + public Bond build() { + Specs.require(spreadBps, "spreadBps"); + Specs.require(issue, "issue"); + Specs.require(maturity, "maturity"); + + ObjectNode spec = Json.object(); + spec.put("type", "floating_rate"); + Specs.applyIdentifier(spec, cusip, isin, name); + spec.put("spread_bps", spreadBps); + spec.put("rate_index", rateIndex.wire()); + spec.put("issue", issue.toString()); + spec.put("maturity", maturity.toString()); + spec.put("frequency", frequency.wire()); + spec.put("day_count", dayCount.wire()); + spec.put("currency", currency.wire()); + spec.put("face_value", faceValue); + if (cap != null) { + spec.put("cap", cap); + } + if (floor != null) { + spec.put("floor", floor); + } + return Bond.fromSpecJson(Json.write(spec)); + } + } +} diff --git a/bindings/java/src/main/java/com/convex/Frequency.java b/bindings/java/src/main/java/com/convex/Frequency.java new file mode 100644 index 00000000..37ef98a0 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/Frequency.java @@ -0,0 +1,21 @@ +package com.convex; + +/** Coupon / compounding frequency. Wire values match {@code convex_core::types::Frequency}. */ +public enum Frequency { + ANNUAL("Annual"), + SEMI_ANNUAL("SemiAnnual"), + QUARTERLY("Quarterly"), + MONTHLY("Monthly"), + ZERO("Zero"); + + private final String wire; + + Frequency(String wire) { + this.wire = wire; + } + + /** The JSON token the native layer expects. */ + public String wire() { + return wire; + } +} diff --git a/bindings/java/src/main/java/com/convex/HedgeAdvisor.java b/bindings/java/src/main/java/com/convex/HedgeAdvisor.java new file mode 100644 index 00000000..73366069 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/HedgeAdvisor.java @@ -0,0 +1,146 @@ +package com.convex; + +import com.convex.internal.ConvexFfi; +import com.convex.internal.Json; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** + * The hedge advisor: build a position {@link RiskProfile}, propose hedges via a + * range of strategies, and {@link #compare} them. Mirrors + * {@code convex_analytics::risk}. See the module README for a usage example. + */ +public final class HedgeAdvisor { + + private HedgeAdvisor() {} + + // ---- position risk ------------------------------------------------------ + + public static PositionRiskBuilder positionRisk() { + return new PositionRiskBuilder(); + } + + public static final class PositionRiskBuilder { + private Bond bond; + private LocalDate settlement; + private Mark mark; + private BigDecimal notionalFace; + private YieldCurve curve; + private String curveId = "discount"; + private Frequency quoteFrequency; + private double[] keyRateTenors; + private String positionId; + private Double volatility; + + public PositionRiskBuilder bond(Bond v) { this.bond = v; return this; } + public PositionRiskBuilder settlement(LocalDate v) { this.settlement = v; return this; } + public PositionRiskBuilder mark(Mark v) { this.mark = v; return this; } + public PositionRiskBuilder notionalFace(BigDecimal v) { this.notionalFace = v; return this; } + public PositionRiskBuilder curve(YieldCurve v) { this.curve = v; return this; } + public PositionRiskBuilder curveId(String v) { this.curveId = v; return this; } + public PositionRiskBuilder quoteFrequency(Frequency v) { this.quoteFrequency = v; return this; } + public PositionRiskBuilder keyRateTenors(double... v) { this.keyRateTenors = v; return this; } + public PositionRiskBuilder positionId(String v) { this.positionId = v; return this; } + /** Required for callable positions (short-rate volatility, decimal). */ + public PositionRiskBuilder volatility(double v) { this.volatility = v; return this; } + + public RiskProfile compute() { + Specs.require(bond, "bond"); + Specs.require(settlement, "settlement"); + Specs.require(mark, "mark"); + Specs.require(notionalFace, "notionalFace"); + Specs.require(curve, "curve"); + + ObjectNode req = Json.object(); + req.put("bond", bond.handle()); + req.put("settlement", settlement.toString()); + req.put("mark", mark.wire()); + req.put("notional_face", notionalFace); + req.put("curve", curve.handle()); + req.put("curve_id", curveId); + if (quoteFrequency != null) { + req.put("quote_frequency", quoteFrequency.wire()); + } + if (keyRateTenors != null && keyRateTenors.length > 0) { + ArrayNode arr = req.putArray("key_rate_tenors"); + for (double t : keyRateTenors) { + arr.add(t); + } + } + if (positionId != null) { + req.put("position_id", positionId); + } + if (volatility != null) { + req.put("volatility", volatility); + } + return new RiskProfile(Json.unwrap(ConvexFfi.riskProfile(Json.write(req)))); + } + } + + // ---- strategies --------------------------------------------------------- + + public static HedgeProposal durationFutures(RiskProfile p, YieldCurve curve, LocalDate settlement) { + return durationFutures(p, curve, settlement, Constraints.none()); + } + + public static HedgeProposal durationFutures(RiskProfile p, YieldCurve curve, LocalDate settlement, Constraints c) { + return strategy("duration_futures", p, curve, settlement, c); + } + + public static HedgeProposal barbellFutures(RiskProfile p, YieldCurve curve, LocalDate settlement, Constraints c) { + return strategy("barbell_futures", p, curve, settlement, c); + } + + public static HedgeProposal cashBondPair(RiskProfile p, YieldCurve curve, LocalDate settlement, Constraints c) { + return strategy("cash_bond_pair", p, curve, settlement, c); + } + + /** Vanilla interest-rate-swap hedge. */ + public static HedgeProposal swap(RiskProfile p, YieldCurve curve, LocalDate settlement, Constraints c) { + return strategy("interest_rate_swap", p, curve, settlement, c); + } + + public static HedgeProposal keyRateFutures(RiskProfile p, YieldCurve curve, LocalDate settlement, Constraints c) { + return strategy("key_rate_futures", p, curve, settlement, c); + } + + private static HedgeProposal strategy(String kind, RiskProfile p, YieldCurve curve, + LocalDate settlement, Constraints c) { + ObjectNode req = Json.object(); + req.put("strategy", kind); + req.set("position", p.node()); + req.put("curve", curve.handle()); + req.put("curve_id", "discount"); + req.put("settlement", settlement.toString()); + if (c != null) { + req.set("constraints", c.toJson()); + } + return new HedgeProposal(Json.unwrap(ConvexFfi.hedge(Json.write(req)))); + } + + // ---- compare ------------------------------------------------------------ + + public static ComparisonReport compare(RiskProfile position, List proposals, + Constraints constraints, boolean narrate) { + ObjectNode req = Json.object(); + req.set("position", position.node()); + ArrayNode arr = req.putArray("proposals"); + for (HedgeProposal hp : proposals) { + arr.add(hp.node()); + } + if (constraints != null) { + req.set("constraints", constraints.toJson()); + } + req.put("narrate", narrate); + + JsonNode result = Json.unwrap(ConvexFfi.compare(Json.write(req))); + JsonNode report = result.get("report"); + String narrative = result.hasNonNull("narrative") ? result.get("narrative").asText() : null; + return new ComparisonReport(report, narrative); + } +} diff --git a/bindings/java/src/main/java/com/convex/HedgeProposal.java b/bindings/java/src/main/java/com/convex/HedgeProposal.java new file mode 100644 index 00000000..cec9da47 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/HedgeProposal.java @@ -0,0 +1,55 @@ +package com.convex; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * A proposed hedge for a {@link RiskProfile}, as returned by the + * {@link HedgeAdvisor} strategy methods. + * + *

Backed by the verbatim native JSON so it round-trips into + * {@link HedgeAdvisor#compare}. Typed getters cover the headline figures; + * {@link #raw()} exposes the full trade list, residual buckets, and tradeoff + * notes. + */ +public final class HedgeProposal { + + private final JsonNode node; + + HedgeProposal(JsonNode node) { + this.node = node; + } + + JsonNode node() { + return node; + } + + /** Strategy label (e.g. {@code "DurationFutures"}). */ + public String strategy() { + return node.path("strategy").asText(); + } + + /** Round-trip cost as basis points of position market value. */ + public double costBps() { + return node.path("cost_bps").asDouble(Double.NaN); + } + + /** Residual DV01 after the hedge ({@code position.dv01 + Σ trade.dv01}). */ + public double residualDv01() { + return node.path("residual").path("residual_dv01").asDouble(Double.NaN); + } + + /** L1 norm of the residual key-rate vector. */ + public double residualKrdL1Norm() { + return node.path("residual").path("residual_krd_l1_norm").asDouble(Double.NaN); + } + + /** Number of trade legs in the proposal. */ + public int tradeCount() { + JsonNode trades = node.get("trades"); + return trades == null ? 0 : trades.size(); + } + + public JsonNode raw() { + return node; + } +} diff --git a/bindings/java/src/main/java/com/convex/Interpolation.java b/bindings/java/src/main/java/com/convex/Interpolation.java new file mode 100644 index 00000000..02e7db82 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/Interpolation.java @@ -0,0 +1,19 @@ +package com.convex; + +/** Curve interpolation. Wire values match {@code convex_analytics::dto::InterpolationMethodCode} (snake_case). */ +public enum Interpolation { + LINEAR("linear"), + LOG_LINEAR("log_linear"), + CUBIC_SPLINE("cubic_spline"), + MONOTONE_CONVEX("monotone_convex"); + + private final String wire; + + Interpolation(String wire) { + this.wire = wire; + } + + public String wire() { + return wire; + } +} diff --git a/bindings/java/src/main/java/com/convex/Mark.java b/bindings/java/src/main/java/com/convex/Mark.java new file mode 100644 index 00000000..0140fbd3 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/Mark.java @@ -0,0 +1,63 @@ +package com.convex; + +import com.convex.internal.ConvexFfi; +import com.convex.internal.Json; + +import java.math.BigDecimal; + +/** + * A trader mark — a price, yield, or spread quote. + * + *

Marks cross the FFI as the textual shorthand the Rust {@code Mark} parser + * accepts (e.g. {@code "99.5C"}, {@code "4.65%"}, {@code "+125bps@USD.SOFR"}), + * carried as a {@code MarkInput::Text}. Use the factories for the common cases + * or {@link #parse(String)} to validate free-form text against the native + * parser up front. + */ +public final class Mark { + + private final String text; + + private Mark(String text) { + this.text = text; + } + + /** Clean price per 100 (e.g. 99.5 → {@code "99.5C"}). */ + public static Mark cleanPrice(BigDecimal pricePer100) { + return new Mark(pricePer100.toPlainString() + "C"); + } + + /** Dirty price per 100 (e.g. 100.75 → {@code "100.75D"}). */ + public static Mark dirtyPrice(BigDecimal pricePer100) { + return new Mark(pricePer100.toPlainString() + "D"); + } + + /** Yield in percent (e.g. 4.65 → {@code "4.65%"}). */ + public static Mark yieldPercent(BigDecimal percent) { + return new Mark(percent.toPlainString() + "%"); + } + + /** Any textual mark, taken verbatim. */ + public static Mark of(String text) { + return new Mark(text); + } + + /** + * Validate {@code text} against the native mark parser, throwing + * {@link ConvexException} if it is not a recognised mark. + */ + public static Mark parse(String text) { + Json.unwrap(ConvexFfi.markParse(text)); // throws on invalid_input + return new Mark(text); + } + + /** The wire text sent as {@code MarkInput::Text}. */ + public String wire() { + return text; + } + + @Override + public String toString() { + return text; + } +} diff --git a/bindings/java/src/main/java/com/convex/RateIndex.java b/bindings/java/src/main/java/com/convex/RateIndex.java new file mode 100644 index 00000000..41dd4db2 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/RateIndex.java @@ -0,0 +1,24 @@ +package com.convex; + +/** Floating-rate index. Wire values match {@code convex_analytics::dto::RateIndexCode} (snake_case). */ +public enum RateIndex { + SOFR("sofr"), + SONIA("sonia"), + ESTR("estr"), + TONAR("tonar"), + SARON("saron"), + CORRA("corra"), + EURIBOR_3M("euribor3m"), + EURIBOR_6M("euribor6m"), + TIBOR_3M("tibor3m"); + + private final String wire; + + RateIndex(String wire) { + this.wire = wire; + } + + public String wire() { + return wire; + } +} diff --git a/bindings/java/src/main/java/com/convex/RiskProfile.java b/bindings/java/src/main/java/com/convex/RiskProfile.java new file mode 100644 index 00000000..ea0b4b88 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/RiskProfile.java @@ -0,0 +1,59 @@ +package com.convex; + +import com.convex.internal.Json; +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.List; + +/** + * A position's risk profile, as produced by {@link HedgeAdvisor#positionRisk()}. + * + *

Backed by the exact JSON the native layer returned so it round-trips + * losslessly into the hedge-strategy and compare calls (those take the full + * profile by value). Typed getters expose the headline metrics; {@link #raw()} + * is the escape hatch for fields not surfaced here. + */ +public final class RiskProfile { + + private final JsonNode node; + + RiskProfile(JsonNode node) { + this.node = node; + } + + /** A single key-rate bucket: tenor (years) and partial DV01. */ + public record Bucket(double tenorYears, double partialDv01) {} + + /** Package-private: the verbatim profile JSON for round-tripping. */ + JsonNode node() { + return node; + } + + public String currency() { return node.path("currency").asText(); } + public double notionalFace() { return node.path("notional_face").asDouble(); } + public double marketValue() { return node.path("market_value").asDouble(); } + public double cleanPrice() { return Json.dbl(node, "clean_price_per_100"); } + public double dirtyPrice() { return Json.dbl(node, "dirty_price_per_100"); } + public double ytmDecimal() { return Json.dbl(node, "ytm_decimal"); } + public double modifiedDuration() { return Json.dbl(node, "modified_duration_years"); } + public double macaulayDuration() { return Json.dbl(node, "macaulay_duration_years"); } + public double convexity() { return Json.dbl(node, "convexity"); } + public double dv01() { return Json.dbl(node, "dv01"); } + + public List keyRateBuckets() { + List out = new ArrayList<>(); + JsonNode arr = node.get("key_rate_buckets"); + if (arr != null && arr.isArray()) { + for (JsonNode b : arr) { + out.add(new Bucket(b.path("tenor_years").asDouble(), b.path("partial_dv01").asDouble())); + } + } + return List.copyOf(out); + } + + /** The full underlying JSON (read-only). */ + public JsonNode raw() { + return node; + } +} diff --git a/bindings/java/src/main/java/com/convex/SinkingFundBond.java b/bindings/java/src/main/java/com/convex/SinkingFundBond.java new file mode 100644 index 00000000..8a9a846e --- /dev/null +++ b/bindings/java/src/main/java/com/convex/SinkingFundBond.java @@ -0,0 +1,86 @@ +package com.convex; + +import com.convex.internal.Json; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * Builder for a sinking-fund bond, mirroring the Rust {@code SinkingFundBond} — + * a fixed-coupon bond with a scheduled amortisation (sink) of principal. See + * the module README for usage. + */ +public final class SinkingFundBond { + + private SinkingFundBond() {} + + public static Builder builder() { + return new Builder(); + } + + /** One sink payment: a date, the % of original face retired, and the sink price (% of par). */ + public record Payment(LocalDate date, double amountPctOfFace, double pricePctOfPar) { + /** Defaults the sink price to par (100). */ + public Payment(LocalDate date, double amountPctOfFace) { + this(date, amountPctOfFace, 100.0); + } + } + + public static final class Builder { + private String cusip; + private String isin; + private String name; + private BigDecimal couponRate; + private Frequency frequency = Frequency.SEMI_ANNUAL; + private LocalDate maturity; + private LocalDate issue; + private DayCount dayCount = DayCount.THIRTY_360_US; + private Currency currency = Currency.USD; + private BigDecimal faceValue = new BigDecimal("100"); + private final List schedule = new ArrayList<>(); + + public Builder cusip(String v) { this.cusip = v; return this; } + public Builder isin(String v) { this.isin = v; return this; } + public Builder name(String v) { this.name = v; return this; } + public Builder couponRate(BigDecimal v) { this.couponRate = v; return this; } + public Builder frequency(Frequency v) { this.frequency = v; return this; } + public Builder maturity(LocalDate v) { this.maturity = v; return this; } + public Builder issue(LocalDate v) { this.issue = v; return this; } + public Builder dayCount(DayCount v) { this.dayCount = v; return this; } + public Builder currency(Currency v) { this.currency = v; return this; } + public Builder faceValue(BigDecimal v) { this.faceValue = v; return this; } + + /** Retire {@code amountPctOfFace}% of original face on {@code date} at par. */ + public Builder sink(LocalDate date, double amountPctOfFace) { + schedule.add(new Payment(date, amountPctOfFace)); + return this; + } + + public Builder sink(Payment payment) { + schedule.add(payment); + return this; + } + + public Bond build() { + if (schedule.isEmpty()) { + throw new IllegalArgumentException("sinking-fund bond requires at least one sink payment"); + } + ObjectNode spec = Specs.fixedRate( + "sinking_fund", cusip, isin, name, couponRate, frequency, + issue, maturity, dayCount, currency, faceValue); + ArrayNode sched = spec.putArray("schedule"); + for (Payment p : schedule) { + ObjectNode n = Json.object(); + n.put("date", p.date().toString()); + n.put("amount", p.amountPctOfFace()); + n.put("price", p.pricePctOfPar()); + sched.add(n); + } + return Bond.fromSpecJson(Json.write(spec)); + } + } +} diff --git a/bindings/java/src/main/java/com/convex/Specs.java b/bindings/java/src/main/java/com/convex/Specs.java new file mode 100644 index 00000000..8f13923b --- /dev/null +++ b/bindings/java/src/main/java/com/convex/Specs.java @@ -0,0 +1,54 @@ +package com.convex; + +import com.convex.internal.Json; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Objects; + +/** + * Package-private helpers that assemble the JSON {@code BondSpec} / {@code CurveSpec} + * shapes consumed by {@code crates/convex-analytics::dto}. Centralised so the + * builders stay declarative and required-field validation lives in one place. + */ +final class Specs { + + private Specs() {} + + /** Shared fixed-coupon spec (used by fixed_rate, callable, sinking_fund). */ + static ObjectNode fixedRate(String type, String cusip, String isin, String name, + BigDecimal couponRate, Frequency frequency, + LocalDate issue, LocalDate maturity, + DayCount dayCount, Currency currency, BigDecimal faceValue) { + require(couponRate, "couponRate"); + require(issue, "issue"); + require(maturity, "maturity"); + + ObjectNode n = Json.object(); + n.put("type", type); + applyIdentifier(n, cusip, isin, name); + n.put("coupon_rate", couponRate); + n.put("frequency", frequency.wire()); + n.put("issue", issue.toString()); + n.put("maturity", maturity.toString()); + n.put("day_count", dayCount.wire()); + n.put("currency", currency.wire()); + n.put("face_value", faceValue); + return n; + } + + static void applyIdentifier(ObjectNode n, String cusip, String isin, String name) { + if (cusip != null) { + n.put("cusip", cusip); + } else if (isin != null) { + n.put("isin", isin); + } else if (name != null) { + n.put("name", name); + } + } + + static T require(T value, String field) { + return Objects.requireNonNull(value, () -> "missing required field: " + field); + } +} diff --git a/bindings/java/src/main/java/com/convex/SpreadType.java b/bindings/java/src/main/java/com/convex/SpreadType.java new file mode 100644 index 00000000..6375a0a0 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/SpreadType.java @@ -0,0 +1,23 @@ +package com.convex; + +/** Spread family. Wire values match {@code convex_core::types::SpreadType}. */ +public enum SpreadType { + Z_SPREAD("ZSpread"), + G_SPREAD("GSpread"), + I_SPREAD("ISpread"), + ASSET_SWAP_PAR("AssetSwapPar"), + ASSET_SWAP_PROCEEDS("AssetSwapProceeds"), + OAS("OAS"), + CREDIT("Credit"), + DISCOUNT_MARGIN("DiscountMargin"); + + private final String wire; + + SpreadType(String wire) { + this.wire = wire; + } + + public String wire() { + return wire; + } +} diff --git a/bindings/java/src/main/java/com/convex/YieldCurve.java b/bindings/java/src/main/java/com/convex/YieldCurve.java new file mode 100644 index 00000000..141ddcf6 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/YieldCurve.java @@ -0,0 +1,120 @@ +package com.convex; + +import com.convex.internal.ConvexFfi; +import com.convex.internal.Json; +import com.convex.internal.NativeRef; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.lang.ref.Cleaner; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * A discount / yield curve in the native registry. Build it with + * {@link #discrete()}; values are continuously-compounded zero rates (decimal) + * unless {@link DiscreteBuilder#asDiscountFactors()} is set. See the module + * README for a usage example. + */ +public final class YieldCurve implements AutoCloseable { + + private final long handle; + private final Cleaner.Cleanable cleanable; + + private YieldCurve(long handle) { + this.handle = handle; + this.cleanable = NativeRef.releaseOnClean(this, handle); + } + + long handle() { + return handle; + } + + public static DiscreteBuilder discrete() { + return new DiscreteBuilder(); + } + + // ---- queries ------------------------------------------------------------ + + /** Zero rate (continuously compounded, decimal) at a tenor in years. */ + public double zeroRate(double tenorYears) { + return query("zero", tenorYears, null); + } + + /** Discount factor at a tenor in years. */ + public double discountFactor(double tenorYears) { + return query("df", tenorYears, null); + } + + /** Forward rate (continuously compounded, decimal) between two tenors in years. */ + public double forwardRate(double startYears, double endYears) { + return query("forward", startYears, endYears); + } + + private double query(String kind, double tenor, Double tenorEnd) { + ObjectNode req = Json.object(); + req.put("curve", handle); + req.put("query", kind); + req.put("tenor", tenor); + if (tenorEnd != null) { + req.put("tenor_end", tenorEnd); + } + JsonNode result = Json.unwrap(ConvexFfi.curveQuery(Json.write(req))); + return result.get("value").asDouble(); + } + + @Override + public void close() { + cleanable.clean(); + } + + // ---- builder ------------------------------------------------------------ + + public static final class DiscreteBuilder { + private String name; + private LocalDate referenceDate; + private final List points = new ArrayList<>(); // {tenor, value} + private Interpolation interpolation = Interpolation.LINEAR; + private boolean discountFactors = false; + + public DiscreteBuilder name(String v) { this.name = v; return this; } + public DiscreteBuilder referenceDate(LocalDate v) { this.referenceDate = v; return this; } + public DiscreteBuilder interpolation(Interpolation v) { this.interpolation = v; return this; } + + /** Add a (tenorYears, zeroRateDecimal) node. */ + public DiscreteBuilder point(double tenorYears, double zeroRate) { + points.add(new double[]{tenorYears, zeroRate}); + return this; + } + + /** Interpret the supplied values as discount factors instead of zero rates. */ + public DiscreteBuilder asDiscountFactors() { + this.discountFactors = true; + return this; + } + + public YieldCurve build() { + Specs.require(referenceDate, "referenceDate"); + if (points.isEmpty()) { + throw new IllegalArgumentException("a discrete curve needs at least one point"); + } + ObjectNode spec = Json.object(); + spec.put("type", "discrete"); + if (name != null) { + spec.put("name", name); + } + spec.put("ref_date", referenceDate.toString()); + ArrayNode tenors = spec.putArray("tenors"); + ArrayNode values = spec.putArray("values"); + for (double[] p : points) { + tenors.add(p[0]); + values.add(p[1]); + } + spec.put("value_kind", discountFactors ? "discount_factor" : "zero_rate"); + spec.put("interpolation", interpolation.wire()); + return new YieldCurve(ConvexFfi.buildCurve(Json.write(spec))); + } + } +} diff --git a/bindings/java/src/main/java/com/convex/ZeroCouponBond.java b/bindings/java/src/main/java/com/convex/ZeroCouponBond.java new file mode 100644 index 00000000..929d2ab1 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/ZeroCouponBond.java @@ -0,0 +1,58 @@ +package com.convex; + +import com.convex.internal.Json; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * Builder for a zero-coupon bond, mirroring the Rust {@code ZeroCouponBond}. + * See the module README for usage. + */ +public final class ZeroCouponBond { + + private ZeroCouponBond() {} + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String cusip; + private String isin; + private String name; + private LocalDate maturity; + private LocalDate issue; + private Compounding compounding = Compounding.SEMI_ANNUAL; + private DayCount dayCount = DayCount.ACT_ACT_ICMA; + private Currency currency = Currency.USD; + private BigDecimal faceValue = new BigDecimal("100"); + + public Builder cusip(String v) { this.cusip = v; return this; } + public Builder isin(String v) { this.isin = v; return this; } + public Builder name(String v) { this.name = v; return this; } + public Builder maturity(LocalDate v) { this.maturity = v; return this; } + public Builder issue(LocalDate v) { this.issue = v; return this; } + public Builder compounding(Compounding v) { this.compounding = v; return this; } + public Builder dayCount(DayCount v) { this.dayCount = v; return this; } + public Builder currency(Currency v) { this.currency = v; return this; } + public Builder faceValue(BigDecimal v) { this.faceValue = v; return this; } + + public Bond build() { + Specs.require(issue, "issue"); + Specs.require(maturity, "maturity"); + + ObjectNode spec = Json.object(); + spec.put("type", "zero_coupon"); + Specs.applyIdentifier(spec, cusip, isin, name); + spec.put("issue", issue.toString()); + spec.put("maturity", maturity.toString()); + spec.put("compounding", compounding.wire()); + spec.put("day_count", dayCount.wire()); + spec.put("currency", currency.wire()); + spec.put("face_value", faceValue); + return Bond.fromSpecJson(Json.write(spec)); + } + } +} diff --git a/bindings/java/src/main/java/com/convex/internal/ConvexFfi.java b/bindings/java/src/main/java/com/convex/internal/ConvexFfi.java new file mode 100644 index 00000000..7800adf7 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/internal/ConvexFfi.java @@ -0,0 +1,226 @@ +package com.convex.internal; + +import com.convex.ConvexException; + +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.MethodHandle; + +/** + * Panama FFM binding over the {@code convex-ffi} C ABI: one {@link MethodHandle} + * per symbol, all UTF-8 {@code char*} in / {@code char*} out. Returned strings + * are Rust-owned and freed here via {@code convex_string_free}. Safe to call + * from any thread. + */ +public final class ConvexFfi { + + private ConvexFfi() {} + + private static final Linker LINKER = Linker.nativeLinker(); + private static final SymbolLookup LOOKUP; + + // char* fn(const char*) + private static final MethodHandle PRICE; + private static final MethodHandle RISK; + private static final MethodHandle SPREAD; + private static final MethodHandle CASHFLOWS; + private static final MethodHandle CURVE_QUERY; + private static final MethodHandle MAKE_WHOLE; + private static final MethodHandle RISK_PROFILE; + private static final MethodHandle HEDGE; + private static final MethodHandle COMPARE; + private static final MethodHandle MARK_PARSE; + private static final MethodHandle DESCRIBE; // char* fn(u64) + + // u64 fn(const char*) + private static final MethodHandle BOND_FROM_JSON; + private static final MethodHandle CURVE_FROM_JSON; + + private static final MethodHandle RELEASE; // void fn(u64) + private static final MethodHandle STRING_FREE; // void fn(char*) + private static final MethodHandle LAST_ERROR; // const char* fn() + private static final MethodHandle VERSION; // const char* fn() + private static final MethodHandle OBJECT_COUNT; // i32 fn() + private static final MethodHandle CLEAR_ALL; // void fn() + + static { + NativeLoader.ensureLoaded(); + LOOKUP = SymbolLookup.loaderLookup(); + + FunctionDescriptor strToStr = FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS); + FunctionDescriptor strToHandle = FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS); + FunctionDescriptor handleToStr = FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.JAVA_LONG); + FunctionDescriptor voidToStr = FunctionDescriptor.of(ValueLayout.ADDRESS); + FunctionDescriptor voidToInt = FunctionDescriptor.of(ValueLayout.JAVA_INT); + FunctionDescriptor handleToVoid = FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG); + FunctionDescriptor ptrToVoid = FunctionDescriptor.ofVoid(ValueLayout.ADDRESS); + FunctionDescriptor voidToVoid = FunctionDescriptor.ofVoid(); + + PRICE = handle("convex_price", strToStr); + RISK = handle("convex_risk", strToStr); + SPREAD = handle("convex_spread", strToStr); + CASHFLOWS = handle("convex_cashflows", strToStr); + CURVE_QUERY = handle("convex_curve_query", strToStr); + MAKE_WHOLE = handle("convex_make_whole", strToStr); + RISK_PROFILE = handle("convex_risk_profile", strToStr); + HEDGE = handle("convex_hedge", strToStr); + COMPARE = handle("convex_compare", strToStr); + MARK_PARSE = handle("convex_mark_parse", strToStr); + DESCRIBE = handle("convex_describe", handleToStr); + + BOND_FROM_JSON = handle("convex_bond_from_json", strToHandle); + CURVE_FROM_JSON = handle("convex_curve_from_json", strToHandle); + + RELEASE = handle("convex_release", handleToVoid); + STRING_FREE = handle("convex_string_free", ptrToVoid); + LAST_ERROR = handle("convex_last_error", voidToStr); + VERSION = handle("convex_version", voidToStr); + OBJECT_COUNT = handle("convex_object_count", voidToInt); + CLEAR_ALL = handle("convex_clear_all", voidToVoid); + } + + private static MethodHandle handle(String symbol, FunctionDescriptor descriptor) { + MemorySegment addr = LOOKUP.find(symbol) + .orElseThrow(() -> new ConvexException("missing native symbol: " + symbol)); + return LINKER.downcallHandle(addr, descriptor); + } + + // ---- public surface ---------------------------------------------------- + + /** Force class loading / native init eagerly (used by tests). */ + public static String version() { + return readStaticCString(invokePtr(VERSION)); + } + + public static int objectCount() { + try { + return (int) OBJECT_COUNT.invokeExact(); + } catch (Throwable t) { + throw rethrow(t); + } + } + + public static void clearAll() { + try { + CLEAR_ALL.invokeExact(); + } catch (Throwable t) { + throw rethrow(t); + } + } + + public static void release(long handle) { + try { + RELEASE.invokeExact(handle); + } catch (Throwable t) { + throw rethrow(t); + } + } + + /** Build a bond from a {@code BondSpec} JSON; throws on a {@code 0} handle. */ + public static long buildBond(String specJson) { + return build(BOND_FROM_JSON, specJson, "bond"); + } + + /** Build a curve from a {@code CurveSpec} JSON; throws on a {@code 0} handle. */ + public static long buildCurve(String specJson) { + return build(CURVE_FROM_JSON, specJson, "curve"); + } + + public static String price(String requestJson) { return rpc(PRICE, requestJson); } + public static String risk(String requestJson) { return rpc(RISK, requestJson); } + public static String spread(String requestJson) { return rpc(SPREAD, requestJson); } + public static String cashflows(String requestJson) { return rpc(CASHFLOWS, requestJson); } + public static String curveQuery(String requestJson) { return rpc(CURVE_QUERY, requestJson); } + public static String makeWhole(String requestJson) { return rpc(MAKE_WHOLE, requestJson); } + public static String riskProfile(String requestJson) { return rpc(RISK_PROFILE, requestJson); } + public static String hedge(String requestJson) { return rpc(HEDGE, requestJson); } + public static String compare(String requestJson) { return rpc(COMPARE, requestJson); } + public static String markParse(String text) { return rpc(MARK_PARSE, text); } + + public static String describe(long handle) { + try { + MemorySegment ptr = (MemorySegment) DESCRIBE.invokeExact(handle); + return ownedToString(ptr); + } catch (Throwable t) { + throw rethrow(t); + } + } + + // ---- internals ---------------------------------------------------------- + + private static long build(MethodHandle ctor, String specJson, String kind) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment in = arena.allocateFrom(specJson); + long handle = (long) ctor.invokeExact(in); + if (handle == 0L) { + throw new ConvexException("invalid_input", "failed to build " + kind + ": " + lastError(), null); + } + return handle; + } catch (ConvexException e) { + throw e; + } catch (Throwable t) { + throw rethrow(t); + } + } + + /** + * Invoke a {@code char* fn(const char*)} RPC: marshal the request, read the + * returned owned C string, free it, and return the JSON text. Envelope + * decoding (and error throwing) is the caller's job (see {@code Json}). + */ + private static String rpc(MethodHandle fn, String requestJson) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment in = arena.allocateFrom(requestJson); + MemorySegment out = (MemorySegment) fn.invokeExact(in); + return ownedToString(out); + } catch (Throwable t) { + throw rethrow(t); + } + } + + /** Read a Rust-owned C string and free it. */ + private static String ownedToString(MemorySegment ptr) { + if (ptr.address() == 0L) { + throw new ConvexException("native function returned NULL"); + } + try { + // Returned pointers are zero-length; widen so the string is readable. + String s = ptr.reinterpret(Long.MAX_VALUE).getString(0); + STRING_FREE.invokeExact(ptr); + return s; + } catch (Throwable t) { + throw rethrow(t); + } + } + + /** Read a static (borrowed, never-freed) C string such as version/last_error. */ + private static String readStaticCString(MemorySegment ptr) { + if (ptr.address() == 0L) { + return ""; + } + return ptr.reinterpret(Long.MAX_VALUE).getString(0); + } + + private static MemorySegment invokePtr(MethodHandle fn) { + try { + return (MemorySegment) fn.invokeExact(); + } catch (Throwable t) { + throw rethrow(t); + } + } + + private static String lastError() { + return readStaticCString(invokePtr(LAST_ERROR)); + } + + private static RuntimeException rethrow(Throwable t) { + if (t instanceof ConvexException ce) { + return ce; + } + return new ConvexException("error", "native call failed: " + t, null); + } +} diff --git a/bindings/java/src/main/java/com/convex/internal/Json.java b/bindings/java/src/main/java/com/convex/internal/Json.java new file mode 100644 index 00000000..79ab2979 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/internal/Json.java @@ -0,0 +1,87 @@ +package com.convex.internal; + +import com.convex.ConvexException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * JSON marshalling and envelope decoding for the FFI boundary. + * + *

The native RPCs always answer with an envelope: + * {@code {"ok":"true","result":…}} or + * {@code {"ok":"false","error":{code,message,field?}}} (note {@code ok} is the + * string {@code "true"}/{@code "false"} — it is the serde tag, not a boolean). + * {@link #unwrap(String)} returns the {@code result} node or throws a typed + * {@link ConvexException}. + */ +public final class Json { + + private Json() {} + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public static ObjectNode object() { + return MAPPER.createObjectNode(); + } + + public static ObjectMapper mapper() { + return MAPPER; + } + + /** Parse arbitrary JSON text into a tree (used for round-tripping values). */ + public static JsonNode parse(String json) { + try { + return MAPPER.readTree(json); + } catch (Exception e) { + throw new ConvexException("parse", "invalid JSON from native layer: " + e.getMessage(), null); + } + } + + public static String write(JsonNode node) { + try { + return MAPPER.writeValueAsString(node); + } catch (Exception e) { + throw new ConvexException("serialize", e.getMessage(), null); + } + } + + /** + * Decode an envelope, returning the {@code result} node on success and + * throwing {@link ConvexException} on {@code "ok":"false"}. + */ + public static JsonNode unwrap(String envelopeJson) { + JsonNode env = parse(envelopeJson); + JsonNode ok = env.get("ok"); + if (ok != null && "true".equals(ok.asText())) { + JsonNode result = env.get("result"); + return result == null ? MAPPER.nullNode() : result; + } + JsonNode error = env.get("error"); + if (error != null) { + String code = text(error, "code", "error"); + String message = text(error, "message", "unknown error"); + String field = error.hasNonNull("field") ? error.get("field").asText() : null; + throw new ConvexException(code, message, field); + } + throw new ConvexException("malformed envelope: " + envelopeJson); + } + + private static String text(JsonNode node, String key, String fallback) { + JsonNode v = node.get(key); + return v == null || v.isNull() ? fallback : v.asText(); + } + + // ---- small typed readers (null-safe) ---- + + public static double dbl(JsonNode node, String field) { + JsonNode v = node.get(field); + return v == null || v.isNull() ? Double.NaN : v.asDouble(); + } + + public static java.util.OptionalDouble optDbl(JsonNode node, String field) { + JsonNode v = node.get(field); + return v == null || v.isNull() ? java.util.OptionalDouble.empty() + : java.util.OptionalDouble.of(v.asDouble()); + } +} diff --git a/bindings/java/src/main/java/com/convex/internal/NativeLoader.java b/bindings/java/src/main/java/com/convex/internal/NativeLoader.java new file mode 100644 index 00000000..2dd22c7e --- /dev/null +++ b/bindings/java/src/main/java/com/convex/internal/NativeLoader.java @@ -0,0 +1,92 @@ +package com.convex.internal; + +import com.convex.ConvexException; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; + +/** + * Locates and loads the bundled {@code convex-ffi} cdylib for the running + * platform. + * + *

The native libraries ship inside the jar under + * {@code /native//}; this extracts the matching one to a + * temp file (libraries can only be loaded from the real filesystem) and calls + * {@link System#load(String)}. Loading once per JVM is enough — the + * {@link ConvexFfi} static initializer drives it. + */ +final class NativeLoader { + + private NativeLoader() {} + + private static volatile boolean loaded = false; + + static synchronized void ensureLoaded() { + if (loaded) { + return; + } + String classifier = classifier(); + String libName = libName(classifier); + String resource = "/native/" + classifier + "/" + libName; + + try (InputStream in = NativeLoader.class.getResourceAsStream(resource)) { + if (in == null) { + throw new ConvexException( + "no bundled native library for this platform: " + resource + + " (build crates/convex-ffi for this target and stage it under" + + " src/main/resources/native//)"); + } + Path tmpDir = Files.createTempDirectory("convex-native"); + Path target = tmpDir.resolve(libName); + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + target.toFile().deleteOnExit(); + tmpDir.toFile().deleteOnExit(); + System.load(target.toAbsolutePath().toString()); + loaded = true; + } catch (IOException e) { + throw new ConvexException("error", "failed to extract native library: " + e.getMessage(), null); + } + } + + /** {@code -} matching the resource directory layout. */ + private static String classifier() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String arch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT); + + String osTag; + if (os.contains("win")) { + osTag = "windows"; + } else if (os.contains("mac") || os.contains("darwin")) { + osTag = "darwin"; + } else if (os.contains("nux") || os.contains("nix")) { + osTag = "linux"; + } else { + throw new ConvexException("unsupported OS: " + os); + } + + String archTag; + if (arch.equals("amd64") || arch.equals("x86_64")) { + archTag = "x86_64"; + } else if (arch.equals("aarch64") || arch.equals("arm64")) { + archTag = "aarch64"; + } else { + throw new ConvexException("unsupported architecture: " + arch); + } + + return osTag + "-" + archTag; + } + + private static String libName(String classifier) { + if (classifier.startsWith("windows")) { + return "convex_ffi.dll"; + } else if (classifier.startsWith("darwin")) { + return "libconvex_ffi.dylib"; + } else { + return "libconvex_ffi.so"; + } + } +} diff --git a/bindings/java/src/main/java/com/convex/internal/NativeRef.java b/bindings/java/src/main/java/com/convex/internal/NativeRef.java new file mode 100644 index 00000000..db73af75 --- /dev/null +++ b/bindings/java/src/main/java/com/convex/internal/NativeRef.java @@ -0,0 +1,21 @@ +package com.convex.internal; + +import java.lang.ref.Cleaner; + +/** Shared {@link Cleaner} for native registry handles (bonds, curves). */ +public final class NativeRef { + + private NativeRef() {} + + private static final Cleaner CLEANER = Cleaner.create(); + + /** + * Register {@code owner} for cleanup that releases {@code handle}. The + * returned {@link Cleaner.Cleanable} lets the owner release eagerly from + * {@code close()}; the cleaning action captures only the handle, never the + * owner, so it can't keep it alive. + */ + public static Cleaner.Cleanable releaseOnClean(Object owner, long handle) { + return CLEANER.register(owner, () -> ConvexFfi.release(handle)); + } +} diff --git a/bindings/java/src/main/resources/native/README.md b/bindings/java/src/main/resources/native/README.md new file mode 100644 index 00000000..ca136374 --- /dev/null +++ b/bindings/java/src/main/resources/native/README.md @@ -0,0 +1,24 @@ +# Native libraries + +This directory is populated at build time with the `convex-ffi` cdylib for each +supported platform, one subdirectory per classifier: + +``` +native/ + linux-x86_64/libconvex_ffi.so + linux-aarch64/libconvex_ffi.so + darwin-x86_64/libconvex_ffi.dylib + darwin-aarch64/libconvex_ffi.dylib + windows-x86_64/convex_ffi.dll +``` + +Build one with, e.g.: + +``` +cargo build -p convex-ffi --release +cp target/release/libconvex_ffi.so \ + bindings/java/src/main/resources/native/linux-x86_64/ +``` + +CI builds all targets in a matrix and stages them here before `mvn package`. +`NativeLoader` extracts the matching file at runtime. diff --git a/bindings/java/src/test/java/com/convex/SmokeTest.java b/bindings/java/src/test/java/com/convex/SmokeTest.java new file mode 100644 index 00000000..e5448631 --- /dev/null +++ b/bindings/java/src/test/java/com/convex/SmokeTest.java @@ -0,0 +1,138 @@ +package com.convex; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end smoke tests for the Java binding. These require the native + * {@code convex-ffi} cdylib on the classpath (staged under + * {@code src/main/resources/native/}); CI builds it before {@code mvn verify}. + */ +class SmokeTest { + + private static final LocalDate SETTLE = LocalDate.of(2025, 4, 15); + private static final Mark PAR_ISH = Mark.cleanPrice(new BigDecimal("99.5")); + + private static Bond fixedRate5pct() { + return FixedRateBond.builder() + .cusip("TEST10Y5") + .couponRate(new BigDecimal("0.05")) + .frequency(Frequency.SEMI_ANNUAL) + .issue(LocalDate.of(2025, 1, 15)) + .maturity(LocalDate.of(2035, 1, 15)) + .dayCount(DayCount.THIRTY_360_US) + .currency(Currency.USD) + .build(); + } + + private static YieldCurve flatCurve(double rate) { + return YieldCurve.discrete() + .referenceDate(LocalDate.of(2025, 1, 15)) + .point(0.5, rate).point(2.0, rate).point(5.0, rate) + .point(10.0, rate).point(30.0, rate) + .interpolation(Interpolation.LINEAR) + .build(); + } + + @Test + void versionIsNonEmpty() { + assertTrue(!Convex.version().isBlank()); + } + + @Test + void priceFromCleanMark() { + try (Bond bond = fixedRate5pct()) { + var r = ConvexAnalytics.price(bond, SETTLE, PAR_ISH); + assertEquals(99.5, r.cleanPrice(), 1e-9); + assertTrue(r.ytmDecimal() > 0.04 && r.ytmDecimal() < 0.06, "ytm=" + r.ytmDecimal()); + assertEquals(r.dirtyPrice(), r.cleanPrice() + r.accrued(), 1e-9); + } + } + + @Test + void riskHasDv01AndKeyRates() { + try (Bond bond = fixedRate5pct(); YieldCurve curve = flatCurve(0.04)) { + var r = ConvexAnalytics.risk(bond, SETTLE, PAR_ISH, curve, 2, 5, 10, 30); + assertNotEquals(0.0, r.dv01()); + assertTrue(r.modifiedDuration() > 0 && r.modifiedDuration() < 30); + assertEquals(4, r.keyRates().size()); + } + } + + @Test + void zSpreadIsFinite() { + try (Bond bond = fixedRate5pct(); YieldCurve curve = flatCurve(0.04)) { + var s = ConvexAnalytics.spread(bond, curve, SETTLE, PAR_ISH, SpreadType.Z_SPREAD); + assertTrue(Double.isFinite(s.spreadBps())); + assertTrue(s.spreadDv01().isPresent()); + } + } + + @Test + void hedgeAdvisorEndToEnd() { + try (Bond bond = fixedRate5pct(); YieldCurve curve = flatCurve(0.04)) { + RiskProfile pos = HedgeAdvisor.positionRisk() + .bond(bond).settlement(SETTLE).mark(PAR_ISH) + .notionalFace(new BigDecimal("10000000")) + .curve(curve).curveId("USD.SOFR") + .keyRateTenors(2, 5, 10, 30) + .compute(); + + assertNotEquals(0.0, pos.dv01()); + assertEquals(4, pos.keyRateBuckets().size()); + assertTrue(pos.marketValue() > 9_000_000 && pos.marketValue() < 11_000_000); + + HedgeProposal df = HedgeAdvisor.durationFutures(pos, curve, SETTLE); + HedgeProposal sw = HedgeAdvisor.swap(pos, curve, SETTLE, Constraints.none()); + assertTrue(df.tradeCount() >= 1); + assertTrue(Math.abs(df.residualDv01()) < Math.abs(pos.dv01())); + + ComparisonReport report = HedgeAdvisor.compare(pos, List.of(df, sw), Constraints.none(), true); + assertEquals(2, report.rowCount()); + assertTrue(report.recommendedRowIndex() < report.rowCount()); + assertTrue(report.narrative().orElse("").length() > 0); + } + } + + @Test + void buildsAllBondShapes() { + // FRN, zero-coupon, and sinking-fund builders each produce a usable + // handle with a non-empty cashflow schedule. + try (Bond frn = FloatingRateNote.builder() + .cusip("FRN00000A").spreadBps(new BigDecimal("75")) + .rateIndex(RateIndex.SOFR) + .issue(LocalDate.of(2025, 1, 15)).maturity(LocalDate.of(2030, 1, 15)) + .build(); + Bond zero = ZeroCouponBond.builder() + .cusip("ZERO0000A") + .issue(LocalDate.of(2025, 1, 15)).maturity(LocalDate.of(2030, 1, 15)) + .build(); + Bond sinker = SinkingFundBond.builder() + .cusip("SINK0000A").couponRate(new BigDecimal("0.05")) + .issue(LocalDate.of(2025, 1, 15)).maturity(LocalDate.of(2030, 1, 15)) + .sink(LocalDate.of(2028, 1, 15), 25.0) + .sink(LocalDate.of(2029, 1, 15), 25.0) + .build()) { + + assertTrue(ConvexAnalytics.cashflows(frn, SETTLE).size() >= 1); + assertEquals(1, ConvexAnalytics.cashflows(zero, SETTLE).size(), "zero has a single redemption flow"); + assertTrue(ConvexAnalytics.cashflows(sinker, SETTLE).size() >= 1); + } + } + + @Test + void handlesAreReleased() { + Convex.clearAll(); + try (Bond bond = fixedRate5pct(); YieldCurve curve = flatCurve(0.04)) { + assertEquals(2, Convex.objectCount()); + } + assertEquals(0, Convex.objectCount(), "try-with-resources must release native objects"); + } +} From 4dd0b16facc423b4e6894e3382c64e06c0303e36 Mon Sep 17 00:00:00 2001 From: sujitn Date: Fri, 29 May 2026 21:49:41 +0100 Subject: [PATCH 3/5] fix(excel): scope handles per cell and parallelize stateless UDFs - Build bonds/curves with registry_key = calling cell (xlfCaller) so two cells referencing the same CUSIP no longer evict each other's handle. Previously, because Excel's calc order across cells is nondeterministic, the losing cell would intermittently show stale or #ERROR values on recalc. - Mark the stateless analytics UDFs (PRICE/RISK/SPREAD/CASHFLOWS/MW/ CURVE.QUERY) IsThreadSafe for multi-threaded recalc; builders stay on the main thread (they mutate the registry and use xlfCaller). --- excel/Convex.Excel/Cx.cs | 24 ++++++++++++++++++++++++ excel/Convex.Excel/Functions.cs | 18 ++++++++++++------ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/excel/Convex.Excel/Cx.cs b/excel/Convex.Excel/Cx.cs index e61a814a..55fbe850 100644 --- a/excel/Convex.Excel/Cx.cs +++ b/excel/Convex.Excel/Cx.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; +using ExcelDna.Integration; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -73,6 +74,7 @@ internal static class Cx public static ulong BuildBond(JObject spec) { + ApplyCallerKey(spec); ulong h = convex_bond_from_json(spec.ToString(Formatting.None)); if (h == InvalidHandle) throw new ConvexException(LastError() ?? "bond build failed"); @@ -81,12 +83,34 @@ public static ulong BuildBond(JObject spec) public static ulong BuildCurve(JObject spec) { + ApplyCallerKey(spec); ulong h = convex_curve_from_json(spec.ToString(Formatting.None)); if (h == InvalidHandle) throw new ConvexException(LastError() ?? "curve build failed"); return h; } + // Scope the registry slot to the calling cell so two cells that + // reference the same CUSIP / curve name don't evict each other's handle + // on recalc (Excel's calc order is nondeterministic). No-op outside a + // cell calculation (ribbon/forms) — there the spec's own id is the key. + private static void ApplyCallerKey(JObject spec) + { + if (spec["registry_key"] != null) return; + try + { + if (XlCall.Excel(XlCall.xlfCaller) is ExcelReference r) + { + var sheet = XlCall.Excel(XlCall.xlSheetNm, r) as string ?? ""; + spec["registry_key"] = $"{sheet}!R{r.RowFirst}C{r.ColumnFirst}"; + } + } + catch + { + // xlfCaller is only valid during cell calculation; ignore otherwise. + } + } + public static void Release(ulong handle) => convex_release(handle); public static int ObjectCount() => convex_object_count(); public static void ClearAll() => convex_clear_all(); diff --git a/excel/Convex.Excel/Functions.cs b/excel/Convex.Excel/Functions.cs index 38c2ed82..d1117550 100644 --- a/excel/Convex.Excel/Functions.cs +++ b/excel/Convex.Excel/Functions.cs @@ -18,6 +18,12 @@ namespace Convex.Excel // Adding a new bond shape, spread family, or pricing convention does not // touch this file. The Rust DTO enum picks it up; the existing UDFs route // it. + // + // Threading: the stateless analytics (PRICE/RISK/SPREAD/CASHFLOWS/MW/ + // CURVE.QUERY) are IsThreadSafe so Excel's multi-threaded recalc runs them in + // parallel — the native registry guards reads with a lock and clones objects + // out before computing. Builders mutate the registry and use xlfCaller, so + // they stay on the main calc thread. public static class Functions { // =================================================================== @@ -189,7 +195,7 @@ public static object CxCurveBootstrap( [ExcelFunction(Name = "CX.PRICE", Description = "Prices a bond against a trader mark and returns clean/dirty/accrued/ytm.", - Category = "Convex Pricing")] + Category = "Convex Pricing", IsThreadSafe = true)] public static object CxPrice( [ExcelArgument("Bond handle")] object bondRef, [ExcelArgument("Settlement date")] DateTime settlement, @@ -215,7 +221,7 @@ public static object CxPrice( [ExcelFunction(Name = "CX.RISK", Description = "Returns risk metrics. Default returns a 2D grid; pass a metric name for a scalar.", - Category = "Convex Risk")] + Category = "Convex Risk", IsThreadSafe = true)] public static object CxRisk( object bondRef, DateTime settlement, @@ -250,7 +256,7 @@ public static object CxRisk( [ExcelFunction(Name = "CX.SPREAD", Description = "Computes a spread (Z, G, I, ASW, OAS, DM, …) at the given mark.", - Category = "Convex Spreads")] + Category = "Convex Spreads", IsThreadSafe = true)] public static object CxSpread( object bondRef, object curveRef, @@ -278,7 +284,7 @@ public static object CxSpread( [ExcelFunction(Name = "CX.CASHFLOWS", Description = "Bond cashflow schedule on or after settlement.", - Category = "Convex Bonds")] + Category = "Convex Bonds", IsThreadSafe = true)] public static object CxCashflows(object bondRef, DateTime settlement) => Safe(() => { @@ -294,7 +300,7 @@ public static object CxCashflows(object bondRef, DateTime settlement) => [ExcelFunction(Name = "CX.MW", Description = "Make-whole call price for a callable bond carrying a make-whole spread. " + "Returns price (default), discount_rate, or spread_bps.", - Category = "Convex Bonds")] + Category = "Convex Bonds", IsThreadSafe = true)] public static object CxMakeWhole( object bondRef, [ExcelArgument("Hypothetical call date")] DateTime callDate, @@ -321,7 +327,7 @@ public static object CxMakeWhole( [ExcelFunction(Name = "CX.CURVE.QUERY", Description = "Read a curve point: zero rate (default), discount factor, or forward rate.", - Category = "Convex Curves")] + Category = "Convex Curves", IsThreadSafe = true)] public static object CxCurveQuery( object curveRef, [ExcelArgument("Tenor in years")] double tenor, From 2034036fb80a75e0e3705638f668f4d529928430 Mon Sep 17 00:00:00 2001 From: sujitn Date: Fri, 29 May 2026 23:35:54 +0100 Subject: [PATCH 4/5] Address review feedback - ffi(schemas): add advisor response schemas (RiskProfile, HedgeProposal, CompareResponse) so each advisor verb has request/response symmetry. - ffi(tests): give the zero_coupon_5y / frn_5y helpers unique per-build ids (uid) so parallel runs don't evict each other's handle; apply rustfmt. - java: fail fast on null builder fields in Specs.fixedRate, ZeroCouponBond, and FloatingRateNote instead of NPE-ing on .wire() / emitting null JSON. - ci: declare least-privilege `permissions: contents: read` on both Java workflows; trigger java.yml when convex-ffi's dependency crates change (convex-core/-math/-curves/-bonds). - docs: add language identifiers to the native/README fenced code blocks. --- .github/workflows/java-release.yml | 5 ++ .github/workflows/java.yml | 12 +++++ .../java/com/convex/FloatingRateNote.java | 5 ++ .../java/src/main/java/com/convex/Specs.java | 4 ++ .../main/java/com/convex/ZeroCouponBond.java | 4 ++ .../java/src/main/resources/native/README.md | 4 +- crates/convex-ffi/src/dispatch.rs | 4 +- crates/convex-ffi/src/schemas.rs | 54 +++++++++++++++++++ crates/convex-ffi/tests/smoke.rs | 23 +++++--- 9 files changed, 103 insertions(+), 12 deletions(-) diff --git a/.github/workflows/java-release.yml b/.github/workflows/java-release.yml index 4c8cba75..02e53ec3 100644 --- a/.github/workflows/java-release.yml +++ b/.github/workflows/java-release.yml @@ -6,6 +6,11 @@ on: tags: - 'java-v*' +# Deploy goes to Maven Central via the Sonatype plugin (repo secrets), so the +# job needs no write scope on the GitHub token — only read to check out. +permissions: + contents: read + env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 7a623447..f6962f56 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -7,6 +7,10 @@ on: - 'bindings/java/**' - 'crates/convex-ffi/**' - 'crates/convex-analytics/**' + - 'crates/convex-bonds/**' + - 'crates/convex-core/**' + - 'crates/convex-curves/**' + - 'crates/convex-math/**' - '.github/workflows/java.yml' pull_request: branches: [main] @@ -14,8 +18,16 @@ on: - 'bindings/java/**' - 'crates/convex-ffi/**' - 'crates/convex-analytics/**' + - 'crates/convex-bonds/**' + - 'crates/convex-core/**' + - 'crates/convex-curves/**' + - 'crates/convex-math/**' - '.github/workflows/java.yml' +# Build + test only; no repo writes. +permissions: + contents: read + env: CARGO_TERM_COLOR: always diff --git a/bindings/java/src/main/java/com/convex/FloatingRateNote.java b/bindings/java/src/main/java/com/convex/FloatingRateNote.java index ce63ef5f..943577b5 100644 --- a/bindings/java/src/main/java/com/convex/FloatingRateNote.java +++ b/bindings/java/src/main/java/com/convex/FloatingRateNote.java @@ -54,6 +54,11 @@ public Bond build() { Specs.require(spreadBps, "spreadBps"); Specs.require(issue, "issue"); Specs.require(maturity, "maturity"); + Specs.require(rateIndex, "rateIndex"); + Specs.require(frequency, "frequency"); + Specs.require(dayCount, "dayCount"); + Specs.require(currency, "currency"); + Specs.require(faceValue, "faceValue"); ObjectNode spec = Json.object(); spec.put("type", "floating_rate"); diff --git a/bindings/java/src/main/java/com/convex/Specs.java b/bindings/java/src/main/java/com/convex/Specs.java index 8f13923b..813a4f49 100644 --- a/bindings/java/src/main/java/com/convex/Specs.java +++ b/bindings/java/src/main/java/com/convex/Specs.java @@ -24,6 +24,10 @@ static ObjectNode fixedRate(String type, String cusip, String isin, String name, require(couponRate, "couponRate"); require(issue, "issue"); require(maturity, "maturity"); + require(frequency, "frequency"); + require(dayCount, "dayCount"); + require(currency, "currency"); + require(faceValue, "faceValue"); ObjectNode n = Json.object(); n.put("type", type); diff --git a/bindings/java/src/main/java/com/convex/ZeroCouponBond.java b/bindings/java/src/main/java/com/convex/ZeroCouponBond.java index 929d2ab1..faa3d40b 100644 --- a/bindings/java/src/main/java/com/convex/ZeroCouponBond.java +++ b/bindings/java/src/main/java/com/convex/ZeroCouponBond.java @@ -42,6 +42,10 @@ public static final class Builder { public Bond build() { Specs.require(issue, "issue"); Specs.require(maturity, "maturity"); + Specs.require(compounding, "compounding"); + Specs.require(dayCount, "dayCount"); + Specs.require(currency, "currency"); + Specs.require(faceValue, "faceValue"); ObjectNode spec = Json.object(); spec.put("type", "zero_coupon"); diff --git a/bindings/java/src/main/resources/native/README.md b/bindings/java/src/main/resources/native/README.md index ca136374..b4de2bd5 100644 --- a/bindings/java/src/main/resources/native/README.md +++ b/bindings/java/src/main/resources/native/README.md @@ -3,7 +3,7 @@ This directory is populated at build time with the `convex-ffi` cdylib for each supported platform, one subdirectory per classifier: -``` +```text native/ linux-x86_64/libconvex_ffi.so linux-aarch64/libconvex_ffi.so @@ -14,7 +14,7 @@ native/ Build one with, e.g.: -``` +```sh cargo build -p convex-ffi --release cp target/release/libconvex_ffi.so \ bindings/java/src/main/resources/native/linux-x86_64/ diff --git a/crates/convex-ffi/src/dispatch.rs b/crates/convex-ffi/src/dispatch.rs index c68d2620..55d412ca 100644 --- a/crates/convex-ffi/src/dispatch.rs +++ b/crates/convex-ffi/src/dispatch.rs @@ -16,8 +16,8 @@ use std::sync::Arc; use convex_analytics::dto::{ CashflowEntry, CashflowRequest, CashflowResponse, CompareRequest, CompareResponse, - CurveQueryKind, CurveQueryRequest, CurveQueryResponse, HedgeRequest, HedgeStrategyKind, KeyRate, - MakeWholeRequest, MakeWholeResponse, MarkInput, PricingRequest, PricingResponse, + CurveQueryKind, CurveQueryRequest, CurveQueryResponse, HedgeRequest, HedgeStrategyKind, + KeyRate, MakeWholeRequest, MakeWholeResponse, MarkInput, PricingRequest, PricingResponse, RiskProfileRequest, RiskRequest, RiskResponse, SpreadRequest, SpreadResponse, }; use convex_analytics::pricing::price_from_mark; diff --git a/crates/convex-ffi/src/schemas.rs b/crates/convex-ffi/src/schemas.rs index 4ce4ce55..e408a41e 100644 --- a/crates/convex-ffi/src/schemas.rs +++ b/crates/convex-ffi/src/schemas.rs @@ -22,8 +22,11 @@ pub fn lookup(name: &str) -> Result { "MakeWholeRequest" => MAKE_WHOLE_REQUEST, "MakeWholeResponse" => MAKE_WHOLE_RESPONSE, "RiskProfileRequest" => RISK_PROFILE_REQUEST, + "RiskProfile" => RISK_PROFILE_RESPONSE, "HedgeRequest" => HEDGE_REQUEST, + "HedgeProposal" => HEDGE_PROPOSAL_RESPONSE, "CompareRequest" => COMPARE_REQUEST, + "CompareResponse" => COMPARE_RESPONSE, other => return Err(format!("unknown schema name {other:?}")), }; Ok(body.to_string()) @@ -271,3 +274,54 @@ const COMPARE_REQUEST: &str = r##"{ "narrate": {"type": "boolean", "description": "Include a deterministic text narrative (default false)"} } }"##; + +const RISK_PROFILE_RESPONSE: &str = r##"{ + "title": "RiskProfile", + "description": "Position risk profile returned by convex_risk_profile; round-trips into HedgeRequest/CompareRequest.", + "type": "object", + "required": ["currency","settlement","notional_face","market_value","dv01"], + "properties": { + "position_id": {"type": ["string","null"]}, + "currency": {"type": "string"}, + "settlement": {"type": "string", "format": "date"}, + "notional_face": {"type": "number"}, + "clean_price_per_100": {"type": "number"}, + "dirty_price_per_100": {"type": "number"}, + "accrued_per_100": {"type": "number"}, + "market_value": {"type": "number"}, + "ytm_decimal": {"type": "number"}, + "modified_duration_years": {"type": "number"}, + "macaulay_duration_years": {"type": "number"}, + "convexity": {"type": "number"}, + "dv01": {"type": "number"}, + "key_rate_buckets": {"type": "array", "items": {"type":"object","properties":{"tenor_years":{"type":"number"},"partial_dv01":{"type":"number"}}}}, + "provenance": {"type": "object", "description": "{curves_used[], cost_model, advisor_version, oas_volatility?}"} + } +}"##; + +const HEDGE_PROPOSAL_RESPONSE: &str = r##"{ + "title": "HedgeProposal", + "description": "Proposed hedge returned by convex_hedge; feeds back into CompareRequest.proposals.", + "type": "object", + "required": ["strategy","trades","residual","cost_bps"], + "properties": { + "strategy": {"type": "string"}, + "trades": {"type": "array", "items": {"type": "object"}, "description": "HedgeTrade {instrument, quantity, dv01, key_rate_buckets?}"}, + "residual": {"type": "object", "description": "ResidualRisk {residual_dv01, residual_buckets?, residual_krd_l1_norm}"}, + "cost_bps": {"type": "number", "description": "Round-trip cost as bps of position market value"}, + "cost_total": {"type": "number"}, + "tradeoffs": {"type": "object", "description": "{strengths[], weaknesses[]}"}, + "provenance": {"type": "object"} + } +}"##; + +const COMPARE_RESPONSE: &str = r##"{ + "title": "CompareResponse", + "description": "Result of convex_compare: a ComparisonReport plus an optional narrative.", + "type": "object", + "required": ["report"], + "properties": { + "report": {"type": "object", "description": "ComparisonReport {currency, position_market_value, position_dv01, rows:[ComparisonRow], recommendation:{strategy,row_index,reasons[]}}"}, + "narrative": {"type": ["string","null"]} + } +}"##; diff --git a/crates/convex-ffi/tests/smoke.rs b/crates/convex-ffi/tests/smoke.rs index e822c0e6..332a4a36 100644 --- a/crates/convex-ffi/tests/smoke.rs +++ b/crates/convex-ffi/tests/smoke.rs @@ -348,7 +348,7 @@ fn asw_par_returns_a_finite_spread() { fn zero_coupon_5y() -> Value { json!({ "type": "zero_coupon", - "cusip": "TESTZ0005", + "cusip": uid("ZC"), "maturity": "2030-01-15", "issue": "2025-01-15", "compounding": "SemiAnnual", @@ -396,7 +396,7 @@ fn zero_risk_closed_form() { fn frn_5y() -> Value { json!({ "type": "floating_rate", - "cusip": "TESTFR005", + "cusip": uid("FRN"), "spread_bps": 75, "maturity": "2030-01-15", "issue": "2025-01-15", @@ -708,7 +708,10 @@ fn duration_futures_proposes_a_hedge() { "strategy label = {strategy}" ); let trades = proposal["trades"].as_array().unwrap(); - assert!(!trades.is_empty(), "expected at least one trade: {proposal}"); + assert!( + !trades.is_empty(), + "expected at least one trade: {proposal}" + ); // A duration hedge should shrink |residual DV01| well below the // position's own DV01. let pos_dv01 = position["dv01"].as_f64().unwrap().abs(); @@ -829,9 +832,8 @@ fn registry_key_scopes_eviction_per_cell() { "face_value": 100 }) }; - let req = |h: u64| { - json!({"bond": h, "settlement": "2025-04-15", "mark": "99.5C"}).to_string() - }; + let req = + |h: u64| json!({"bond": h, "settlement": "2025-04-15", "mark": "99.5C"}).to_string(); let a = build_handle(spec(&key_a)); let b = build_handle(spec(&key_b)); @@ -845,10 +847,15 @@ fn registry_key_scopes_eviction_per_cell() { let a2 = build_handle(spec(&key_a)); assert_ne!(a2, a); assert_eq!( - rpc(convex_ffi::convex_price, &req(a))["ok"], "false", + rpc(convex_ffi::convex_price, &req(a))["ok"], + "false", "A's old handle must be evicted by its own cell's rebuild" ); - assert_eq!(rpc(convex_ffi::convex_price, &req(b))["ok"], "true", "B untouched"); + assert_eq!( + rpc(convex_ffi::convex_price, &req(b))["ok"], + "true", + "B untouched" + ); assert_eq!(rpc(convex_ffi::convex_price, &req(a2))["ok"], "true"); } } From 580a3fbecb6a6173e7e7d93b500cd0e4c194bf23 Mon Sep 17 00:00:00 2001 From: sujitn Date: Sat, 30 May 2026 06:58:17 +0100 Subject: [PATCH 5/5] Address second review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - java: match the Rust enums — Currency gains the 11 missing ISO codes (NZD/SEK/NOK/DKK/HKD/SGD/CNY/INR/BRL/MXN/ZAR); DayCount drops Thirty360Icma/ Simple/Daily (no such DayCountConvention variants) and adds Act365Leap/ ActActAfb/Thirty360EIsda/Thirty360German. - java: HedgeAdvisor uses the position's own curve_id (from provenance) instead of hardcoding "discount", and rejects a compare response missing "report". - java: wrap System.load failures as ConvexException; validate FRN cap >= floor and that make-whole call style carries a spread. - java: drop CallableBond.put() — the FFI's callable builder ignores put_schedule, so it was unwired surface. - ffi: cbindgen build script reruns on any src change; constrain the position field to an object in the hedge/compare request schemas. - ci(java-release): pin actions to commit SHAs (release pipeline carries publishing secrets) and set persist-credentials: false; java.yml also sets persist-credentials: false. --- .github/workflows/java-release.yml | 16 ++++++++++------ .github/workflows/java.yml | 2 ++ .../src/main/java/com/convex/CallableBond.java | 14 +++++--------- .../java/src/main/java/com/convex/Currency.java | 2 +- .../java/src/main/java/com/convex/DayCount.java | 13 +++++++------ .../main/java/com/convex/FloatingRateNote.java | 3 +++ .../src/main/java/com/convex/HedgeAdvisor.java | 11 ++++++++++- .../java/com/convex/internal/NativeLoader.java | 3 +++ crates/convex-ffi/build.rs | 4 ++-- crates/convex-ffi/src/schemas.rs | 4 ++-- 10 files changed, 45 insertions(+), 27 deletions(-) diff --git a/.github/workflows/java-release.yml b/.github/workflows/java-release.yml index 02e53ec3..8f53c1fc 100644 --- a/.github/workflows/java-release.yml +++ b/.github/workflows/java-release.yml @@ -29,9 +29,11 @@ jobs: - { runner: macos-latest, target: aarch64-apple-darwin, classifier: darwin-aarch64, lib: libconvex_ffi.dylib } - { runner: windows-latest, target: x86_64-pc-windows-msvc, classifier: windows-x86_64, lib: convex_ffi.dll } steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: targets: ${{ matrix.target }} @@ -44,7 +46,7 @@ jobs: - name: Build cdylib run: cargo build -p convex-ffi --release --target ${{ matrix.target }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ matrix.classifier }} path: target/${{ matrix.target }}/release/${{ matrix.lib }} @@ -56,10 +58,12 @@ jobs: needs: natives runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Download native libraries - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: staging @@ -71,7 +75,7 @@ jobs: cp -r staging/* bindings/java/src/main/resources/native/ - name: Set up JDK 22 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: temurin java-version: '22' diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index f6962f56..3a613d88 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -39,6 +39,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/bindings/java/src/main/java/com/convex/CallableBond.java b/bindings/java/src/main/java/com/convex/CallableBond.java index 3c002257..20e538c2 100644 --- a/bindings/java/src/main/java/com/convex/CallableBond.java +++ b/bindings/java/src/main/java/com/convex/CallableBond.java @@ -45,7 +45,6 @@ public static final class Builder { private CallStyle callStyle = CallStyle.AMERICAN; private Double makeWholeSpreadBps; private final List calls = new ArrayList<>(); - private final List puts = new ArrayList<>(); public Builder cusip(String v) { this.cusip = v; return this; } public Builder isin(String v) { this.isin = v; return this; } @@ -70,15 +69,15 @@ public Builder call(CallEntry entry) { return this; } - public Builder put(LocalDate date, double pricePer100) { - puts.add(new CallEntry(date, pricePer100)); - return this; - } - public Bond build() { + // The FFI's callable builder consumes only the call schedule (puts + // are not yet wired), so at least one call entry is required. if (calls.isEmpty()) { throw new IllegalArgumentException("callable bond requires at least one call entry"); } + if (callStyle == CallStyle.MAKE_WHOLE && makeWholeSpreadBps == null) { + throw new IllegalArgumentException("make-whole call style requires makeWholeSpreadBps"); + } ObjectNode spec = Specs.fixedRate( "callable", cusip, isin, name, couponRate, frequency, issue, maturity, dayCount, currency, faceValue); @@ -87,9 +86,6 @@ public Bond build() { spec.put("make_whole_spread_bps", makeWholeSpreadBps); } spec.set("call_schedule", entries(calls)); - if (!puts.isEmpty()) { - spec.set("put_schedule", entries(puts)); - } return Bond.fromSpecJson(Json.write(spec)); } diff --git a/bindings/java/src/main/java/com/convex/Currency.java b/bindings/java/src/main/java/com/convex/Currency.java index a9694a52..8bacf557 100644 --- a/bindings/java/src/main/java/com/convex/Currency.java +++ b/bindings/java/src/main/java/com/convex/Currency.java @@ -2,7 +2,7 @@ /** ISO currency. Wire values match {@code convex_core::types::Currency} variant names. */ public enum Currency { - USD, EUR, GBP, JPY, CHF, CAD, AUD; + USD, EUR, GBP, JPY, CHF, CAD, AUD, NZD, SEK, NOK, DKK, HKD, SGD, CNY, INR, BRL, MXN, ZAR; /** The JSON token the native layer expects (the variant name). */ public String wire() { diff --git a/bindings/java/src/main/java/com/convex/DayCount.java b/bindings/java/src/main/java/com/convex/DayCount.java index 75007e78..1d652794 100644 --- a/bindings/java/src/main/java/com/convex/DayCount.java +++ b/bindings/java/src/main/java/com/convex/DayCount.java @@ -2,15 +2,16 @@ /** Day-count convention. Wire values match {@code convex_core::daycounts::DayCountConvention}. */ public enum DayCount { - THIRTY_360_US("Thirty360US"), - THIRTY_360_E("Thirty360E"), - THIRTY_360_ICMA("Thirty360Icma"), ACT_360("Act360"), ACT_365_FIXED("Act365Fixed"), - ACT_ACT_ICMA("ActActIcma"), + ACT_365_LEAP("Act365Leap"), ACT_ACT_ISDA("ActActIsda"), - SIMPLE("Simple"), - DAILY("Daily"); + ACT_ACT_ICMA("ActActIcma"), + ACT_ACT_AFB("ActActAfb"), + THIRTY_360_US("Thirty360US"), + THIRTY_360_E("Thirty360E"), + THIRTY_360_E_ISDA("Thirty360EIsda"), + THIRTY_360_GERMAN("Thirty360German"); private final String wire; diff --git a/bindings/java/src/main/java/com/convex/FloatingRateNote.java b/bindings/java/src/main/java/com/convex/FloatingRateNote.java index 943577b5..e85cc3f8 100644 --- a/bindings/java/src/main/java/com/convex/FloatingRateNote.java +++ b/bindings/java/src/main/java/com/convex/FloatingRateNote.java @@ -59,6 +59,9 @@ public Bond build() { Specs.require(dayCount, "dayCount"); Specs.require(currency, "currency"); Specs.require(faceValue, "faceValue"); + if (cap != null && floor != null && cap.compareTo(floor) < 0) { + throw new IllegalArgumentException("FRN cap (" + cap + ") must be >= floor (" + floor + ")"); + } ObjectNode spec = Json.object(); spec.put("type", "floating_rate"); diff --git a/bindings/java/src/main/java/com/convex/HedgeAdvisor.java b/bindings/java/src/main/java/com/convex/HedgeAdvisor.java index 73366069..7c70a8e9 100644 --- a/bindings/java/src/main/java/com/convex/HedgeAdvisor.java +++ b/bindings/java/src/main/java/com/convex/HedgeAdvisor.java @@ -115,7 +115,7 @@ private static HedgeProposal strategy(String kind, RiskProfile p, YieldCurve cur req.put("strategy", kind); req.set("position", p.node()); req.put("curve", curve.handle()); - req.put("curve_id", "discount"); + req.put("curve_id", curveIdOf(p)); req.put("settlement", settlement.toString()); if (c != null) { req.set("constraints", c.toJson()); @@ -123,6 +123,12 @@ private static HedgeProposal strategy(String kind, RiskProfile p, YieldCurve cur return new HedgeProposal(Json.unwrap(ConvexFfi.hedge(Json.write(req)))); } + /** The curve id the position was built with, so provenance stays consistent. */ + private static String curveIdOf(RiskProfile p) { + JsonNode used = p.node().path("provenance").path("curves_used"); + return used.isArray() && !used.isEmpty() ? used.get(0).asText("discount") : "discount"; + } + // ---- compare ------------------------------------------------------------ public static ComparisonReport compare(RiskProfile position, List proposals, @@ -140,6 +146,9 @@ public static ComparisonReport compare(RiskProfile position, List JsonNode result = Json.unwrap(ConvexFfi.compare(Json.write(req))); JsonNode report = result.get("report"); + if (report == null || report.isNull()) { + throw new ConvexException("compare response missing 'report'"); + } String narrative = result.hasNonNull("narrative") ? result.get("narrative").asText() : null; return new ComparisonReport(report, narrative); } diff --git a/bindings/java/src/main/java/com/convex/internal/NativeLoader.java b/bindings/java/src/main/java/com/convex/internal/NativeLoader.java index 2dd22c7e..29a34e45 100644 --- a/bindings/java/src/main/java/com/convex/internal/NativeLoader.java +++ b/bindings/java/src/main/java/com/convex/internal/NativeLoader.java @@ -49,6 +49,9 @@ static synchronized void ensureLoaded() { loaded = true; } catch (IOException e) { throw new ConvexException("error", "failed to extract native library: " + e.getMessage(), null); + } catch (UnsatisfiedLinkError | SecurityException e) { + throw new ConvexException( + "error", "failed to load native library " + resource + ": " + e.getMessage(), null); } } diff --git a/crates/convex-ffi/build.rs b/crates/convex-ffi/build.rs index ff9d068e..cfaf1a14 100644 --- a/crates/convex-ffi/build.rs +++ b/crates/convex-ffi/build.rs @@ -17,8 +17,8 @@ fn main() { let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); let config = cbindgen::Config::from_root_or_default(&crate_dir); - // Only the lib's own source defines the ABI; re-run when it changes. - println!("cargo:rerun-if-changed=src/lib.rs"); + // cbindgen parses the whole crate, so re-run on any source change. + println!("cargo:rerun-if-changed=src"); println!("cargo:rerun-if-changed=cbindgen.toml"); match cbindgen::Builder::new() diff --git a/crates/convex-ffi/src/schemas.rs b/crates/convex-ffi/src/schemas.rs index e408a41e..b242b3d8 100644 --- a/crates/convex-ffi/src/schemas.rs +++ b/crates/convex-ffi/src/schemas.rs @@ -253,7 +253,7 @@ const HEDGE_REQUEST: &str = r##"{ "required": ["strategy","position","curve","settlement"], "properties": { "strategy": {"enum": ["duration_futures","barbell_futures","cash_bond_pair","interest_rate_swap","key_rate_futures"]}, - "position": {"description": "RiskProfile from convex_risk_profile"}, + "position": {"type": "object", "description": "RiskProfile from convex_risk_profile"}, "constraints": {"type": "object", "description": "Constraints {max_residual_dv01?, max_cost_bps?, allowed_strategies?, max_residual_per_bucket?}"}, "curve": {"type": "integer"}, "curve_id": {"type": "string"}, @@ -268,7 +268,7 @@ const COMPARE_REQUEST: &str = r##"{ "type": "object", "required": ["position","proposals"], "properties": { - "position": {"description": "RiskProfile"}, + "position": {"type": "object", "description": "RiskProfile"}, "proposals": {"type": "array", "items": {"type": "object"}, "description": "HedgeProposal[]"}, "constraints": {"type": "object"}, "narrate": {"type": "boolean", "description": "Include a deterministic text narrative (default false)"}