From c7b7ea9b67ebc78f12174869a72d2d289ee9eae6 Mon Sep 17 00:00:00 2001 From: sujitn Date: Sun, 31 May 2026 14:57:24 +0100 Subject: [PATCH 1/5] refactor(math,core): clean up linear algebra solvers and validation test redundancy --- crates/convex-core/src/validation_tests.rs | 539 ------------------- crates/convex-math/src/linear_algebra/mod.rs | 77 +-- 2 files changed, 2 insertions(+), 614 deletions(-) diff --git a/crates/convex-core/src/validation_tests.rs b/crates/convex-core/src/validation_tests.rs index 0e10c34e..3447c00a 100644 --- a/crates/convex-core/src/validation_tests.rs +++ b/crates/convex-core/src/validation_tests.rs @@ -357,542 +357,3 @@ mod accrued_interest_validation { assert!((accrued - dec!(25.3125)).abs() < dec!(0.0001)); } } - -#[cfg(test)] -mod price_yield_validation { - // ========================================================================= - // Price from Yield Test Cases (Section 3.1) - // ========================================================================= - - #[test] - fn test_prc_001_treasury_at_premium() { - // US Treasury at Premium - // Face: $100, Coupon: 4.375%, 10-year maturity - // Settlement: On coupon date (no accrued) - // YTM: 4.50% (semi-annual bond basis) - - let face_value: f64 = 100.0; - let coupon_rate: f64 = 0.04375; - let ytm: f64 = 0.045; - let periods: i32 = 20; // 10 years × 2 - let frequency: i32 = 2; - - // Semi-annual coupon payment - let pmt: f64 = face_value * coupon_rate / frequency as f64; // 2.1875 - let r: f64 = ytm / frequency as f64; // 0.0225 - - // Present value calculation - // PV = PMT × [(1 - (1+r)^-n) / r] + Face / (1+r)^n - let pv_annuity: f64 = pmt * (1.0 - (1.0 + r).powi(-periods)) / r; - let pv_face: f64 = face_value / (1.0 + r).powi(periods); - let clean_price: f64 = pv_annuity + pv_face; - - // Since coupon (4.375%) < YTM (4.5%), bond trades at discount (< 100) - // PV_annuity = 2.1875 × [(1 - 1.0225^-20) / 0.0225] - // = 2.1875 × 16.3514 = 35.769 - // PV_face = 100 / 1.0225^20 = 64.084 - // Total ≈ 99.85 - assert!(clean_price > 99.0 && clean_price < 100.0); - // More precise: verify discount bond relationship - assert!(clean_price < 100.0); // Discount bond - } - - #[test] - fn test_prc_002_corporate_at_discount() { - // Corporate Bond at Discount - // Face: $100, Coupon: 3.4% annual, 2-year maturity - // YTM: 3.93% - - let face_value: f64 = 100.0; - let coupon_rate: f64 = 0.034; - let ytm: f64 = 0.0393; - - // Annual coupon payment - let pmt: f64 = face_value * coupon_rate; // 3.4 - - // PV of cash flows - // Year 1: 3.4 / 1.0393 - // Year 2: 103.4 / 1.0393^2 - let pv_1: f64 = pmt / (1.0 + ytm); - let pv_2: f64 = (pmt + face_value) / (1.0 + ytm).powi(2); - let clean_price: f64 = pv_1 + pv_2; - - // Expected: 99.0000 - assert!((clean_price - 99.0).abs() < 0.001); - } - - // ========================================================================= - // Zero-Coupon Bond Yield Test Case (Section 3.3) - // ========================================================================= - - #[test] - fn test_yld_002_treasury_strip() { - // Treasury STRIP - // Face: $100, 5-year maturity - // Price: $78.35 - - let face_value: f64 = 100.0; - let price: f64 = 78.35; - let years: f64 = 5.0; - - // YTM = (Face / Price)^(1/n) - 1 - let ytm_annual: f64 = (face_value / price).powf(1.0 / years) - 1.0; - - // Expected: (100/78.35)^0.2 - 1 = 1.2763^0.2 - 1 = 0.0500 = 5.00% - assert!((ytm_annual - 0.05).abs() < 0.001); - - // Semi-annual equivalent: 2 × [(1 + annual)^0.5 - 1] - let ytm_semi: f64 = 2.0 * ((1.0 + ytm_annual).sqrt() - 1.0); - - // Expected: ~4.94% semi-annual - assert!((ytm_semi - 0.0494).abs() < 0.001); - } -} - -#[cfg(test)] -mod duration_convexity_validation { - // ========================================================================= - // Duration and Convexity Test Cases (Section 4) - // ========================================================================= - - /// Helper to compute Macaulay duration for a bond - fn macaulay_duration(coupon_rate: f64, ytm: f64, periods: i32, freq: f64) -> f64 { - let pmt: f64 = 100.0 * coupon_rate / freq; - let r: f64 = ytm / freq; - let face: f64 = 100.0; - - let mut sum_t_pv: f64 = 0.0; - let mut price: f64 = 0.0; - - for t in 1..=periods { - let cf: f64 = if t == periods { pmt + face } else { pmt }; - let pv: f64 = cf / (1.0 + r).powi(t); - sum_t_pv += (t as f64 / freq) * pv; - price += pv; - } - - sum_t_pv / price - } - - /// Helper to compute modified duration - fn modified_duration(mac_dur: f64, ytm: f64, freq: f64) -> f64 { - mac_dur / (1.0 + ytm / freq) - } - - #[test] - fn test_dur_001_macaulay_duration() { - // Standard bond: 6% coupon, 5-year, semi-annual, YTM = 5.5% - - let mac_dur: f64 = macaulay_duration(0.06, 0.055, 10, 2.0); - - // The calculated duration should be around 4.37-4.40 years - // for a 5-year 6% bond at 5.5% yield - assert!(mac_dur > 4.30 && mac_dur < 4.50); - } - - #[test] - fn test_dur_002_modified_duration() { - // From DUR-001 - let mac_dur: f64 = 4.373; - let ytm: f64 = 0.055; - let freq: f64 = 2.0; - - let mod_dur: f64 = modified_duration(mac_dur, ytm, freq); - - // Expected: 4.373 / 1.0275 = 4.255 - assert!((mod_dur - 4.255).abs() < 0.01); - } - - #[test] - fn test_dur_003_effective_duration() { - // Numerical approximation - // Price at YTM: P0 = 104.1234 - // Price at YTM + 1bp: P+ = 104.0347 - // Price at YTM - 1bp: P- = 104.2122 - // Δy = 0.0001 - - let p0: f64 = 104.1234; - let p_up: f64 = 104.0347; - let p_down: f64 = 104.2122; - let delta_y: f64 = 0.0001; - - // Effective Duration = (P- - P+) / (2 × Δy × P0) - let eff_dur: f64 = (p_down - p_up) / (2.0 * delta_y * p0); - - // Expected: 8.525 - assert!((eff_dur - 8.525).abs() < 0.01); - } - - #[test] - fn test_cvx_001_convexity() { - // Convexity = (P+ + P- - 2×P0) / (Δy² × P0) - - let p0: f64 = 104.1234; - let p_up: f64 = 104.0347; - let p_down: f64 = 104.2122; - let delta_y: f64 = 0.0001; - - let convexity: f64 = (p_up + p_down - 2.0 * p0) / (delta_y * delta_y * p0); - - // Expected: ~96.05 - assert!((convexity - 96.05).abs() < 1.0); - } -} - -#[cfg(test)] -mod spread_validation { - // ========================================================================= - // G-Spread Test Cases (Section 5.1) - // ========================================================================= - - #[test] - fn test_gsp_001_corporate_vs_treasury() { - // Corporate YTM: 9.5678% - // Treasury YTM: 7.4702% - // G-Spread = 9.5678% - 7.4702% = 2.0976% = 209.76 bps - - let corporate_ytm: f64 = 0.095678; - let treasury_ytm: f64 = 0.074702; - - let g_spread: f64 = corporate_ytm - treasury_ytm; - let g_spread_bps: f64 = g_spread * 10000.0; - - assert!((g_spread_bps - 209.76).abs() < 0.01); - } - - #[test] - fn test_gsp_002_with_interpolation() { - // Treasury curve: 2Y=4.10%, 3Y=4.25%, 5Y=4.45%, 7Y=4.60%, 10Y=4.75% - // Corporate bond: 4.3 years to maturity, YTM = 5.20% - - let corp_ytm: f64 = 0.0520; - let corp_maturity: f64 = 4.3; - - // Linear interpolation between 3Y and 5Y - // Interpolated = 4.25% + (4.3-3)/(5-3) × (4.45%-4.25%) - // = 4.25% + 0.65 × 0.20% = 4.38% - let interp_tsy: f64 = 0.0425 + ((corp_maturity - 3.0) / (5.0 - 3.0)) * (0.0445 - 0.0425); - - assert!((interp_tsy - 0.0438).abs() < 0.0001); - - // G-Spread = 5.20% - 4.38% = 0.82% = 82 bps - let g_spread_bps: f64 = (corp_ytm - interp_tsy) * 10000.0; - assert!((g_spread_bps - 82.0).abs() < 0.1); - } - - // ========================================================================= - // I-Spread Test Cases (Section 5.4) - // ========================================================================= - - #[test] - fn test_isp_001_eur_corporate() { - // EUR Corporate Bond YTM: 3.20% - // EUR 5Y Swap rate: 2.85% - // I-Spread = 3.20% - 2.85% = 0.35% = 35 bps - - let bond_ytm: f64 = 0.0320; - let swap_rate: f64 = 0.0285; - - let i_spread_bps: f64 = (bond_ytm - swap_rate) * 10000.0; - assert!((i_spread_bps - 35.0).abs() < 0.1); - } - - // ========================================================================= - // Z-Spread Test Cases (Section 5.2) - // ========================================================================= - - #[test] - fn test_zsp_001_canonical_example() { - // Z-Spread Validation Test Case ZSP-001 - // - // Bond Parameters: - // - Face: $100 - // - Coupon: 5% semi-annual - // - Maturity: 3 years - // - Price: $98.50 (clean) - // - // Spot Curve (continuously compounded): - // - 1Y: 4.5% - // - 2Y: 4.7% - // - 3Y: 5.0% - // - // The Z-spread is the constant spread that, when added to all spot rates, - // makes the present value of cash flows equal to the market price. - // - // Cash flows (semi-annual): - // - 0.5Y: $2.50 (coupon) - // - 1.0Y: $2.50 (coupon) - // - 1.5Y: $2.50 (coupon) - // - 2.0Y: $2.50 (coupon) - // - 2.5Y: $2.50 (coupon) - // - 3.0Y: $102.50 (coupon + principal) - - let price: f64 = 98.50; - let face: f64 = 100.0; - let coupon_rate: f64 = 0.05; - let coupon: f64 = face * coupon_rate / 2.0; // 2.50 semi-annual - - // Spot rates (interpolated for semi-annual periods) - // Using linear interpolation between pillar points - fn interpolate_rate(t: f64) -> f64 { - if t <= 1.0 { - 0.045 - } else if t <= 2.0 { - 0.045 + (t - 1.0) * (0.047 - 0.045) - } else { - 0.047 + (t - 2.0) * (0.050 - 0.047) - } - } - - // Function to calculate PV with a given Z-spread - let pv_with_spread = |z_spread: f64| -> f64 { - let mut pv = 0.0; - - // Cash flows at 0.5, 1.0, 1.5, 2.0, 2.5, 3.0 years - for i in 1..=6 { - let t: f64 = i as f64 * 0.5; - let cf: f64 = if i == 6 { coupon + face } else { coupon }; - let r: f64 = interpolate_rate(t); - - // Continuously compounded discounting with spread - let df: f64 = (-(r + z_spread) * t).exp(); - pv += cf * df; - } - - pv - }; - - // Binary search for Z-spread - let mut low: f64 = -0.05; - let mut high: f64 = 0.10; - let tolerance: f64 = 1e-8; - - for _ in 0..100 { - let mid: f64 = (low + high) / 2.0; - let pv: f64 = pv_with_spread(mid); - - if (pv - price).abs() < tolerance { - break; - } - - if pv > price { - low = mid; - } else { - high = mid; - } - } - - let z_spread: f64 = (low + high) / 2.0; - let z_spread_bps: f64 = z_spread * 10000.0; - - // Verify Z-spread is in reasonable range - // For a discount bond (price < par) with coupon ~= spot rates, - // the Z-spread should be positive - assert!( - z_spread > 0.0, - "Z-spread should be positive for discount bond" - ); - - // The Z-spread compensates for the discount to par - // With this setup, expect Z-spread around 50-60 bps - assert!( - z_spread_bps > 40.0 && z_spread_bps < 70.0, - "Z-spread {} bps should be in reasonable range", - z_spread_bps - ); - - // Verify roundtrip: PV with calculated Z-spread should equal price - let calculated_pv: f64 = pv_with_spread(z_spread); - assert!( - (calculated_pv - price).abs() < 0.001, - "Calculated PV {} should equal target price {}", - calculated_pv, - price - ); - } -} - -#[cfg(test)] -mod periodicity_conversion_validation { - // ========================================================================= - // Periodicity Conversion Test Cases (Section 10) - // ========================================================================= - - /// Convert yield between different periodicities - /// Formula: (1 + APR_m/m)^m = (1 + APR_n/n)^n - fn convert_yield(yield_rate: f64, from_frequency: u32, to_frequency: u32) -> f64 { - let effective_annual: f64 = - (1.0 + yield_rate / from_frequency as f64).powi(from_frequency as i32); - to_frequency as f64 * (effective_annual.powf(1.0 / to_frequency as f64) - 1.0) - } - - #[test] - fn test_per_001_semi_annual_to_annual() { - // Semi-annual yield: 4.00% - // Annual equivalent = (1 + 0.04/2)^2 - 1 = 4.04% - - let result: f64 = convert_yield(0.04, 2, 1); - assert!((result - 0.0404).abs() < 0.0001); - } - - #[test] - fn test_per_002_annual_to_semi_annual() { - // Annual yield: 5.0625% - // Semi-annual = 2 × [(1 + 0.050625)^0.5 - 1] = 5.00% - - let result: f64 = convert_yield(0.050625, 1, 2); - assert!((result - 0.0500).abs() < 0.0001); - } - - #[test] - fn test_per_003_semi_annual_to_quarterly() { - // Semi-annual: 4.00% - // Quarterly = 4 × [(1.02)^0.5 - 1] = 3.98% - - let result: f64 = convert_yield(0.04, 2, 4); - assert!((result - 0.0398).abs() < 0.0001); - } - - #[test] - fn test_periodicity_roundtrip() { - // Convert semi-annual to annual and back - let semi: f64 = 0.05; - let annual: f64 = convert_yield(semi, 2, 1); - let back_to_semi: f64 = convert_yield(annual, 1, 2); - - assert!((back_to_semi - semi).abs() < 0.000001); - } -} - -#[cfg(test)] -mod callable_bond_validation { - // ========================================================================= - // Callable Bond Test Cases (Section 6) - // ========================================================================= - - /// Calculate yield given cash flows and price (Newton-Raphson) - fn solve_yield(price: f64, cash_flows: &[(f64, f64)], max_iter: usize) -> f64 { - let mut y: f64 = 0.05; // Initial guess - - for _ in 0..max_iter { - let mut pv: f64 = 0.0; - let mut dpv: f64 = 0.0; - - for &(t, cf) in cash_flows { - let df: f64 = (1.0 + y / 2.0).powf(-2.0 * t); - pv += cf * df; - dpv -= t * cf * df / (1.0 + y / 2.0); - } - - let f: f64 = pv - price; - if f.abs() < 1e-10 { - break; - } - y -= f / dpv; - } - - y - } - - #[test] - fn test_ytc_001_single_call() { - // Callable Bond: Face=$1000, Coupon=8%, Maturity=10yr, Call@103 in 4yr - // Market price: $980 - - // Cash flows to call: 7 semi-annual payments of $40, then $40 + $1030 - let mut cash_flows: Vec<(f64, f64)> = Vec::new(); - for i in 1..=7 { - cash_flows.push((i as f64 * 0.5, 40.0)); - } - cash_flows.push((4.0, 40.0 + 1030.0)); // Final: coupon + call price - - let ytc: f64 = solve_yield(980.0, &cash_flows, 100); - - // Expected YTC: ~9.25% annual (semi-annual r = 4.625%) - // This is approximate - exact value depends on implementation - assert!(ytc > 0.08 && ytc < 0.11); - } -} - -#[cfg(test)] -mod frn_validation { - // ========================================================================= - // Floating Rate Note Test Cases (Section 7) - // ========================================================================= - - #[test] - fn test_frn_001_current_coupon() { - // SOFR-Linked FRN - // Quoted margin: +75 bps - // Current 3M SOFR: 5.25% - - let sofr: f64 = 0.0525; - let quoted_margin: f64 = 0.0075; - - // Current period rate = SOFR + margin - let current_rate: f64 = sofr + quoted_margin; - - // Expected: 6.00% - assert!((current_rate - 0.06).abs() < 0.0001); - } - - #[test] - fn test_sm_001_simple_margin() { - // Simple Margin = Quoted Margin + (100 - Price) / Years to Maturity - - let quoted_margin: f64 = 0.0050; // 50 bps - let price: f64 = 98.50; - let years: f64 = 2.0; - - let simple_margin: f64 = quoted_margin + (100.0 - price) / (100.0 * years); - - // Expected: 1.25% = 125 bps - assert!((simple_margin - 0.0125).abs() < 0.0001); - } - - #[test] - fn test_sofr_001_compounded() { - // 3-day SOFR compounding - let daily_sofr: Vec = vec![0.0500, 0.0505, 0.0510]; // 5.00%, 5.05%, 5.10% - - // Compounded = [Π(1 + SOFR_i × 1/360)] - 1 - let mut product: f64 = 1.0; - for rate in &daily_sofr { - product *= 1.0 + rate / 360.0; - } - - // Annualized: (product - 1) × (360/3) - let compounded_annualized: f64 = (product - 1.0) * (360.0 / 3.0); - - // Expected: ~5.0508% - assert!((compounded_annualized - 0.050508).abs() < 0.0001); - } -} - -#[cfg(test)] -mod sinking_fund_validation { - // ========================================================================= - // Sinking Fund Test Cases (Section 8) - // ========================================================================= - - #[test] - fn test_wal_001_weighted_average_life() { - // $100M bond, sinking fund starts Year 6 - // $20M/year from Year 6 to Year 10 - - let total_principal: f64 = 100_000_000.0; - let schedule: Vec<(f64, f64)> = vec![ - (6.0, 20_000_000.0), - (7.0, 20_000_000.0), - (8.0, 20_000_000.0), - (9.0, 20_000_000.0), - (10.0, 20_000_000.0), - ]; - - // WAL = Σ(t × Principal_t) / Total - let wal: f64 = schedule.iter().map(|(t, p)| t * p).sum::() / total_principal; - - // Expected: 8.0 years - assert!((wal - 8.0).abs() < 0.001); - } -} diff --git a/crates/convex-math/src/linear_algebra/mod.rs b/crates/convex-math/src/linear_algebra/mod.rs index 3da0535c..a021de27 100644 --- a/crates/convex-math/src/linear_algebra/mod.rs +++ b/crates/convex-math/src/linear_algebra/mod.rs @@ -71,41 +71,7 @@ pub fn solve_tridiagonal(a: &[f64], b: &[f64], c: &[f64], d: &[f64]) -> MathResu Ok(x) } -/// Performs LU decomposition of a square matrix. -/// -/// Returns matrices L and U such that A = L * U, where L is lower -/// triangular and U is upper triangular. -pub fn lu_decomposition(matrix: &DMatrix) -> MathResult<(DMatrix, DMatrix)> { - let n = matrix.nrows(); - if n != matrix.ncols() { - return Err(MathError::invalid_input( - "Matrix must be square for LU decomposition", - )); - } - - let mut l = DMatrix::identity(n, n); - let mut u = matrix.clone(); - - for k in 0..n { - if u[(k, k)].abs() < 1e-15 { - return Err(MathError::SingularMatrix); - } - - for i in k + 1..n { - let factor = u[(i, k)] / u[(k, k)]; - l[(i, k)] = factor; - - for j in k..n { - u[(i, j)] -= factor * u[(k, j)]; - } - } - } - - Ok((l, u)) -} - /// Solves a linear system Ax = b using LU decomposition. -#[allow(clippy::many_single_char_names)] pub fn solve_linear_system(a: &DMatrix, b: &DVector) -> MathResult> { let n = a.nrows(); if n != a.ncols() { @@ -120,32 +86,8 @@ pub fn solve_linear_system(a: &DMatrix, b: &DVector) -> MathResult Date: Sun, 31 May 2026 15:00:17 +0100 Subject: [PATCH 2/5] refactor(server,core): modularize request handlers and simplify date wrapper --- crates/convex-core/src/types/date.rs | 9 + .../{handlers.rs => handlers/analytics.rs} | 900 +--------------- crates/convex-server/src/handlers/mod.rs | 998 ++++++++++++++++++ 3 files changed, 1009 insertions(+), 898 deletions(-) rename crates/convex-server/src/{handlers.rs => handlers/analytics.rs} (85%) create mode 100644 crates/convex-server/src/handlers/mod.rs diff --git a/crates/convex-core/src/types/date.rs b/crates/convex-core/src/types/date.rs index 41abc08e..decdfe08 100644 --- a/crates/convex-core/src/types/date.rs +++ b/crates/convex-core/src/types/date.rs @@ -27,6 +27,15 @@ use crate::error::{ConvexError, ConvexResult}; #[serde(transparent)] pub struct Date(NaiveDate); +impl std::ops::Deref for Date { + type Target = NaiveDate; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + impl Date { /// Creates a new date from year, month, and day. /// diff --git a/crates/convex-server/src/handlers.rs b/crates/convex-server/src/handlers/analytics.rs similarity index 85% rename from crates/convex-server/src/handlers.rs rename to crates/convex-server/src/handlers/analytics.rs index 37a03a06..280dcc59 100644 --- a/crates/convex-server/src/handlers.rs +++ b/crates/convex-server/src/handlers/analytics.rs @@ -88,46 +88,8 @@ use convex_traits::reference_data::{ }; /// Application state. -pub struct AppState { - /// The pricing engine - pub engine: Arc, - /// WebSocket state for real-time streaming - pub ws_state: WebSocketState, - /// Bond reference data store (for CRUD operations) - pub bond_store: Arc, - /// Portfolio store (for CRUD operations) - pub portfolio_store: Arc, -} - -/// Health check response. -#[derive(Serialize)] -pub struct HealthResponse { - status: String, - version: String, -} - -/// Health check handler. -pub async fn health() -> Json { - Json(HealthResponse { - status: "ok".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }) -} - -/// Error response. -#[derive(Serialize)] -pub struct ErrorResponse { - error: String, -} - -impl ErrorResponse { - #[allow(dead_code)] - fn new(message: impl Into) -> Self { - Self { - error: message.into(), - } - } -} + +use super::*; /// Query parameters for single bond quote. #[derive(Debug, Deserialize)] @@ -138,86 +100,6 @@ pub struct BondQuoteQuery { pub market_price: Option, } -/// Get bond quote by instrument ID. -/// -/// Looks up bond reference data and prices the bond. -pub async fn get_bond_quote( - State(state): State>, - Path(instrument_id): Path, - axum::extract::Query(query): axum::extract::Query, -) -> impl IntoResponse { - use convex_engine::pricing_router::PricingInput; - - let id = InstrumentId::new(&instrument_id); - - // Look up bond reference data - let bond = match state.engine.reference_data().bonds.get_by_id(&id).await { - Ok(Some(bond)) => bond, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": format!("Bond not found: {}", instrument_id) - })), - ); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": format!("Failed to look up bond: {}", e) - })), - ); - } - }; - - // Parse or default settlement date - let settlement_date = match query.settlement_date { - Some(ref date_str) => match parse_date(date_str) { - Ok(d) => d, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": e })), - ); - } - }, - None => { - // Default to today - let now = chrono::Utc::now(); - Date::from_ymd(now.year(), now.month(), now.day()) - .unwrap_or_else(|_| Date::from_ymd(2024, 1, 15).unwrap()) - } - }; - - // Build pricing input - let input = PricingInput::with_mid_price( - bond, - settlement_date, - query.market_price, - None, // discount_curve - None, // benchmark_curve - None, // government_curve - None, // volatility - ); - - // Price the bond - let router = state.engine.pricing_router(); - match router.price(&input) { - Ok(quote) => { - // Publish to WebSocket subscribers - state.ws_state.publish_bond_quote(quote.clone()); - (StatusCode::OK, Json(serde_json::to_value(quote).unwrap())) - } - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": format!("Pricing failed: {}", e) - })), - ), - } -} - // ============================================================================= // SINGLE BOND PRICING (POST) // ============================================================================= @@ -357,47 +239,6 @@ pub async fn price_single_bond( } } -/// Get curve. -pub async fn get_curve( - State(state): State>, - Path(curve_id): Path, -) -> impl IntoResponse { - let id = CurveId::new(curve_id); - - if let Some(curve) = state.engine.curve_builder().get(&id) { - ( - StatusCode::OK, - Json(serde_json::json!({ - "curve_id": curve.curve_id.as_str(), - "built_at": curve.built_at, - "points": curve.points, - })), - ) - } else { - ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "Curve not found" - })), - ) - } -} - -/// List curves. -pub async fn list_curves(State(state): State>) -> impl IntoResponse { - let curves: Vec = state - .engine - .curve_builder() - .list() - .iter() - .map(|c| c.as_str().to_string()) - .collect(); - - Json(serde_json::json!({ - "curves": curves - })) -} - // ============================================================================= // CURVE MANAGEMENT // ============================================================================= @@ -435,82 +276,6 @@ pub struct CurveResponse { pub built_at: i64, } -/// Create a new curve from points. -pub async fn create_curve( - State(state): State>, - Json(request): Json, -) -> impl IntoResponse { - // Parse reference date - let reference_date = match parse_date(&request.reference_date) { - Ok(d) => d, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": e })), - ); - } - }; - - // Validate points - if request.points.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": "At least one curve point is required" })), - ); - } - - // Convert points - let points: Vec<(f64, f64)> = request.points.iter().map(|p| (p.tenor, p.rate)).collect(); - - let curve_id = CurveId::new(&request.curve_id); - - // Create the curve - match state - .engine - .curve_builder() - .create_from_points(curve_id.clone(), reference_date, points) - { - Ok(curve) => { - let response = CurveResponse { - curve_id: curve.curve_id.as_str().to_string(), - reference_date: format!( - "{:04}-{:02}-{:02}", - curve.reference_date.year(), - curve.reference_date.month(), - curve.reference_date.day() - ), - points: curve.points.clone(), - built_at: curve.built_at, - }; - ( - StatusCode::CREATED, - Json(serde_json::to_value(response).unwrap()), - ) - } - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": e.to_string() })), - ), - } -} - -/// Delete a curve. -pub async fn delete_curve( - State(state): State>, - Path(curve_id): Path, -) -> impl IntoResponse { - let id = CurveId::new(&curve_id); - - if state.engine.curve_builder().delete(&id) { - (StatusCode::NO_CONTENT, Json(serde_json::json!({}))) - } else { - ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "Curve not found" })), - ) - } -} - // ============================================================================ // CURVE BOOTSTRAPPING // ============================================================================ @@ -753,124 +518,6 @@ fn default_compounding() -> String { "continuous".to_string() } -/// Get zero rate for a tenor. -pub async fn get_curve_zero_rate( - State(state): State>, - Path((curve_id, tenor)): Path<(String, f64)>, - axum::extract::Query(query): axum::extract::Query, -) -> impl IntoResponse { - use convex_curves::{Compounding, RateCurveDyn}; - - let id = CurveId::new(&curve_id); - - let curve = match state.engine.curve_builder().get(&id) { - Some(c) => c, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "Curve not found" })), - ); - } - }; - - let compounding = match query.compounding.to_lowercase().as_str() { - "simple" => Compounding::Simple, - "annual" => Compounding::Annual, - "semiannual" | "semi_annual" => Compounding::SemiAnnual, - "quarterly" => Compounding::Quarterly, - "monthly" => Compounding::Monthly, - "daily" => Compounding::Daily, - _ => Compounding::Continuous, - }; - - match curve.zero_rate(tenor, compounding) { - Ok(rate) => ( - StatusCode::OK, - Json(serde_json::json!({ - "curve_id": curve_id, - "tenor": tenor, - "zero_rate": rate, - "compounding": query.compounding - })), - ), - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": e.to_string() })), - ), - } -} - -/// Get discount factor for a tenor. -pub async fn get_curve_discount_factor( - State(state): State>, - Path((curve_id, tenor)): Path<(String, f64)>, -) -> impl IntoResponse { - use convex_curves::RateCurveDyn; - - let id = CurveId::new(&curve_id); - - let curve = match state.engine.curve_builder().get(&id) { - Some(c) => c, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "Curve not found" })), - ); - } - }; - - match curve.discount_factor(tenor) { - Ok(df) => ( - StatusCode::OK, - Json(serde_json::json!({ - "curve_id": curve_id, - "tenor": tenor, - "discount_factor": df - })), - ), - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": e.to_string() })), - ), - } -} - -/// Get forward rate between two tenors. -pub async fn get_curve_forward_rate( - State(state): State>, - Path((curve_id, t1, t2)): Path<(String, f64, f64)>, -) -> impl IntoResponse { - use convex_curves::RateCurveDyn; - - let id = CurveId::new(&curve_id); - - let curve = match state.engine.curve_builder().get(&id) { - Some(c) => c, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "Curve not found" })), - ); - } - }; - - match curve.forward_rate(t1, t2) { - Ok(rate) => ( - StatusCode::OK, - Json(serde_json::json!({ - "curve_id": curve_id, - "t1": t1, - "t2": t2, - "forward_rate": rate - })), - ), - Err(e) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": e.to_string() })), - ), - } -} - // ============================================================================= // BATCH PRICING // ============================================================================= @@ -2706,202 +2353,6 @@ pub struct BondListResponse { pub offset: usize, } -/// List bonds with optional filtering and pagination. -pub async fn list_bonds( - State(state): State>, - axum::extract::Query(query): axum::extract::Query, -) -> impl IntoResponse { - use convex_traits::reference_data::{BondType, IssuerType}; - - // Build filter from query params - let filter = BondFilter { - currency: query.currency.as_ref().and_then(|c| Currency::from_code(c)), - issuer_type: query - .issuer_type - .as_ref() - .and_then(|t| match t.to_lowercase().as_str() { - "sovereign" => Some(IssuerType::Sovereign), - "agency" => Some(IssuerType::Agency), - "supranational" => Some(IssuerType::Supranational), - "corporateig" | "corporate_ig" => Some(IssuerType::CorporateIG), - "corporatehy" | "corporate_hy" => Some(IssuerType::CorporateHY), - "financial" => Some(IssuerType::Financial), - "municipal" => Some(IssuerType::Municipal), - _ => None, - }), - bond_type: query - .bond_type - .as_ref() - .and_then(|t| match t.to_lowercase().as_str() { - "fixedbullet" | "fixed_bullet" => Some(BondType::FixedBullet), - "fixedcallable" | "fixed_callable" => Some(BondType::FixedCallable), - "fixedputable" | "fixed_putable" => Some(BondType::FixedPutable), - "floatingrate" | "floating_rate" | "frn" => Some(BondType::FloatingRate), - "zerocoupon" | "zero_coupon" => Some(BondType::ZeroCoupon), - "inflationlinked" | "inflation_linked" | "linker" => { - Some(BondType::InflationLinked) - } - "amortizing" => Some(BondType::Amortizing), - "convertible" => Some(BondType::Convertible), - _ => None, - }), - country: query.country.clone(), - sector: query.sector.clone(), - issuer_id: query.issuer_id.clone(), - text_search: query.q.clone(), - is_callable: query.is_callable, - is_floating: query.is_floating, - is_inflation_linked: query.is_inflation_linked, - maturity_from: None, - maturity_to: None, - }; - - // Get total count first - let total = match state.bond_store.count(&filter).await { - Ok(count) => count, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": e.to_string() })), - ); - } - }; - - // Get bonds with pagination - let bonds = match state - .bond_store - .search(&filter, query.limit, query.offset) - .await - { - Ok(bonds) => bonds, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": e.to_string() })), - ); - } - }; - - let response = BondListResponse { - bonds, - total, - limit: query.limit, - offset: query.offset, - }; - - ( - StatusCode::OK, - Json(serde_json::to_value(response).unwrap()), - ) -} - -/// Get a single bond by instrument ID. -pub async fn get_bond( - State(state): State>, - Path(instrument_id): Path, -) -> impl IntoResponse { - let id = InstrumentId::new(&instrument_id); - - match state.bond_store.get_by_id(&id).await { - Ok(Some(bond)) => (StatusCode::OK, Json(serde_json::to_value(bond).unwrap())), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": format!("Bond not found: {}", instrument_id) })), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": e.to_string() })), - ), - } -} - -/// Create a new bond. -pub async fn create_bond( - State(state): State>, - Json(bond): Json, -) -> impl IntoResponse { - // Check if bond already exists - if let Ok(Some(_)) = state.bond_store.get_by_id(&bond.instrument_id).await { - return ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "error": format!("Bond already exists: {}", bond.instrument_id.as_str()) - })), - ); - } - - // Set timestamp if not provided - let mut bond = bond; - if bond.last_updated == 0 { - bond.last_updated = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - } - - let created = state.bond_store.upsert(bond); - - ( - StatusCode::CREATED, - Json(serde_json::to_value(created).unwrap()), - ) -} - -/// Update an existing bond. -pub async fn update_bond( - State(state): State>, - Path(instrument_id): Path, - Json(mut bond): Json, -) -> impl IntoResponse { - let id = InstrumentId::new(&instrument_id); - - // Verify bond exists - match state.bond_store.get_by_id(&id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": format!("Bond not found: {}", instrument_id) })), - ); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": e.to_string() })), - ); - } - } - - // Ensure instrument ID matches path - bond.instrument_id = id; - - // Update timestamp - bond.last_updated = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - - let updated = state.bond_store.upsert(bond); - - (StatusCode::OK, Json(serde_json::to_value(updated).unwrap())) -} - -/// Delete a bond. -pub async fn delete_bond( - State(state): State>, - Path(instrument_id): Path, -) -> impl IntoResponse { - let id = InstrumentId::new(&instrument_id); - - match state.bond_store.delete(&id) { - Some(_) => (StatusCode::NO_CONTENT, Json(serde_json::json!({}))), - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": format!("Bond not found: {}", instrument_id) })), - ), - } -} - /// Batch create bonds. #[derive(Debug, Deserialize)] pub struct BatchBondCreateRequest { @@ -2920,82 +2371,6 @@ pub struct BatchBondCreateResponse { pub errors: Vec, } -/// Batch create bonds. -pub async fn batch_create_bonds( - State(state): State>, - Json(request): Json, -) -> impl IntoResponse { - let mut created = 0; - let mut skipped = 0; - let errors: Vec = Vec::new(); - - for mut bond in request.bonds { - // Check if bond already exists - if let Ok(Some(_)) = state.bond_store.get_by_id(&bond.instrument_id).await { - skipped += 1; - continue; - } - - // Set timestamp if not provided - if bond.last_updated == 0 { - bond.last_updated = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - } - - state.bond_store.upsert(bond); - created += 1; - } - - let response = BatchBondCreateResponse { - created, - skipped, - errors, - }; - - ( - StatusCode::OK, - Json(serde_json::to_value(response).unwrap()), - ) -} - -/// Get bond by ISIN. -pub async fn get_bond_by_isin( - State(state): State>, - Path(isin): Path, -) -> impl IntoResponse { - match state.bond_store.get_by_isin(&isin).await { - Ok(Some(bond)) => (StatusCode::OK, Json(serde_json::to_value(bond).unwrap())), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": format!("Bond not found with ISIN: {}", isin) })), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": e.to_string() })), - ), - } -} - -/// Get bond by CUSIP. -pub async fn get_bond_by_cusip( - State(state): State>, - Path(cusip): Path, -) -> impl IntoResponse { - match state.bond_store.get_by_cusip(&cusip).await { - Ok(Some(bond)) => (StatusCode::OK, Json(serde_json::to_value(bond).unwrap())), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": format!("Bond not found with CUSIP: {}", cusip) })), - ), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": e.to_string() })), - ), - } -} - // ============================================================================= // PORTFOLIO CRUD // ============================================================================= @@ -3028,55 +2403,6 @@ pub struct PortfolioListResponse { pub offset: usize, } -/// List portfolios with optional filtering and pagination. -pub async fn list_portfolios( - State(state): State>, - axum::extract::Query(query): axum::extract::Query, -) -> impl IntoResponse { - // Build filter from query params - let filter = PortfolioFilter { - currency: query.currency, - text_search: query.q, - }; - - // Get total count - let total = state.portfolio_store.count(&filter); - - // Get portfolios with pagination - let portfolios = state - .portfolio_store - .list(&filter, query.limit, query.offset); - - let response = PortfolioListResponse { - portfolios, - total, - limit: query.limit, - offset: query.offset, - }; - - ( - StatusCode::OK, - Json(serde_json::to_value(response).unwrap()), - ) -} - -/// Get a single portfolio by ID. -pub async fn get_portfolio( - State(state): State>, - Path(portfolio_id): Path, -) -> impl IntoResponse { - match state.portfolio_store.get(&portfolio_id) { - Some(portfolio) => ( - StatusCode::OK, - Json(serde_json::to_value(portfolio).unwrap()), - ), - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": format!("Portfolio not found: {}", portfolio_id) })), - ), - } -} - /// Request for creating a portfolio. #[derive(Debug, Deserialize)] pub struct CreatePortfolioRequest { @@ -3094,44 +2420,6 @@ pub struct CreatePortfolioRequest { pub positions: Vec, } -/// Create a new portfolio. -pub async fn create_portfolio( - State(state): State>, - Json(request): Json, -) -> impl IntoResponse { - // Check if portfolio already exists - if state.portfolio_store.get(&request.portfolio_id).is_some() { - return ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "error": format!("Portfolio already exists: {}", request.portfolio_id) - })), - ); - } - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - - let portfolio = StoredPortfolio { - portfolio_id: request.portfolio_id, - name: request.name, - currency: request.currency, - description: request.description, - positions: request.positions, - created_at: now, - updated_at: now, - }; - - let created = state.portfolio_store.upsert(portfolio); - - ( - StatusCode::CREATED, - Json(serde_json::to_value(created).unwrap()), - ) -} - /// Request for updating a portfolio. #[derive(Debug, Deserialize)] pub struct UpdatePortfolioRequest { @@ -3145,124 +2433,6 @@ pub struct UpdatePortfolioRequest { pub positions: Option>, } -/// Update an existing portfolio. -pub async fn update_portfolio( - State(state): State>, - Path(portfolio_id): Path, - Json(request): Json, -) -> impl IntoResponse { - // Get existing portfolio - let existing = match state.portfolio_store.get(&portfolio_id) { - Some(p) => p, - None => { - return ( - StatusCode::NOT_FOUND, - Json( - serde_json::json!({ "error": format!("Portfolio not found: {}", portfolio_id) }), - ), - ); - } - }; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - - // Update fields - let portfolio = StoredPortfolio { - portfolio_id: existing.portfolio_id, - name: request.name.unwrap_or(existing.name), - currency: request.currency.unwrap_or(existing.currency), - description: request.description.or(existing.description), - positions: request.positions.unwrap_or(existing.positions), - created_at: existing.created_at, - updated_at: now, - }; - - let updated = state.portfolio_store.upsert(portfolio); - - (StatusCode::OK, Json(serde_json::to_value(updated).unwrap())) -} - -/// Delete a portfolio. -pub async fn delete_portfolio( - State(state): State>, - Path(portfolio_id): Path, -) -> impl IntoResponse { - match state.portfolio_store.delete(&portfolio_id) { - Some(_) => (StatusCode::NO_CONTENT, Json(serde_json::json!({}))), - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": format!("Portfolio not found: {}", portfolio_id) })), - ), - } -} - -/// Add a position to a portfolio. -pub async fn add_portfolio_position( - State(state): State>, - Path(portfolio_id): Path, - Json(position): Json, -) -> impl IntoResponse { - match state.portfolio_store.add_position(&portfolio_id, position) { - Some(portfolio) => ( - StatusCode::OK, - Json(serde_json::to_value(portfolio).unwrap()), - ), - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": format!("Portfolio not found: {}", portfolio_id) })), - ), - } -} - -/// Remove a position from a portfolio. -pub async fn remove_portfolio_position( - State(state): State>, - Path((portfolio_id, instrument_id)): Path<(String, String)>, -) -> impl IntoResponse { - match state - .portfolio_store - .remove_position(&portfolio_id, &instrument_id) - { - Some(portfolio) => ( - StatusCode::OK, - Json(serde_json::to_value(portfolio).unwrap()), - ), - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": format!("Portfolio not found: {}", portfolio_id) })), - ), - } -} - -/// Update a position in a portfolio. -pub async fn update_portfolio_position( - State(state): State>, - Path((portfolio_id, instrument_id)): Path<(String, String)>, - Json(mut position): Json, -) -> impl IntoResponse { - // Ensure instrument_id matches path - position.instrument_id = instrument_id.clone(); - - match state - .portfolio_store - .update_position(&portfolio_id, position) - { - Some(portfolio) => ( - StatusCode::OK, - Json(serde_json::to_value(portfolio).unwrap()), - ), - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": format!("Portfolio or position not found: {}/{}", portfolio_id, instrument_id) - })), - ), - } -} - /// Batch create portfolios. #[derive(Debug, Deserialize)] pub struct BatchPortfolioCreateRequest { @@ -3279,48 +2449,6 @@ pub struct BatchPortfolioCreateResponse { pub skipped: usize, } -/// Batch create portfolios. -pub async fn batch_create_portfolios( - State(state): State>, - Json(request): Json, -) -> impl IntoResponse { - let mut created = 0; - let mut skipped = 0; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - - for req in request.portfolios { - // Check if portfolio already exists - if state.portfolio_store.get(&req.portfolio_id).is_some() { - skipped += 1; - continue; - } - - let portfolio = StoredPortfolio { - portfolio_id: req.portfolio_id, - name: req.name, - currency: req.currency, - description: req.description, - positions: req.positions, - created_at: now, - updated_at: now, - }; - - state.portfolio_store.upsert(portfolio); - created += 1; - } - - let response = BatchPortfolioCreateResponse { created, skipped }; - - ( - StatusCode::OK, - Json(serde_json::to_value(response).unwrap()), - ) -} - // ============================================================================= // STRESS TESTING // ============================================================================= @@ -5612,27 +4740,3 @@ fn parse_date(s: &str) -> Result { Date::from_ymd(year, month, day).map_err(|e| format!("Invalid date: {}", e)) } - -fn parse_currency(s: &str) -> Currency { - match s.to_uppercase().as_str() { - "USD" => Currency::USD, - "EUR" => Currency::EUR, - "GBP" => Currency::GBP, - "JPY" => Currency::JPY, - "CHF" => Currency::CHF, - "CAD" => Currency::CAD, - "AUD" => Currency::AUD, - "NZD" => Currency::NZD, - "SEK" => Currency::SEK, - "NOK" => Currency::NOK, - "DKK" => Currency::DKK, - "HKD" => Currency::HKD, - "SGD" => Currency::SGD, - "CNY" => Currency::CNY, - "INR" => Currency::INR, - "BRL" => Currency::BRL, - "MXN" => Currency::MXN, - "ZAR" => Currency::ZAR, - _ => Currency::USD, // Default to USD for unknown currencies - } -} diff --git a/crates/convex-server/src/handlers/mod.rs b/crates/convex-server/src/handlers/mod.rs new file mode 100644 index 00000000..932139fc --- /dev/null +++ b/crates/convex-server/src/handlers/mod.rs @@ -0,0 +1,998 @@ +//! Request handlers. + +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use chrono::Datelike; +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +use convex_analytics::risk::{Duration as AnalyticsDuration, KeyRateDuration, KeyRateDurations}; +use convex_bonds::types::BondIdentifiers; +use convex_core::Currency; +use convex_core::Date; +use convex_engine::{Portfolio, Position, PricingEngine}; +use convex_ext_file::{ + InMemoryBondStore, InMemoryPortfolioStore, PortfolioFilter, StoredPortfolio, StoredPosition, +}; +use convex_portfolio::{ + active_weights, + // Key rate duration + aggregate_key_rate_profile, + analyze_basket, + // Benchmark comparison + benchmark_comparison, + bucket_by_country, + bucket_by_currency, + bucket_by_issuer, + bucket_by_maturity, + bucket_by_rating, + bucket_by_sector, + build_creation_basket, + // Credit quality analytics + calculate_credit_quality, + // Liquidity analytics + calculate_liquidity_metrics, + calculate_migration_risk, + // ETF analytics + calculate_sec_yield, + cs01_contributions, + duration_contributions, + duration_difference_by_sector, + dv01_contributions, + estimate_days_to_liquidate, + estimate_tracking_error, + liquidity_distribution, + // Stress testing + run_stress_scenario, + run_stress_scenarios, + spread_contributions, + spread_difference_by_sector, + stress_scenarios, + summarize_results, + AnalyticsConfig, + BucketContribution, + BucketMetrics, + Classification, + CreditRating, + Cs01Contributions, + DurationContributions, + Dv01Contributions, + Holding, + HoldingAnalytics, + HoldingBuilder, + HoldingContribution, + // Portfolio + Portfolio as ConvexPortfolio, + PortfolioBuilder, + RateScenario, + RatingBucket, + RatingInfo, + SecYieldInput, + Sector, + SpreadContributions, + SpreadScenario, + StressResult, + StressScenario, +}; + +use crate::websocket::WebSocketState; +use convex_traits::ids::{CurveId, EtfId, InstrumentId, PortfolioId}; +use convex_traits::output::{BondQuoteOutput, EtfQuoteOutput, PortfolioAnalyticsOutput}; +use convex_traits::reference_data::{ + BondFilter, BondReferenceData, BondReferenceSource, EtfHoldingEntry, EtfHoldings, +}; + +/// Application state. + + +pub struct AppState { + /// The pricing engine + pub engine: Arc, + /// WebSocket state for real-time streaming + pub ws_state: WebSocketState, + /// Bond reference data store (for CRUD operations) + pub bond_store: Arc, + /// Portfolio store (for CRUD operations) + pub portfolio_store: Arc, +} + +pub mod analytics; +pub use analytics::*; + +/// Health check response. +#[derive(Serialize)] +pub struct HealthResponse { + status: String, + version: String, +} + +/// Health check handler. +pub async fn health() -> Json { + Json(HealthResponse { + status: "ok".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }) +} + +/// Error response. +#[derive(Serialize)] +pub struct ErrorResponse { + error: String, +} + +impl ErrorResponse { + #[allow(dead_code)] + fn new(message: impl Into) -> Self { + Self { + error: message.into(), + } + } +} + +/// Get bond quote by instrument ID. +/// +/// Looks up bond reference data and prices the bond. +pub async fn get_bond_quote( + State(state): State>, + Path(instrument_id): Path, + axum::extract::Query(query): axum::extract::Query, +) -> impl IntoResponse { + use convex_engine::pricing_router::PricingInput; + + let id = InstrumentId::new(&instrument_id); + + // Look up bond reference data + let bond = match state.engine.reference_data().bonds.get_by_id(&id).await { + Ok(Some(bond)) => bond, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("Bond not found: {}", instrument_id) + })), + ); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to look up bond: {}", e) + })), + ); + } + }; + + // Parse or default settlement date + let settlement_date = match query.settlement_date { + Some(ref date_str) => match parse_date(date_str) { + Ok(d) => d, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e })), + ); + } + }, + None => { + // Default to today + let now = chrono::Utc::now(); + Date::from_ymd(now.year(), now.month(), now.day()) + .unwrap_or_else(|_| Date::from_ymd(2024, 1, 15).unwrap()) + } + }; + + // Build pricing input + let input = PricingInput::with_mid_price( + bond, + settlement_date, + query.market_price, + None, // discount_curve + None, // benchmark_curve + None, // government_curve + None, // volatility + ); + + // Price the bond + let router = state.engine.pricing_router(); + match router.price(&input) { + Ok(quote) => { + // Publish to WebSocket subscribers + state.ws_state.publish_bond_quote(quote.clone()); + (StatusCode::OK, Json(serde_json::to_value(quote).unwrap())) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Pricing failed: {}", e) + })), + ), + } +} + +/// Get curve. +pub async fn get_curve( + State(state): State>, + Path(curve_id): Path, +) -> impl IntoResponse { + let id = CurveId::new(curve_id); + + if let Some(curve) = state.engine.curve_builder().get(&id) { + ( + StatusCode::OK, + Json(serde_json::json!({ + "curve_id": curve.curve_id.as_str(), + "built_at": curve.built_at, + "points": curve.points, + })), + ) + } else { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "Curve not found" + })), + ) + } +} + +/// List curves. +pub async fn list_curves(State(state): State>) -> impl IntoResponse { + let curves: Vec = state + .engine + .curve_builder() + .list() + .iter() + .map(|c| c.as_str().to_string()) + .collect(); + + Json(serde_json::json!({ + "curves": curves + })) +} + +/// Create a new curve from points. +pub async fn create_curve( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + // Parse reference date + let reference_date = match parse_date(&request.reference_date) { + Ok(d) => d, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e })), + ); + } + }; + + // Validate points + if request.points.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "At least one curve point is required" })), + ); + } + + // Convert points + let points: Vec<(f64, f64)> = request.points.iter().map(|p| (p.tenor, p.rate)).collect(); + + let curve_id = CurveId::new(&request.curve_id); + + // Create the curve + match state + .engine + .curve_builder() + .create_from_points(curve_id.clone(), reference_date, points) + { + Ok(curve) => { + let response = CurveResponse { + curve_id: curve.curve_id.as_str().to_string(), + reference_date: format!( + "{:04}-{:02}-{:02}", + curve.reference_date.year(), + curve.reference_date.month(), + curve.reference_date.day() + ), + points: curve.points.clone(), + built_at: curve.built_at, + }; + ( + StatusCode::CREATED, + Json(serde_json::to_value(response).unwrap()), + ) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ), + } +} + +/// Delete a curve. +pub async fn delete_curve( + State(state): State>, + Path(curve_id): Path, +) -> impl IntoResponse { + let id = CurveId::new(&curve_id); + + if state.engine.curve_builder().delete(&id) { + (StatusCode::NO_CONTENT, Json(serde_json::json!({}))) + } else { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Curve not found" })), + ) + } +} + +/// Get zero rate for a tenor. +pub async fn get_curve_zero_rate( + State(state): State>, + Path((curve_id, tenor)): Path<(String, f64)>, + axum::extract::Query(query): axum::extract::Query, +) -> impl IntoResponse { + use convex_curves::{Compounding, RateCurveDyn}; + + let id = CurveId::new(&curve_id); + + let curve = match state.engine.curve_builder().get(&id) { + Some(c) => c, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Curve not found" })), + ); + } + }; + + let compounding = match query.compounding.to_lowercase().as_str() { + "simple" => Compounding::Simple, + "annual" => Compounding::Annual, + "semiannual" | "semi_annual" => Compounding::SemiAnnual, + "quarterly" => Compounding::Quarterly, + "monthly" => Compounding::Monthly, + "daily" => Compounding::Daily, + _ => Compounding::Continuous, + }; + + match curve.zero_rate(tenor, compounding) { + Ok(rate) => ( + StatusCode::OK, + Json(serde_json::json!({ + "curve_id": curve_id, + "tenor": tenor, + "zero_rate": rate, + "compounding": query.compounding + })), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e.to_string() })), + ), + } +} + +/// Get discount factor for a tenor. +pub async fn get_curve_discount_factor( + State(state): State>, + Path((curve_id, tenor)): Path<(String, f64)>, +) -> impl IntoResponse { + use convex_curves::RateCurveDyn; + + let id = CurveId::new(&curve_id); + + let curve = match state.engine.curve_builder().get(&id) { + Some(c) => c, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Curve not found" })), + ); + } + }; + + match curve.discount_factor(tenor) { + Ok(df) => ( + StatusCode::OK, + Json(serde_json::json!({ + "curve_id": curve_id, + "tenor": tenor, + "discount_factor": df + })), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e.to_string() })), + ), + } +} + +/// Get forward rate between two tenors. +pub async fn get_curve_forward_rate( + State(state): State>, + Path((curve_id, t1, t2)): Path<(String, f64, f64)>, +) -> impl IntoResponse { + use convex_curves::RateCurveDyn; + + let id = CurveId::new(&curve_id); + + let curve = match state.engine.curve_builder().get(&id) { + Some(c) => c, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Curve not found" })), + ); + } + }; + + match curve.forward_rate(t1, t2) { + Ok(rate) => ( + StatusCode::OK, + Json(serde_json::json!({ + "curve_id": curve_id, + "t1": t1, + "t2": t2, + "forward_rate": rate + })), + ), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e.to_string() })), + ), + } +} + +/// List bonds with optional filtering and pagination. +pub async fn list_bonds( + State(state): State>, + axum::extract::Query(query): axum::extract::Query, +) -> impl IntoResponse { + use convex_traits::reference_data::{BondType, IssuerType}; + + // Build filter from query params + let filter = BondFilter { + currency: query.currency.as_ref().and_then(|c| Currency::from_code(c)), + issuer_type: query + .issuer_type + .as_ref() + .and_then(|t| match t.to_lowercase().as_str() { + "sovereign" => Some(IssuerType::Sovereign), + "agency" => Some(IssuerType::Agency), + "supranational" => Some(IssuerType::Supranational), + "corporateig" | "corporate_ig" => Some(IssuerType::CorporateIG), + "corporatehy" | "corporate_hy" => Some(IssuerType::CorporateHY), + "financial" => Some(IssuerType::Financial), + "municipal" => Some(IssuerType::Municipal), + _ => None, + }), + bond_type: query + .bond_type + .as_ref() + .and_then(|t| match t.to_lowercase().as_str() { + "fixedbullet" | "fixed_bullet" => Some(BondType::FixedBullet), + "fixedcallable" | "fixed_callable" => Some(BondType::FixedCallable), + "fixedputable" | "fixed_putable" => Some(BondType::FixedPutable), + "floatingrate" | "floating_rate" | "frn" => Some(BondType::FloatingRate), + "zerocoupon" | "zero_coupon" => Some(BondType::ZeroCoupon), + "inflationlinked" | "inflation_linked" | "linker" => { + Some(BondType::InflationLinked) + } + "amortizing" => Some(BondType::Amortizing), + "convertible" => Some(BondType::Convertible), + _ => None, + }), + country: query.country.clone(), + sector: query.sector.clone(), + issuer_id: query.issuer_id.clone(), + text_search: query.q.clone(), + is_callable: query.is_callable, + is_floating: query.is_floating, + is_inflation_linked: query.is_inflation_linked, + maturity_from: None, + maturity_to: None, + }; + + // Get total count first + let total = match state.bond_store.count(&filter).await { + Ok(count) => count, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ); + } + }; + + // Get bonds with pagination + let bonds = match state + .bond_store + .search(&filter, query.limit, query.offset) + .await + { + Ok(bonds) => bonds, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ); + } + }; + + let response = BondListResponse { + bonds, + total, + limit: query.limit, + offset: query.offset, + }; + + ( + StatusCode::OK, + Json(serde_json::to_value(response).unwrap()), + ) +} + +/// Get a single bond by instrument ID. +pub async fn get_bond( + State(state): State>, + Path(instrument_id): Path, +) -> impl IntoResponse { + let id = InstrumentId::new(&instrument_id); + + match state.bond_store.get_by_id(&id).await { + Ok(Some(bond)) => (StatusCode::OK, Json(serde_json::to_value(bond).unwrap())), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Bond not found: {}", instrument_id) })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ), + } +} + +/// Create a new bond. +pub async fn create_bond( + State(state): State>, + Json(bond): Json, +) -> impl IntoResponse { + // Check if bond already exists + if let Ok(Some(_)) = state.bond_store.get_by_id(&bond.instrument_id).await { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": format!("Bond already exists: {}", bond.instrument_id.as_str()) + })), + ); + } + + // Set timestamp if not provided + let mut bond = bond; + if bond.last_updated == 0 { + bond.last_updated = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + } + + let created = state.bond_store.upsert(bond); + + ( + StatusCode::CREATED, + Json(serde_json::to_value(created).unwrap()), + ) +} + +/// Update an existing bond. +pub async fn update_bond( + State(state): State>, + Path(instrument_id): Path, + Json(mut bond): Json, +) -> impl IntoResponse { + let id = InstrumentId::new(&instrument_id); + + // Verify bond exists + match state.bond_store.get_by_id(&id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Bond not found: {}", instrument_id) })), + ); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ); + } + } + + // Ensure instrument ID matches path + bond.instrument_id = id; + + // Update timestamp + bond.last_updated = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + let updated = state.bond_store.upsert(bond); + + (StatusCode::OK, Json(serde_json::to_value(updated).unwrap())) +} + +/// Delete a bond. +pub async fn delete_bond( + State(state): State>, + Path(instrument_id): Path, +) -> impl IntoResponse { + let id = InstrumentId::new(&instrument_id); + + match state.bond_store.delete(&id) { + Some(_) => (StatusCode::NO_CONTENT, Json(serde_json::json!({}))), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Bond not found: {}", instrument_id) })), + ), + } +} + +/// Batch create bonds. +pub async fn batch_create_bonds( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + let mut created = 0; + let mut skipped = 0; + let errors: Vec = Vec::new(); + + for mut bond in request.bonds { + // Check if bond already exists + if let Ok(Some(_)) = state.bond_store.get_by_id(&bond.instrument_id).await { + skipped += 1; + continue; + } + + // Set timestamp if not provided + if bond.last_updated == 0 { + bond.last_updated = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + } + + state.bond_store.upsert(bond); + created += 1; + } + + let response = BatchBondCreateResponse { + created, + skipped, + errors, + }; + + ( + StatusCode::OK, + Json(serde_json::to_value(response).unwrap()), + ) +} + +/// Get bond by ISIN. +pub async fn get_bond_by_isin( + State(state): State>, + Path(isin): Path, +) -> impl IntoResponse { + match state.bond_store.get_by_isin(&isin).await { + Ok(Some(bond)) => (StatusCode::OK, Json(serde_json::to_value(bond).unwrap())), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Bond not found with ISIN: {}", isin) })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ), + } +} + +/// Get bond by CUSIP. +pub async fn get_bond_by_cusip( + State(state): State>, + Path(cusip): Path, +) -> impl IntoResponse { + match state.bond_store.get_by_cusip(&cusip).await { + Ok(Some(bond)) => (StatusCode::OK, Json(serde_json::to_value(bond).unwrap())), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Bond not found with CUSIP: {}", cusip) })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ), + } +} + +/// List portfolios with optional filtering and pagination. +pub async fn list_portfolios( + State(state): State>, + axum::extract::Query(query): axum::extract::Query, +) -> impl IntoResponse { + // Build filter from query params + let filter = PortfolioFilter { + currency: query.currency, + text_search: query.q, + }; + + // Get total count + let total = state.portfolio_store.count(&filter); + + // Get portfolios with pagination + let portfolios = state + .portfolio_store + .list(&filter, query.limit, query.offset); + + let response = PortfolioListResponse { + portfolios, + total, + limit: query.limit, + offset: query.offset, + }; + + ( + StatusCode::OK, + Json(serde_json::to_value(response).unwrap()), + ) +} + +/// Get a single portfolio by ID. +pub async fn get_portfolio( + State(state): State>, + Path(portfolio_id): Path, +) -> impl IntoResponse { + match state.portfolio_store.get(&portfolio_id) { + Some(portfolio) => ( + StatusCode::OK, + Json(serde_json::to_value(portfolio).unwrap()), + ), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Portfolio not found: {}", portfolio_id) })), + ), + } +} + +/// Create a new portfolio. +pub async fn create_portfolio( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + // Check if portfolio already exists + if state.portfolio_store.get(&request.portfolio_id).is_some() { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": format!("Portfolio already exists: {}", request.portfolio_id) + })), + ); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + let portfolio = StoredPortfolio { + portfolio_id: request.portfolio_id, + name: request.name, + currency: request.currency, + description: request.description, + positions: request.positions, + created_at: now, + updated_at: now, + }; + + let created = state.portfolio_store.upsert(portfolio); + + ( + StatusCode::CREATED, + Json(serde_json::to_value(created).unwrap()), + ) +} + +/// Update an existing portfolio. +pub async fn update_portfolio( + State(state): State>, + Path(portfolio_id): Path, + Json(request): Json, +) -> impl IntoResponse { + // Get existing portfolio + let existing = match state.portfolio_store.get(&portfolio_id) { + Some(p) => p, + None => { + return ( + StatusCode::NOT_FOUND, + Json( + serde_json::json!({ "error": format!("Portfolio not found: {}", portfolio_id) }), + ), + ); + } + }; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + // Update fields + let portfolio = StoredPortfolio { + portfolio_id: existing.portfolio_id, + name: request.name.unwrap_or(existing.name), + currency: request.currency.unwrap_or(existing.currency), + description: request.description.or(existing.description), + positions: request.positions.unwrap_or(existing.positions), + created_at: existing.created_at, + updated_at: now, + }; + + let updated = state.portfolio_store.upsert(portfolio); + + (StatusCode::OK, Json(serde_json::to_value(updated).unwrap())) +} + +/// Delete a portfolio. +pub async fn delete_portfolio( + State(state): State>, + Path(portfolio_id): Path, +) -> impl IntoResponse { + match state.portfolio_store.delete(&portfolio_id) { + Some(_) => (StatusCode::NO_CONTENT, Json(serde_json::json!({}))), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Portfolio not found: {}", portfolio_id) })), + ), + } +} + +/// Add a position to a portfolio. +pub async fn add_portfolio_position( + State(state): State>, + Path(portfolio_id): Path, + Json(position): Json, +) -> impl IntoResponse { + match state.portfolio_store.add_position(&portfolio_id, position) { + Some(portfolio) => ( + StatusCode::OK, + Json(serde_json::to_value(portfolio).unwrap()), + ), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Portfolio not found: {}", portfolio_id) })), + ), + } +} + +/// Remove a position from a portfolio. +pub async fn remove_portfolio_position( + State(state): State>, + Path((portfolio_id, instrument_id)): Path<(String, String)>, +) -> impl IntoResponse { + match state + .portfolio_store + .remove_position(&portfolio_id, &instrument_id) + { + Some(portfolio) => ( + StatusCode::OK, + Json(serde_json::to_value(portfolio).unwrap()), + ), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Portfolio not found: {}", portfolio_id) })), + ), + } +} + +/// Update a position in a portfolio. +pub async fn update_portfolio_position( + State(state): State>, + Path((portfolio_id, instrument_id)): Path<(String, String)>, + Json(mut position): Json, +) -> impl IntoResponse { + // Ensure instrument_id matches path + position.instrument_id = instrument_id.clone(); + + match state + .portfolio_store + .update_position(&portfolio_id, position) + { + Some(portfolio) => ( + StatusCode::OK, + Json(serde_json::to_value(portfolio).unwrap()), + ), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("Portfolio or position not found: {}/{}", portfolio_id, instrument_id) + })), + ), + } +} + +/// Batch create portfolios. +pub async fn batch_create_portfolios( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + let mut created = 0; + let mut skipped = 0; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + for req in request.portfolios { + // Check if portfolio already exists + if state.portfolio_store.get(&req.portfolio_id).is_some() { + skipped += 1; + continue; + } + + let portfolio = StoredPortfolio { + portfolio_id: req.portfolio_id, + name: req.name, + currency: req.currency, + description: req.description, + positions: req.positions, + created_at: now, + updated_at: now, + }; + + state.portfolio_store.upsert(portfolio); + created += 1; + } + + let response = BatchPortfolioCreateResponse { created, skipped }; + + ( + StatusCode::OK, + Json(serde_json::to_value(response).unwrap()), + ) +} + +/// Parse a date string (YYYY-MM-DD) into a Date. +pub(crate) fn parse_date(s: &str) -> Result { + Date::parse(s).map_err(|e| e.to_string()) +} + +pub(crate) fn parse_currency(s: &str) -> Currency { + match s.to_uppercase().as_str() { + "USD" => Currency::USD, + "EUR" => Currency::EUR, + "GBP" => Currency::GBP, + "JPY" => Currency::JPY, + "CHF" => Currency::CHF, + "CAD" => Currency::CAD, + "AUD" => Currency::AUD, + "NZD" => Currency::NZD, + "SEK" => Currency::SEK, + "NOK" => Currency::NOK, + "DKK" => Currency::DKK, + "HKD" => Currency::HKD, + "SGD" => Currency::SGD, + "CNY" => Currency::CNY, + "INR" => Currency::INR, + "BRL" => Currency::BRL, + "MXN" => Currency::MXN, + "ZAR" => Currency::ZAR, + _ => Currency::USD, // Default to USD for unknown currencies + } +} From 05c99ca88c0dce053f35fb655203aa5e63c9a17f Mon Sep 17 00:00:00 2001 From: sujitn Date: Sun, 31 May 2026 15:01:15 +0100 Subject: [PATCH 3/5] refactor(engine): remove unused reactive sharding types, methods, and tests --- crates/convex-engine/src/calc_graph.rs | 327 +------------------------ crates/convex-engine/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 327 deletions(-) diff --git a/crates/convex-engine/src/calc_graph.rs b/crates/convex-engine/src/calc_graph.rs index 50884a9b..246a3412 100644 --- a/crates/convex-engine/src/calc_graph.rs +++ b/crates/convex-engine/src/calc_graph.rs @@ -241,138 +241,6 @@ pub enum NodeValue { Empty, } -// ============================================================================= -// SHARDING TYPES -// ============================================================================= - -/// Strategy for assigning nodes to shards. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum ShardStrategy { - /// Assign by hash of instrument ID (default). - #[default] - HashBased, - /// Assign by currency. - ByCurrency, - /// Assign by issuer type. - ByIssuerType, - /// Manual assignment via configuration. - Manual, -} - -/// Assignment specification for manual/explicit sharding. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ShardAssignment { - /// Currencies this shard handles. - pub currencies: Option>, - /// Issuer types this shard handles. - pub issuer_types: Option>, - /// Explicit instrument IDs this shard handles. - pub instrument_ids: Option>, -} - -/// Configuration for sharded calculation graph. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ShardConfig { - /// This shard's ID (0-indexed). - pub shard_id: u32, - /// Total number of shards. - pub total_shards: u32, - /// Sharding strategy. - pub strategy: ShardStrategy, - /// Explicit assignment (for ByCurrency, ByIssuerType, Manual). - pub assignment: Option, -} - -impl Default for ShardConfig { - fn default() -> Self { - Self { - shard_id: 0, - total_shards: 1, - strategy: ShardStrategy::HashBased, - assignment: None, - } - } -} - -impl ShardConfig { - /// Create a new shard config for a single-shard deployment. - pub fn single_shard() -> Self { - Self::default() - } - - /// Create a new shard config for hash-based sharding. - pub fn hash_based(shard_id: u32, total_shards: u32) -> Self { - Self { - shard_id, - total_shards, - strategy: ShardStrategy::HashBased, - assignment: None, - } - } - - /// Create a shard config for currency-based sharding. - pub fn by_currency(shard_id: u32, total_shards: u32, currencies: Vec) -> Self { - Self { - shard_id, - total_shards, - strategy: ShardStrategy::ByCurrency, - assignment: Some(ShardAssignment { - currencies: Some(currencies), - issuer_types: None, - instrument_ids: None, - }), - } - } - - /// Check if this is a single-shard configuration. - pub fn is_single_shard(&self) -> bool { - self.total_shards == 1 - } - - /// Compute which shard owns a given string key (for hash-based). - pub fn shard_for_key(&self, key: &str) -> u32 { - if self.total_shards == 1 { - return 0; - } - Self::hash_string(key) % self.total_shards - } - - /// Check if this shard owns a given key (for hash-based). - pub fn owns_key(&self, key: &str) -> bool { - if self.total_shards == 1 { - return true; - } - self.shard_for_key(key) == self.shard_id - } - - /// Check if this shard owns a given currency (for ByCurrency strategy). - pub fn owns_currency(&self, currency: &str) -> bool { - if self.total_shards == 1 { - return true; - } - match &self.assignment { - Some(assignment) => assignment - .currencies - .as_ref() - .map(|cs| cs.iter().any(|c| c.eq_ignore_ascii_case(currency))) - .unwrap_or(false), - None => { - // Fall back to hash-based - self.owns_key(currency) - } - } - } - - /// Simple string hash function (DJB2 variant). - fn hash_string(s: &str) -> u32 { - let mut hash: u32 = 5381; - for byte in s.bytes() { - hash = hash.wrapping_mul(33).wrapping_add(byte as u32); - } - hash - } -} - /// Cached value with revision tracking. #[derive(Debug, Clone)] pub struct CachedValue { @@ -385,9 +253,6 @@ pub struct CachedValue { } /// The calculation graph manages dependencies and memoization. -/// -/// Supports sharding for large universes (>10K bonds) where the graph -/// is partitioned across multiple replicas. pub struct CalculationGraph { /// Dependencies: node -> nodes it depends on dependencies: DashMap>, @@ -412,19 +277,11 @@ pub struct CalculationGraph { /// Current global revision current_revision: AtomicU64, - - /// Shard configuration for distributed deployments - shard_config: ShardConfig, } impl CalculationGraph { - /// Create a new calculation graph (single shard). + /// Create a new calculation graph. pub fn new() -> Self { - Self::with_sharding(ShardConfig::single_shard()) - } - - /// Create a new calculation graph with sharding configuration. - pub fn with_sharding(shard_config: ShardConfig) -> Self { Self { dependencies: DashMap::new(), dependents: DashMap::new(), @@ -434,70 +291,9 @@ impl CalculationGraph { last_calc_time: DashMap::new(), throttle_pending: DashSet::new(), current_revision: AtomicU64::new(0), - shard_config, } } - /// Get the shard configuration. - pub fn shard_config(&self) -> &ShardConfig { - &self.shard_config - } - - /// Get this shard's ID. - pub fn shard_id(&self) -> u32 { - self.shard_config.shard_id - } - - /// Get the total number of shards. - pub fn total_shards(&self) -> u32 { - self.shard_config.total_shards - } - - /// Check if this graph is sharded (more than one shard). - pub fn is_sharded(&self) -> bool { - !self.shard_config.is_single_shard() - } - - /// Check if this shard owns a given node. - /// - /// For single-shard deployments, always returns true. - /// For multi-shard deployments, uses the configured strategy. - pub fn owns_node(&self, node_id: &NodeId) -> bool { - if !self.is_sharded() { - return true; - } - - // Extract the key for sharding - let key = self.node_shard_key(node_id); - self.shard_config.owns_key(&key) - } - - /// Get the shard key for a node. - /// - /// This extracts the relevant identifier for sharding (e.g., instrument ID). - fn node_shard_key(&self, node_id: &NodeId) -> String { - match node_id { - NodeId::Quote { instrument_id } => instrument_id.to_string(), - NodeId::BondPrice { instrument_id } => instrument_id.to_string(), - NodeId::Curve { curve_id } => curve_id.to_string(), - NodeId::CurveInput { curve_id, .. } => curve_id.to_string(), - NodeId::VolSurface { surface_id } => surface_id.to_string(), - NodeId::FxRate { pair } => pair.to_string(), - NodeId::IndexFixing { index, .. } => index.to_string(), - NodeId::InflationFixing { index, .. } => index.to_string(), - NodeId::Config { config_id } => config_id.clone(), - NodeId::EtfInav { etf_id } => etf_id.to_string(), - NodeId::EtfNav { etf_id } => etf_id.to_string(), - NodeId::Portfolio { portfolio_id } => portfolio_id.to_string(), - } - } - - /// Compute which shard would own a given node. - pub fn shard_for_node(&self, node_id: &NodeId) -> u32 { - let key = self.node_shard_key(node_id); - self.shard_config.shard_for_key(&key) - } - /// Add a node with its dependencies. pub fn add_node(&self, node_id: NodeId, deps: Vec) { // Store dependencies @@ -694,126 +490,5 @@ mod tests { assert!(graph.is_dirty(&bond_node)); } - // ============ Sharding Tests ============ - - #[test] - fn test_single_shard_owns_everything() { - let graph = CalculationGraph::new(); - - // Single shard should own all nodes - assert!(!graph.is_sharded()); - assert_eq!(graph.shard_id(), 0); - assert_eq!(graph.total_shards(), 1); - - let bond_node = NodeId::BondPrice { - instrument_id: InstrumentId::new("US912810TD00"), - }; - assert!(graph.owns_node(&bond_node)); - } - - #[test] - fn test_shard_config_hash_based() { - let config = ShardConfig::hash_based(0, 4); - - assert!(!config.is_single_shard()); - - // Same key always maps to same shard - let shard1 = config.shard_for_key("US912810TD00"); - let shard2 = config.shard_for_key("US912810TD00"); - assert_eq!(shard1, shard2); - - // Keys should be distributed across shards - let mut shard_counts = [0; 4]; - for i in 0..100 { - let key = format!("INSTRUMENT_{}", i); - let shard = config.shard_for_key(&key); - shard_counts[shard as usize] += 1; - } - // Each shard should get some keys (rough distribution check) - for count in shard_counts.iter() { - assert!(*count > 0, "Shard should have at least one key"); - } - } - - #[test] - fn test_sharded_graph_owns_subset() { - // Create 4 shards - let shard_configs: Vec<_> = (0..4).map(|i| ShardConfig::hash_based(i, 4)).collect(); - - // Test that exactly one shard owns each node - let test_ids = [ - "US912810TD00", - "US912810TE00", - "US912810TF00", - "XS123456789", - "DE000ABC123", - ]; - - for id in test_ids.iter() { - let _node = NodeId::BondPrice { - instrument_id: InstrumentId::new(*id), - }; - - let owners: Vec<_> = shard_configs - .iter() - .enumerate() - .filter(|(_, config)| config.owns_key(id)) - .map(|(i, _)| i) - .collect(); - - assert_eq!(owners.len(), 1, "Exactly one shard should own {}", id); - } - } - - #[test] - fn test_shard_for_node() { - let graph = CalculationGraph::with_sharding(ShardConfig::hash_based(0, 4)); - - let node = NodeId::BondPrice { - instrument_id: InstrumentId::new("US912810TD00"), - }; - - // Should return the correct shard - let shard = graph.shard_for_node(&node); - assert!(shard < 4); - - // Node should be owned only if it maps to shard 0 - assert_eq!(graph.owns_node(&node), shard == 0); - } - - #[test] - fn test_currency_based_sharding() { - let usd_config = ShardConfig::by_currency(0, 2, vec!["USD".to_string(), "CAD".to_string()]); - let eur_config = ShardConfig::by_currency(1, 2, vec!["EUR".to_string(), "GBP".to_string()]); - - assert!(usd_config.owns_currency("USD")); - assert!(usd_config.owns_currency("CAD")); - assert!(!usd_config.owns_currency("EUR")); - - assert!(eur_config.owns_currency("EUR")); - assert!(eur_config.owns_currency("GBP")); - assert!(!eur_config.owns_currency("USD")); - } - - #[test] - fn test_node_shard_key_extraction() { - let graph = CalculationGraph::new(); - - // Test various node types - let bond_node = NodeId::BondPrice { - instrument_id: InstrumentId::new("US912810TD00"), - }; - assert_eq!(graph.node_shard_key(&bond_node), "US912810TD00"); - - let quote_node = NodeId::Quote { - instrument_id: InstrumentId::new("XS123456789"), - }; - assert_eq!(graph.node_shard_key("e_node), "XS123456789"); - - let curve_node = NodeId::Curve { - curve_id: CurveId::new("USD.SOFR"), - }; - assert_eq!(graph.node_shard_key(&curve_node), "USD.SOFR"); - } } diff --git a/crates/convex-engine/src/lib.rs b/crates/convex-engine/src/lib.rs index 2ae60601..c72a9861 100644 --- a/crates/convex-engine/src/lib.rs +++ b/crates/convex-engine/src/lib.rs @@ -56,7 +56,7 @@ mod context; // Re-exports pub use builder::PricingEngineBuilder; pub use calc_graph::{ - CalculationGraph, NodeId, NodeValue, ShardAssignment, ShardConfig, ShardStrategy, + CalculationGraph, NodeId, NodeValue, }; pub use curve_builder::{BuiltCurve, CurveBuilder}; pub use error::EngineError; From 19736045dcf828086c5f311548dc3fe005c92a7f Mon Sep 17 00:00:00 2001 From: sujitn Date: Sun, 31 May 2026 17:56:26 +0100 Subject: [PATCH 4/5] refactor(server): improve handler input validation, delete payloads, and database check safety --- .../convex-server/src/handlers/analytics.rs | 143 +++++++------- crates/convex-server/src/handlers/mod.rs | 177 ++++++++++++------ 2 files changed, 198 insertions(+), 122 deletions(-) diff --git a/crates/convex-server/src/handlers/analytics.rs b/crates/convex-server/src/handlers/analytics.rs index 280dcc59..a618b3d4 100644 --- a/crates/convex-server/src/handlers/analytics.rs +++ b/crates/convex-server/src/handlers/analytics.rs @@ -131,13 +131,13 @@ pub async fn price_single_bond( use rust_decimal::prelude::FromPrimitive; // Parse settlement date - let settlement_date = match parse_date(&request.settlement_date) { + let settlement_date = match super::parse_date(&request.settlement_date) { Ok(d) => d, Err(e) => { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ); + ).into_response(); } }; @@ -228,14 +228,14 @@ pub async fn price_single_bond( Ok(quote) => { // Publish to WebSocket subscribers state.ws_state.publish_bond_quote(quote.clone()); - (StatusCode::OK, Json(serde_json::to_value(quote).unwrap())) + (StatusCode::OK, Json(serde_json::to_value(quote).unwrap())).into_response() } Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Pricing failed: {}", e) })), - ), + ).into_response(), } } @@ -591,13 +591,13 @@ pub async fn batch_price( use std::time::Instant; // Parse settlement date - let settlement_date = match parse_date(&request.settlement_date) { + let settlement_date = match super::parse_date(&request.settlement_date) { Ok(d) => d, Err(e) => { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ); + ).into_response(); } }; @@ -674,7 +674,7 @@ pub async fn batch_price( ( StatusCode::OK, Json(serde_json::to_value(response).unwrap()), - ) + ).into_response() } // ============================================================================= @@ -741,23 +741,33 @@ pub async fn calculate_inav( Json(request): Json, ) -> impl IntoResponse { // Parse dates - let settlement_date = match parse_date(&request.settlement_date) { + let settlement_date = match super::parse_date(&request.settlement_date) { Ok(d) => d, Err(e) => { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ); + ).into_response(); } }; - let as_of_date = match parse_date(&request.holdings.as_of_date) { + let as_of_date = match super::parse_date(&request.holdings.as_of_date) { Ok(d) => d, Err(e) => { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("Invalid as_of_date: {}", e) })), - ); + ).into_response(); + } + }; + + let currency = match super::parse_currency(&request.holdings.currency) { + Ok(c) => c, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e.to_string() })), + ).into_response(); } }; @@ -765,7 +775,7 @@ pub async fn calculate_inav( let holdings = EtfHoldings { etf_id: EtfId::new(&request.holdings.etf_id), name: request.holdings.name, - currency: parse_currency(&request.holdings.currency), + currency, as_of_date, holdings: request .holdings @@ -796,12 +806,12 @@ pub async fn calculate_inav( Ok(output) => { // Publish to WebSocket subscribers state.ws_state.publish_etf_quote(output.clone()); - (StatusCode::OK, Json(serde_json::json!(output))) + (StatusCode::OK, Json(serde_json::json!(output))).into_response() } Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), - ), + ).into_response(), } } @@ -840,28 +850,38 @@ pub async fn batch_calculate_inav( Json(request): Json, ) -> impl IntoResponse { // Parse settlement date - let settlement_date = match parse_date(&request.settlement_date) { + let settlement_date = match super::parse_date(&request.settlement_date) { Ok(d) => d, Err(e) => { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ); + ).into_response(); } }; // Convert all holdings let mut holdings_list = Vec::new(); for etf_input in request.etfs { - let as_of_date = match parse_date(&etf_input.as_of_date) { + let as_of_date = match super::parse_date(&etf_input.as_of_date) { Ok(d) => d, Err(_) => settlement_date, // Default to settlement }; + let currency = match super::parse_currency(&etf_input.currency) { + Ok(c) => c, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e.to_string() })), + ).into_response(); + } + }; + holdings_list.push(EtfHoldings { etf_id: EtfId::new(&etf_input.etf_id), name: etf_input.name, - currency: parse_currency(&etf_input.currency), + currency, as_of_date, holdings: etf_input .holdings @@ -918,7 +938,7 @@ pub async fn batch_calculate_inav( ( StatusCode::OK, Json(serde_json::to_value(response).unwrap()), - ) + ).into_response() } // ============================================================================= @@ -973,7 +993,15 @@ pub async fn calculate_portfolio_analytics( Json(request): Json, ) -> impl IntoResponse { // Convert to internal types - let portfolio = convert_portfolio_input(&request.portfolio); + let portfolio = match convert_portfolio_input(&request.portfolio) { + Ok(p) => p, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e })), + ).into_response(); + } + }; let analyzer = state.engine.portfolio_analyzer(); @@ -981,12 +1009,12 @@ pub async fn calculate_portfolio_analytics( Ok(output) => { // Publish to WebSocket subscribers state.ws_state.publish_portfolio_analytics(output.clone()); - (StatusCode::OK, Json(serde_json::to_value(output).unwrap())) + (StatusCode::OK, Json(serde_json::to_value(output).unwrap())).into_response() } Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), - ), + ).into_response(), } } @@ -1023,11 +1051,18 @@ pub async fn batch_calculate_portfolio_analytics( Json(request): Json, ) -> impl IntoResponse { // Convert all portfolios - let portfolios: Vec = request - .portfolios - .iter() - .map(convert_portfolio_input) - .collect(); + let mut portfolios = Vec::new(); + for p_input in request.portfolios { + match convert_portfolio_input(&p_input) { + Ok(p) => portfolios.push(p), + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e })), + ).into_response(); + } + } + } let analyzer = state.engine.portfolio_analyzer(); let results = analyzer.calculate_batch(&portfolios, &request.bond_prices); @@ -1060,7 +1095,7 @@ pub async fn batch_calculate_portfolio_analytics( ( StatusCode::OK, Json(serde_json::to_value(response).unwrap()), - ) + ).into_response() } /// Request for duration contribution analysis. @@ -1097,7 +1132,15 @@ pub async fn calculate_duration_contribution( State(state): State>, Json(request): Json, ) -> impl IntoResponse { - let portfolio = convert_portfolio_input(&request.portfolio); + let portfolio = match convert_portfolio_input(&request.portfolio) { + Ok(p) => p, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e })), + ).into_response(); + } + }; let analyzer = state.engine.portfolio_analyzer(); let contributions = analyzer.duration_contribution(&portfolio, &request.bond_prices); @@ -1123,7 +1166,7 @@ pub async fn calculate_duration_contribution( ( StatusCode::OK, Json(serde_json::to_value(response).unwrap()), - ) + ).into_response() } // ============================================================================= @@ -2273,18 +2316,10 @@ fn convert_bucket_metrics(m: &BucketMetrics) -> ApiBucketMetrics { } /// Convert API portfolio input to internal Portfolio type. -fn convert_portfolio_input(input: &PortfolioInput) -> Portfolio { - let currency = match input.currency.to_uppercase().as_str() { - "EUR" => Currency::EUR, - "GBP" => Currency::GBP, - "JPY" => Currency::JPY, - "CHF" => Currency::CHF, - "CAD" => Currency::CAD, - "AUD" => Currency::AUD, - _ => Currency::USD, - }; +fn convert_portfolio_input(input: &PortfolioInput) -> Result { + let currency = super::parse_currency(&input.currency).map_err(|e| e.to_string())?; - Portfolio { + Ok(Portfolio { portfolio_id: PortfolioId::new(&input.portfolio_id), name: input.name.clone(), currency, @@ -2298,7 +2333,7 @@ fn convert_portfolio_input(input: &PortfolioInput) -> Portfolio { rating: p.rating.clone(), }) .collect(), - } + }) } // ============================================================================= @@ -4278,7 +4313,7 @@ pub async fn calculate_sec_yield_handler( State(_state): State>, Json(request): Json, ) -> impl IntoResponse { - let as_of_date = match parse_date(&request.as_of_date) { + let as_of_date = match super::parse_date(&request.as_of_date) { Ok(d) => d, Err(e) => { return ( @@ -4717,26 +4752,4 @@ fn convert_key_rate_positions(positions: &[KeyRatePosition]) -> Vec { .collect() } -// ============================================================================= -// HELPERS -// ============================================================================= - -/// Parse a date string (YYYY-MM-DD) into a Date. -fn parse_date(s: &str) -> Result { - let parts: Vec<&str> = s.split('-').collect(); - if parts.len() != 3 { - return Err(format!("Invalid date format: {} (expected YYYY-MM-DD)", s)); - } - - let year = parts[0] - .parse::() - .map_err(|_| format!("Invalid year: {}", parts[0]))?; - let month = parts[1] - .parse::() - .map_err(|_| format!("Invalid month: {}", parts[1]))?; - let day = parts[2] - .parse::() - .map_err(|_| format!("Invalid day: {}", parts[2]))?; - Date::from_ymd(year, month, day).map_err(|e| format!("Invalid date: {}", e)) -} diff --git a/crates/convex-server/src/handlers/mod.rs b/crates/convex-server/src/handlers/mod.rs index 932139fc..021e1c42 100644 --- a/crates/convex-server/src/handlers/mod.rs +++ b/crates/convex-server/src/handlers/mod.rs @@ -147,23 +147,34 @@ pub async fn get_bond_quote( let id = InstrumentId::new(&instrument_id); // Look up bond reference data - let bond = match state.engine.reference_data().bonds.get_by_id(&id).await { + let bond = match state.bond_store.get_by_id(&id).await { Ok(Some(bond)) => bond, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": format!("Bond not found: {}", instrument_id) - })), - ); - } + Ok(None) => match state.engine.reference_data().bonds.get_by_id(&id).await { + Ok(Some(bond)) => bond, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("Bond not found: {}", instrument_id) + })), + ).into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to look up bond in reference data: {}", e) + })), + ).into_response(); + } + }, Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Failed to look up bond: {}", e) + "error": format!("Failed to look up bond in store: {}", e) })), - ); + ).into_response(); } }; @@ -175,7 +186,7 @@ pub async fn get_bond_quote( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ); + ).into_response(); } }, None => { @@ -203,14 +214,14 @@ pub async fn get_bond_quote( Ok(quote) => { // Publish to WebSocket subscribers state.ws_state.publish_bond_quote(quote.clone()); - (StatusCode::OK, Json(serde_json::to_value(quote).unwrap())) + (StatusCode::OK, Json(serde_json::to_value(quote).unwrap())).into_response() } Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("Pricing failed: {}", e) })), - ), + ).into_response(), } } @@ -322,12 +333,12 @@ pub async fn delete_curve( let id = CurveId::new(&curve_id); if state.engine.curve_builder().delete(&id) { - (StatusCode::NO_CONTENT, Json(serde_json::json!({}))) + StatusCode::NO_CONTENT.into_response() } else { ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Curve not found" })), - ) + ).into_response() } } @@ -347,7 +358,7 @@ pub async fn get_curve_zero_rate( return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Curve not found" })), - ); + ).into_response(); } }; @@ -358,7 +369,25 @@ pub async fn get_curve_zero_rate( "quarterly" => Compounding::Quarterly, "monthly" => Compounding::Monthly, "daily" => Compounding::Daily, - _ => Compounding::Continuous, + "continuous" => Compounding::Continuous, + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": format!("Unsupported compounding value: '{}'", query.compounding) + })), + ).into_response(); + } + }; + + let compounding_str = match compounding { + Compounding::Simple => "simple", + Compounding::Annual => "annual", + Compounding::SemiAnnual => "semiannual", + Compounding::Quarterly => "quarterly", + Compounding::Monthly => "monthly", + Compounding::Daily => "daily", + Compounding::Continuous => "continuous", }; match curve.zero_rate(tenor, compounding) { @@ -368,13 +397,13 @@ pub async fn get_curve_zero_rate( "curve_id": curve_id, "tenor": tenor, "zero_rate": rate, - "compounding": query.compounding + "compounding": compounding_str })), - ), + ).into_response(), Err(e) => ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e.to_string() })), - ), + ).into_response(), } } @@ -564,13 +593,24 @@ pub async fn create_bond( Json(bond): Json, ) -> impl IntoResponse { // Check if bond already exists - if let Ok(Some(_)) = state.bond_store.get_by_id(&bond.instrument_id).await { - return ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "error": format!("Bond already exists: {}", bond.instrument_id.as_str()) - })), - ); + match state.bond_store.get_by_id(&bond.instrument_id).await { + Ok(Some(_)) => { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": format!("Bond already exists: {}", bond.instrument_id.as_str()) + })), + ).into_response(); + } + Ok(None) => {} + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Database read error: {}", e) + })), + ).into_response(); + } } // Set timestamp if not provided @@ -587,7 +627,7 @@ pub async fn create_bond( ( StatusCode::CREATED, Json(serde_json::to_value(created).unwrap()), - ) + ).into_response() } /// Update an existing bond. @@ -637,11 +677,11 @@ pub async fn delete_bond( let id = InstrumentId::new(&instrument_id); match state.bond_store.delete(&id) { - Some(_) => (StatusCode::NO_CONTENT, Json(serde_json::json!({}))), + Some(_) => StatusCode::NO_CONTENT.into_response(), None => ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": format!("Bond not found: {}", instrument_id) })), - ), + ).into_response(), } } @@ -656,9 +696,20 @@ pub async fn batch_create_bonds( for mut bond in request.bonds { // Check if bond already exists - if let Ok(Some(_)) = state.bond_store.get_by_id(&bond.instrument_id).await { - skipped += 1; - continue; + match state.bond_store.get_by_id(&bond.instrument_id).await { + Ok(Some(_)) => { + skipped += 1; + continue; + } + Ok(None) => {} + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to verify bond existence: {}", e) + })), + ).into_response(); + } } // Set timestamp if not provided @@ -682,7 +733,7 @@ pub async fn batch_create_bonds( ( StatusCode::OK, Json(serde_json::to_value(response).unwrap()), - ) + ).into_response() } /// Get bond by ISIN. @@ -854,11 +905,11 @@ pub async fn delete_portfolio( Path(portfolio_id): Path, ) -> impl IntoResponse { match state.portfolio_store.delete(&portfolio_id) { - Some(_) => (StatusCode::NO_CONTENT, Json(serde_json::json!({}))), + Some(_) => StatusCode::NO_CONTENT.into_response(), None => ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": format!("Portfolio not found: {}", portfolio_id) })), - ), + ).into_response(), } } @@ -973,26 +1024,38 @@ pub(crate) fn parse_date(s: &str) -> Result { Date::parse(s).map_err(|e| e.to_string()) } -pub(crate) fn parse_currency(s: &str) -> Currency { +/// Error returned when currency parsing fails. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseCurrencyError(pub String); + +impl std::fmt::Display for ParseCurrencyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Unsupported currency: {}", self.0) + } +} + +impl std::error::Error for ParseCurrencyError {} + +pub(crate) fn parse_currency(s: &str) -> Result { match s.to_uppercase().as_str() { - "USD" => Currency::USD, - "EUR" => Currency::EUR, - "GBP" => Currency::GBP, - "JPY" => Currency::JPY, - "CHF" => Currency::CHF, - "CAD" => Currency::CAD, - "AUD" => Currency::AUD, - "NZD" => Currency::NZD, - "SEK" => Currency::SEK, - "NOK" => Currency::NOK, - "DKK" => Currency::DKK, - "HKD" => Currency::HKD, - "SGD" => Currency::SGD, - "CNY" => Currency::CNY, - "INR" => Currency::INR, - "BRL" => Currency::BRL, - "MXN" => Currency::MXN, - "ZAR" => Currency::ZAR, - _ => Currency::USD, // Default to USD for unknown currencies + "USD" => Ok(Currency::USD), + "EUR" => Ok(Currency::EUR), + "GBP" => Ok(Currency::GBP), + "JPY" => Ok(Currency::JPY), + "CHF" => Ok(Currency::CHF), + "CAD" => Ok(Currency::CAD), + "AUD" => Ok(Currency::AUD), + "NZD" => Ok(Currency::NZD), + "SEK" => Ok(Currency::SEK), + "NOK" => Ok(Currency::NOK), + "DKK" => Ok(Currency::DKK), + "HKD" => Ok(Currency::HKD), + "SGD" => Ok(Currency::SGD), + "CNY" => Ok(Currency::CNY), + "INR" => Ok(Currency::INR), + "BRL" => Ok(Currency::BRL), + "MXN" => Ok(Currency::MXN), + "ZAR" => Ok(Currency::ZAR), + _ => Err(ParseCurrencyError(s.to_string())), } } From 9b9fc428e597fb33d81a2e1ca8b0e753638f3c5f Mon Sep 17 00:00:00 2001 From: sujitn Date: Sun, 31 May 2026 17:57:54 +0100 Subject: [PATCH 5/5] style: format code with cargo fmt --- crates/convex-engine/src/calc_graph.rs | 2 - crates/convex-engine/src/lib.rs | 4 +- .../convex-server/src/handlers/analytics.rs | 54 ++++++++++++------- crates/convex-server/src/handlers/mod.rs | 52 +++++++++++------- 4 files changed, 69 insertions(+), 43 deletions(-) diff --git a/crates/convex-engine/src/calc_graph.rs b/crates/convex-engine/src/calc_graph.rs index 246a3412..aaa76ed1 100644 --- a/crates/convex-engine/src/calc_graph.rs +++ b/crates/convex-engine/src/calc_graph.rs @@ -489,6 +489,4 @@ mod tests { assert!(graph.is_dirty(&curve_node)); assert!(graph.is_dirty(&bond_node)); } - - } diff --git a/crates/convex-engine/src/lib.rs b/crates/convex-engine/src/lib.rs index c72a9861..6d18cd1d 100644 --- a/crates/convex-engine/src/lib.rs +++ b/crates/convex-engine/src/lib.rs @@ -55,9 +55,7 @@ mod context; // Re-exports pub use builder::PricingEngineBuilder; -pub use calc_graph::{ - CalculationGraph, NodeId, NodeValue, -}; +pub use calc_graph::{CalculationGraph, NodeId, NodeValue}; pub use curve_builder::{BuiltCurve, CurveBuilder}; pub use error::EngineError; pub use etf_pricing::EtfPricer; diff --git a/crates/convex-server/src/handlers/analytics.rs b/crates/convex-server/src/handlers/analytics.rs index a618b3d4..2bebe8c8 100644 --- a/crates/convex-server/src/handlers/analytics.rs +++ b/crates/convex-server/src/handlers/analytics.rs @@ -88,7 +88,6 @@ use convex_traits::reference_data::{ }; /// Application state. - use super::*; /// Query parameters for single bond quote. @@ -137,7 +136,8 @@ pub async fn price_single_bond( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ).into_response(); + ) + .into_response(); } }; @@ -235,7 +235,8 @@ pub async fn price_single_bond( Json(serde_json::json!({ "error": format!("Pricing failed: {}", e) })), - ).into_response(), + ) + .into_response(), } } @@ -597,7 +598,8 @@ pub async fn batch_price( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ).into_response(); + ) + .into_response(); } }; @@ -674,7 +676,8 @@ pub async fn batch_price( ( StatusCode::OK, Json(serde_json::to_value(response).unwrap()), - ).into_response() + ) + .into_response() } // ============================================================================= @@ -747,7 +750,8 @@ pub async fn calculate_inav( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ).into_response(); + ) + .into_response(); } }; @@ -757,7 +761,8 @@ pub async fn calculate_inav( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("Invalid as_of_date: {}", e) })), - ).into_response(); + ) + .into_response(); } }; @@ -767,7 +772,8 @@ pub async fn calculate_inav( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e.to_string() })), - ).into_response(); + ) + .into_response(); } }; @@ -811,7 +817,8 @@ pub async fn calculate_inav( Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), - ).into_response(), + ) + .into_response(), } } @@ -856,7 +863,8 @@ pub async fn batch_calculate_inav( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ).into_response(); + ) + .into_response(); } }; @@ -874,7 +882,8 @@ pub async fn batch_calculate_inav( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e.to_string() })), - ).into_response(); + ) + .into_response(); } }; @@ -938,7 +947,8 @@ pub async fn batch_calculate_inav( ( StatusCode::OK, Json(serde_json::to_value(response).unwrap()), - ).into_response() + ) + .into_response() } // ============================================================================= @@ -999,7 +1009,8 @@ pub async fn calculate_portfolio_analytics( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ).into_response(); + ) + .into_response(); } }; @@ -1014,7 +1025,8 @@ pub async fn calculate_portfolio_analytics( Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), - ).into_response(), + ) + .into_response(), } } @@ -1059,7 +1071,8 @@ pub async fn batch_calculate_portfolio_analytics( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ).into_response(); + ) + .into_response(); } } } @@ -1095,7 +1108,8 @@ pub async fn batch_calculate_portfolio_analytics( ( StatusCode::OK, Json(serde_json::to_value(response).unwrap()), - ).into_response() + ) + .into_response() } /// Request for duration contribution analysis. @@ -1138,7 +1152,8 @@ pub async fn calculate_duration_contribution( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ).into_response(); + ) + .into_response(); } }; @@ -1166,7 +1181,8 @@ pub async fn calculate_duration_contribution( ( StatusCode::OK, Json(serde_json::to_value(response).unwrap()), - ).into_response() + ) + .into_response() } // ============================================================================= @@ -4751,5 +4767,3 @@ fn convert_key_rate_positions(positions: &[KeyRatePosition]) -> Vec { }) .collect() } - - diff --git a/crates/convex-server/src/handlers/mod.rs b/crates/convex-server/src/handlers/mod.rs index 021e1c42..7724ad87 100644 --- a/crates/convex-server/src/handlers/mod.rs +++ b/crates/convex-server/src/handlers/mod.rs @@ -89,7 +89,6 @@ use convex_traits::reference_data::{ /// Application state. - pub struct AppState { /// The pricing engine pub engine: Arc, @@ -157,7 +156,8 @@ pub async fn get_bond_quote( Json(serde_json::json!({ "error": format!("Bond not found: {}", instrument_id) })), - ).into_response(); + ) + .into_response(); } Err(e) => { return ( @@ -165,7 +165,8 @@ pub async fn get_bond_quote( Json(serde_json::json!({ "error": format!("Failed to look up bond in reference data: {}", e) })), - ).into_response(); + ) + .into_response(); } }, Err(e) => { @@ -174,7 +175,8 @@ pub async fn get_bond_quote( Json(serde_json::json!({ "error": format!("Failed to look up bond in store: {}", e) })), - ).into_response(); + ) + .into_response(); } }; @@ -186,7 +188,8 @@ pub async fn get_bond_quote( return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e })), - ).into_response(); + ) + .into_response(); } }, None => { @@ -221,7 +224,8 @@ pub async fn get_bond_quote( Json(serde_json::json!({ "error": format!("Pricing failed: {}", e) })), - ).into_response(), + ) + .into_response(), } } @@ -338,7 +342,8 @@ pub async fn delete_curve( ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Curve not found" })), - ).into_response() + ) + .into_response() } } @@ -358,7 +363,8 @@ pub async fn get_curve_zero_rate( return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Curve not found" })), - ).into_response(); + ) + .into_response(); } }; @@ -376,7 +382,8 @@ pub async fn get_curve_zero_rate( Json(serde_json::json!({ "error": format!("Unsupported compounding value: '{}'", query.compounding) })), - ).into_response(); + ) + .into_response(); } }; @@ -399,11 +406,13 @@ pub async fn get_curve_zero_rate( "zero_rate": rate, "compounding": compounding_str })), - ).into_response(), + ) + .into_response(), Err(e) => ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e.to_string() })), - ).into_response(), + ) + .into_response(), } } @@ -600,7 +609,8 @@ pub async fn create_bond( Json(serde_json::json!({ "error": format!("Bond already exists: {}", bond.instrument_id.as_str()) })), - ).into_response(); + ) + .into_response(); } Ok(None) => {} Err(e) => { @@ -609,7 +619,8 @@ pub async fn create_bond( Json(serde_json::json!({ "error": format!("Database read error: {}", e) })), - ).into_response(); + ) + .into_response(); } } @@ -627,7 +638,8 @@ pub async fn create_bond( ( StatusCode::CREATED, Json(serde_json::to_value(created).unwrap()), - ).into_response() + ) + .into_response() } /// Update an existing bond. @@ -681,7 +693,8 @@ pub async fn delete_bond( None => ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": format!("Bond not found: {}", instrument_id) })), - ).into_response(), + ) + .into_response(), } } @@ -708,7 +721,8 @@ pub async fn batch_create_bonds( Json(serde_json::json!({ "error": format!("Failed to verify bond existence: {}", e) })), - ).into_response(); + ) + .into_response(); } } @@ -733,7 +747,8 @@ pub async fn batch_create_bonds( ( StatusCode::OK, Json(serde_json::to_value(response).unwrap()), - ).into_response() + ) + .into_response() } /// Get bond by ISIN. @@ -909,7 +924,8 @@ pub async fn delete_portfolio( None => ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": format!("Portfolio not found: {}", portfolio_id) })), - ).into_response(), + ) + .into_response(), } }