From 6d8445d28d56b71f4b0da49f6daf6ad4214c7092 Mon Sep 17 00:00:00 2001 From: sujitn Date: Fri, 30 Oct 2026 16:50:31 +0000 Subject: [PATCH] Server P11c: each request-time product reads only the curves it uses, once per context (G33, D69) --- .../benches/runtime_benchmarks.rs | 8 +- crates/convex-engine/src/curve_resolution.rs | 2 +- crates/convex-engine/src/lib.rs | 102 +++++++++----- crates/convex-engine/src/request_valuation.rs | 26 ++-- crates/convex-engine/src/telemetry.rs | 3 +- .../tests/support/configuration_setup.rs | 4 +- crates/convex-ports/src/error.rs | 2 +- crates/convex-server/README.md | 16 ++- crates/convex-server/src/handlers.rs | 38 ++--- .../tests/request_time_products.rs | 131 ++++++++++++++++++ 10 files changed, 258 insertions(+), 74 deletions(-) diff --git a/crates/convex-engine/benches/runtime_benchmarks.rs b/crates/convex-engine/benches/runtime_benchmarks.rs index 87adafde..bc404225 100644 --- a/crates/convex-engine/benches/runtime_benchmarks.rs +++ b/crates/convex-engine/benches/runtime_benchmarks.rs @@ -166,10 +166,12 @@ fn bench_request_time(c: &mut Criterion) { }); group.bench_function("context_curves_price", |b| { b.iter(|| { - let context = rt - .block_on(engine.request_context(Some(&id), time(0))) + let curves = rt + .block_on(async { + let mut context = engine.request_context(Some(&id), time(0)).await?; + context.curves_for(&bond).await + }) .unwrap(); - let curves = context.curves_for(&bond).unwrap(); let input = PricingInput::with_mid_price( bond.clone(), settlement, diff --git a/crates/convex-engine/src/curve_resolution.rs b/crates/convex-engine/src/curve_resolution.rs index 5a489d9f..883f7729 100644 --- a/crates/convex-engine/src/curve_resolution.rs +++ b/crates/convex-engine/src/curve_resolution.rs @@ -18,7 +18,7 @@ use crate::curve_builder::BuiltCurve; use crate::telemetry::{observe_curve_build, CurvePath}; /// Why a request could not obtain a curve it needs. Nothing is substituted. -#[derive(Debug, thiserror::Error)] +#[derive(Debug, Clone, thiserror::Error)] pub enum CurveResolutionError { /// The request names no configuration and the runtime runs none. #[error("no valuation context: name an activated configuration")] diff --git a/crates/convex-engine/src/lib.rs b/crates/convex-engine/src/lib.rs index 2ef5a747..f662ffec 100644 --- a/crates/convex-engine/src/lib.rs +++ b/crates/convex-engine/src/lib.rs @@ -102,14 +102,28 @@ pub struct PricingCurves { pub benchmark: Option, } +/// The most curve sets one request context resolves. Each set is the roots a product +/// path reads from the configuration's roles: a bullet's discount and swap curves, a +/// callable's discount curve, and a note's discount and FRN reference curves. +const MAX_CURVE_SETS: usize = 3; + +/// One curve set's resolution, kept by its context, failure included. +type CurveRead = + Result, curve_resolution::CurveResolutionError>; + /// The approved valuation context of one request: an activated configuration's exact -/// pricing version and the curves its roles name, resolved once at the request cutoff. -/// Government and FRN reference roles are not consumed here, as in snapshot valuation. +/// pricing version, at the request cutoff. Each product path reads the curves it needs +/// at first use, and the context keeps every set it resolved, a failed one included, so +/// a request resolves each set once (D69). The government role is not consumed here, as +/// in snapshot valuation. pub struct RequestContext { id: convex_core::contracts::SourceId, activation_revision: convex_core::contracts::Version, config: ports::storage::BondPricingConfig, - resolved: curve_resolution::ResolvedCurves, + known_at: convex_core::contracts::UtcTimestamp, + curves: Arc, + /// Each curve set read, by its roots, in first-use order. + reads: Vec<(Vec, CurveRead)>, } impl RequestContext { @@ -123,27 +137,59 @@ impl RequestContext { self.activation_revision } - /// Every consumed (curve, version) pin, roots and dependencies. - pub fn pins(&self) -> impl Iterator { - self.resolved.pins() + /// Every (curve, version) pin the request read, roots and dependencies, each once, + /// in first-use order. A set that failed to resolve priced nothing and lists nothing. + pub fn pins(&self) -> Vec<(&str, convex_core::contracts::Version)> { + let mut pins = Vec::new(); + for pin in self + .reads + .iter() + .filter_map(|(_, read)| read.as_ref().ok()) + .flat_map(|resolved| resolved.pins()) + { + if !pins.contains(&pin) { + pins.push(pin); + } + } + pins } - /// Curves for `bond`, which the configuration must cover; each curve must fit - /// its role and the bond's currency. - pub fn curves_for( - &self, + /// The curves `roots` name, with their dependencies, at the cutoff. A set is + /// resolved at its first read and kept, failure included. + pub(crate) async fn read(&mut self, roots: &[convex_core::ids::CurveId]) -> CurveRead { + if let Some((_, read)) = self.reads.iter().find(|(set, _)| set.as_slice() == roots) { + return read.clone(); + } + if self.reads.len() >= MAX_CURVE_SETS { + return Err(ports::TraitError::CapacityExceeded.into()); + } + let read = curve_resolution::resolve_curves(self.curves.as_ref(), roots, self.known_at) + .await + .map(Arc::new); + self.reads.push((roots.to_vec(), read.clone())); + read + } + + /// Curves for a bullet `bond`, which the configuration must cover: its discount + /// curve and swap benchmark. Each curve must fit its role and the bond's currency. + pub async fn curves_for( + &mut self, bond: &ports::reference_data::BondReferenceData, ) -> Result { use curve_resolution::CurveResolutionError; if !self.config.applies_to.matches(bond) { return Err(CurveResolutionError::NotCovered(self.id.clone())); } + let roles = &self.config.analytics_curves; + let mut roots = vec![roles.discount_curve.clone()]; + roots.extend(roles.swap_curve.clone()); + let resolved = self.read(&roots).await?; let checked = |id: &convex_core::ids::CurveId, discount: bool| { - let native = self.resolved.native(id)?; + let native = resolved.native(id)?; if !curve_resolution::pricing_role_matches(native.recipe(), discount, bond.currency) { return Err(CurveResolutionError::Role(id.clone())); } - self.resolved.curve(id) + resolved.curve(id) }; let roles = &self.config.analytics_curves; Ok(PricingCurves { @@ -366,10 +412,10 @@ impl PricingEngine { } /// The approved valuation context of a request at `known_at`: the active - /// selection of `configuration` (or of the default) and the curves its roles - /// name, current at the cutoff. Only an activation approves a configuration; - /// a staged version never prices. The configuration is the one active now, and - /// the cutoff governs the curve reads. + /// selection of `configuration` (or of the default). Only an activation approves a + /// configuration; a staged version never prices. The configuration is the one + /// active now, and the cutoff governs the curve reads, which each product path + /// makes at first use (D69). pub async fn request_context( &self, configuration: Option<&convex_core::contracts::SourceId>, @@ -396,18 +442,13 @@ impl PricingEngine { .active(id) .await? .ok_or_else(|| CurveResolutionError::NotActivated(id.clone()))?; - let config = active.staged.document.pricing; - let roles = &config.analytics_curves; - let mut roots = vec![roles.discount_curve.clone()]; - roots.extend(roles.swap_curve.clone()); - let resolved = - curve_resolution::resolve_curves(self.storage.curves.as_ref(), &roots, known_at) - .await?; Ok(RequestContext { id: id.clone(), activation_revision: active.activation.revision, - config, - resolved, + config: active.staged.document.pricing, + known_at, + curves: self.storage.curves.clone(), + reads: Vec::new(), }) } @@ -448,7 +489,7 @@ impl PricingEngine { /// is stored. pub async fn value_request( &self, - context: &RequestContext, + context: &mut RequestContext, reference: &ports::reference_repository::ReferenceRecord, settlement_date: convex_core::contracts::BusinessDate, known_at: convex_core::contracts::UtcTimestamp, @@ -460,14 +501,7 @@ impl PricingEngine { .as_ref() .ok_or(RequestValuationError::NoRuntime)?; let (snapshot, valuation, curves) = valuer - .prepare( - &self.storage, - context, - reference, - settlement_date, - known_at, - mark, - ) + .prepare(context, reference, settlement_date, known_at, mark) .await?; let invalid = |_| ports::TraitError::InvalidInput("invalid request identity"); // As the stream names its valuations, by context. diff --git a/crates/convex-engine/src/request_valuation.rs b/crates/convex-engine/src/request_valuation.rs index 0c035b74..742863e5 100644 --- a/crates/convex-engine/src/request_valuation.rs +++ b/crates/convex-engine/src/request_valuation.rs @@ -23,7 +23,7 @@ use convex_ports::{ use rust_decimal::Decimal; use crate::context::{ValuationContext, ValuationContextSpec, ValuationScenario}; -use crate::curve_resolution::{resolve_curves, CurveResolutionError}; +use crate::curve_resolution::CurveResolutionError; use crate::optional_valuation::{product_measures, product_model}; use crate::runtime::RuntimeServices; use crate::snapshot::{read_curve_ids, SnapshotAssembler, SnapshotValuationResult}; @@ -93,12 +93,12 @@ impl RequestValuer { } /// The one-instrument snapshot of `reference` at `known_at` and its context. The - /// curves are the ones the product's valuation reads, resolved at the cutoff, so a - /// missing or stale one refuses the request as it does for bullets (P9a). + /// curves are the ones the product's valuation reads, read through `context` at its + /// cutoff (D69). A missing or stale one refuses the request as it does for bullets + /// (P9a); a curve the product does not read refuses nothing. pub(crate) async fn prepare( &self, - storage: &StorageAdapter, - context: &RequestContext, + context: &mut RequestContext, reference: &ReferenceRecord, settlement_date: BusinessDate, known_at: UtcTimestamp, @@ -109,6 +109,12 @@ impl RequestValuer { .bond .as_ref() .ok_or(TraitError::InvalidInput("the stored reference is deleted"))?; + // The context reads its curves at its own cutoff, so the snapshot must share it. + if known_at != context.known_at { + return Err( + TraitError::InvalidInput("a request values at its context's cutoff").into(), + ); + } let config = context.config(); if !config.applies_to.matches(bond) { return Err(CurveResolutionError::NotCovered(context.id.clone()).into()); @@ -131,17 +137,17 @@ impl RequestValuer { .into_iter() .map(convex_core::ids::CurveId::new) .collect(); - let curves = resolve_curves(storage.curves.as_ref(), &roots, known_at).await?; let invalid = |_| TraitError::InvalidInput("invalid request identity"); + let configuration = ConfigurationPin { + id: context.id.clone(), + version: Version::new(config.version).map_err(invalid)?, + }; + let curves = context.read(&roots).await?; let key = QuoteKey { instrument_id: reference.instrument_id.clone(), source: REQUEST_SOURCE.into(), }; let instrument_id = InstrumentId::new(reference.instrument_id.as_str()).map_err(invalid)?; - let configuration = ConfigurationPin { - id: context.id.clone(), - version: Version::new(config.version).map_err(invalid)?, - }; let assembly = SnapshotAssembly { as_of: known_at, known_at, diff --git a/crates/convex-engine/src/telemetry.rs b/crates/convex-engine/src/telemetry.rs index ddbbdcbb..1a24dd6b 100644 --- a/crates/convex-engine/src/telemetry.rs +++ b/crates/convex-engine/src/telemetry.rs @@ -19,7 +19,8 @@ pub const CALCULATE_SECONDS: &str = "convex_calculate_seconds"; pub const PUBLISH_SECONDS: &str = "convex_publish_seconds"; /// One curve graph built from stored recipes, labelled by [`CurvePath`]. pub const CURVE_BUILD_SECONDS: &str = "convex_curve_build_seconds"; -/// One request-time valuation context: activation read and curve resolution. +/// One request-time valuation context: the activation read. Each product path reads its +/// curves later, at first use (D69). pub const REQUEST_CONTEXT_SECONDS: &str = "convex_request_context_seconds"; /// One `POST /api/v1/quote`, from handler entry to response. pub const QUOTE_SECONDS: &str = "convex_quote_seconds"; diff --git a/crates/convex-engine/tests/support/configuration_setup.rs b/crates/convex-engine/tests/support/configuration_setup.rs index 772df19c..0b061a0d 100644 --- a/crates/convex-engine/tests/support/configuration_setup.rs +++ b/crates/convex-engine/tests/support/configuration_setup.rs @@ -92,7 +92,7 @@ pub async fn value_at( else { panic!("instrument valuation") }; - let context = engine.request_context(None, known_at).await.unwrap(); + let mut context = engine.request_context(None, known_at).await.unwrap(); let reference = engine .reference_at_for_pricing( &instrument_id.clone().into(), @@ -106,6 +106,6 @@ pub async fn value_at( .unwrap(); let mark = mark.map(|mid| mid.parse().unwrap()); engine - .value_request(&context, &reference, settlement, known_at, mark) + .value_request(&mut context, &reference, settlement, known_at, mark) .await } diff --git a/crates/convex-ports/src/error.rs b/crates/convex-ports/src/error.rs index e39d8ffa..dacddaf7 100644 --- a/crates/convex-ports/src/error.rs +++ b/crates/convex-ports/src/error.rs @@ -3,7 +3,7 @@ use thiserror::Error; /// Common error type for trait operations. -#[derive(Debug, Error)] +#[derive(Debug, Clone, Error)] pub enum TraitError { /// The request violates a declared storage precondition. #[error("invalid input: {0}")] diff --git a/crates/convex-server/README.md b/crates/convex-server/README.md index 63980b5c..ec509533 100644 --- a/crates/convex-server/README.md +++ b/crates/convex-server/README.md @@ -292,6 +292,12 @@ auditable only because its activation is named. A missing or stale curve, or one with the wrong role or currency, is refused with `422 MISSING_INPUT`; the route never prices without curves. +Each product reads only its own curves, when it first needs them: a bullet its +discount curve and swap benchmark, a callable or FRN the curves listed below. A +curve the product does not read refuses nothing, so a stale swap benchmark refuses +bullets and still prices callables and FRNs. A bond the configuration does not cover +is `422 NOT_COVERED` before any curve is read. + Callables and FRNs are valued by the runtime, through the code the stream runs, so the numbers match the stream's for the same mark, cutoff and settlement. Each request builds a one-instrument snapshot that is never stored: the stored terms at `known_at`, @@ -322,10 +328,12 @@ only; it is not sent on the WebSocket. `POST /api/v1/quote` and `POST /api/v1/batch/price` take the same optional `configuration` and `known_at` and resolve the same context. A batch resolves the -context and its curves once. A bond the context cannot price fails on its own in -`errors`, and the rest still price. Their bodies carry `context`: +context once, and each product's curves at the first bond that reads them. A bond the +context cannot price, a curve it reads included, fails on its own in `errors`, and +the rest still price. Their bodies carry `context`: - `{"kind": "configuration", "configuration", "activation", "known_at", "curves"}` - for a configuration, with the headers above. + for a configuration, with the headers above. In a batch, `curves` and + `x-convex-curve-versions` list every curve some bond read, each once. - `{"kind": "supplied"}` when `POST /api/v1/quote` supplies its own curves or volatility. That is a what-if, and naming a `configuration` with it is `422 INVALID_INPUT`. @@ -481,7 +489,7 @@ Build `cargo build --locked -p convex-server --bins` to obtain `convex-config` a | `convex_valuation_seconds` | One instrument valued in the runtime | | `convex_calculate_seconds` | Every instrument of one snapshot, including graph commits | | `convex_publish_seconds` | One result record handed to the publisher | -| `convex_request_context_seconds` | One request-time valuation context: activation read and curve resolution | +| `convex_request_context_seconds` | One request-time valuation context: the activation read. Each product reads its curves later, at first use, and their build is `convex_curve_build_seconds{path="request"}` | | `convex_quote_seconds` | One `POST /api/v1/quote` | On-demand nominal fixed/zero quotes now use the [shared pure bond route](../../docs/BOND-QUOTES.md). Supply an observed price; the curves come from the activated configuration, or from exact supplied discount/swap/government inputs for a what-if. No demo curves, assumed volatility or model/par price are generated. Missing optional measures have structured errors. Full historical contexts, live admission and complete product/model certificates retain their separate gates. diff --git a/crates/convex-server/src/handlers.rs b/crates/convex-server/src/handlers.rs index 2c799e5f..31a3677e 100644 --- a/crates/convex-server/src/handlers.rs +++ b/crates/convex-server/src/handlers.rs @@ -219,10 +219,11 @@ fn runtime_composed(engine: &PricingEngine) -> Result<(), crate::error::ApiError } } -/// Every curve pin a context resolved, as `id@version`. +/// Every curve pin a context read, as `id@version`. fn context_pins(context: &convex_engine::RequestContext) -> Vec { context .pins() + .into_iter() .map(|(id, version)| format!("{id}@{}", version.get())) .collect() } @@ -263,7 +264,7 @@ fn context_json( "configuration": format!("{}@{}", context.config().config_id, context.config().version), "activation": context.activation_revision().get(), "known_at": crate::curves::rfc3339(known_at), - "curves": context.pins().map(|(id, version)| format!("{id}@{}", version.get())).collect::>(), + "curves": context_pins(context), }), None if supplied => serde_json::json!({"kind": "supplied"}), None => serde_json::json!({"kind": "none"}), @@ -290,7 +291,7 @@ async fn optional_context( /// curve pins it consumed, which are also returned. async fn value_stored( engine: &PricingEngine, - context: &convex_engine::RequestContext, + context: &mut convex_engine::RequestContext, reference: &convex_engine::ports::reference_repository::ReferenceRecord, settlement_date: Date, known_at: convex_core::contracts::UtcTimestamp, @@ -329,7 +330,7 @@ async fn value_stored( /// headers, and the stored reference version it priced. async fn runtime_quote( engine: &PricingEngine, - context: &convex_engine::RequestContext, + context: &mut convex_engine::RequestContext, reference: &convex_engine::ports::reference_repository::ReferenceRecord, settlement_date: Date, known_at: convex_core::contracts::UtcTimestamp, @@ -337,7 +338,7 @@ async fn runtime_quote( ) -> Result { let (mut value, pins) = value_stored(engine, context, reference, settlement_date, known_at, mark).await?; - value["context"] = context_json(Some(context), false, known_at); + value["context"] = context_json(Some(&*context), false, known_at); value["context"]["curves"] = serde_json::json!(pins); let response = crate::curves::with_header( Json(value).into_response(), @@ -401,14 +402,14 @@ pub async fn get_bond_quote( if runtime_product(&bond) { runtime_composed(&state.engine)?; } - let context = state + let mut context = state .engine .request_context(query.configuration.as_ref(), known_at) .await?; if runtime_product(&bond) { return runtime_quote( &state.engine, - &context, + &mut context, &reference, settlement_date, known_at, @@ -416,7 +417,7 @@ pub async fn get_bond_quote( ) .await; } - let curves = context.curves_for(&bond)?; + let curves = context.curves_for(&bond).await?; let input = PricingInput { bond, settlement_date, @@ -633,7 +634,7 @@ async fn price_one_bond( return error.into_response(); } } - let context = if supplied { + let mut context = if supplied { None } else { match optional_context(&state.engine, request.configuration.as_ref(), known_at).await { @@ -642,7 +643,7 @@ async fn price_one_bond( } }; let curve_versions: Vec<_> = [("discount",request.discount_curve.as_ref()),("swap",request.swap_curve.as_ref())].into_iter().filter_map(|(role,input)|input.map(|v|serde_json::json!({"role":role,"currency":v.currency,"curve_id":v.curve.curve_id,"version":v.curve.version,"source":v.curve.source,"model_id":v.curve.model_id}))).collect(); - let input = match &context { + let input = match &mut context { Some(context) if runtime => { let quoted = match state.engine.stored_terms(&request.bond, known_at).await { Ok(reference) => { @@ -667,7 +668,7 @@ async fn price_one_bond( .into_response() } Some(context) => { - let curves = match context.curves_for(&request.bond) { + let curves = match context.curves_for(&request.bond).await { Ok(curves) => curves, Err(error) => return crate::error::ApiError::from(error).into_response(), }; @@ -797,8 +798,9 @@ pub struct BatchStats { pub bonds_per_second: f64, } -/// Batch pricing handler. One valuation context prices every bond: its curves are -/// resolved once per batch, and a bond the context cannot price fails on its own. +/// Batch pricing handler. One valuation context prices every bond. Each product path +/// reads its curves at first use, once per batch (D69), and a bond the context cannot +/// price, for a curve it reads or otherwise, fails on its own. pub async fn batch_price( State(state): State>, Json(request): Json, @@ -830,7 +832,7 @@ pub async fn batch_price( Ok(known_at) => known_at, Err(error) => return error.into_response(), }; - let context = + let mut context = match optional_context(&state.engine, request.configuration.as_ref(), known_at).await { Ok(context) => context, Err(error) => return error.into_response(), @@ -854,8 +856,8 @@ pub async fn batch_price( valued.push((index, item)); continue; } - let curves = match &context { - Some(context) => match context.curves_for(&item.bond) { + let curves = match context.as_mut() { + Some(context) => match context.curves_for(&item.bond).await { Ok(curves) => Some(curves), Err(error) => { outcomes[index] = refused(crate::error::ApiError::from(error).summary()); @@ -901,7 +903,7 @@ pub async fn batch_price( outcomes[index] = Some( runtime_item( &state.engine, - context.as_ref(), + context.as_mut(), &item, settlement_date, known_at, @@ -970,7 +972,7 @@ pub async fn batch_price( /// terms (D62) under the batch's context. async fn runtime_item( engine: &PricingEngine, - context: Option<&convex_engine::RequestContext>, + context: Option<&mut convex_engine::RequestContext>, item: &BondPricingItem, settlement_date: Date, known_at: convex_core::contracts::UtcTimestamp, diff --git a/crates/convex-server/tests/request_time_products.rs b/crates/convex-server/tests/request_time_products.rs index 94753f5b..0841499b 100644 --- a/crates/convex-server/tests/request_time_products.rs +++ b/crates/convex-server/tests/request_time_products.rs @@ -121,6 +121,41 @@ async fn activate(setup: &Setup) { configuration::activate_pricing(setup, &config).await; } +/// Approve configuration version 3: version 2 with a swap benchmark, `USD-SWAP`, whose +/// one stored revision has `quality`. Only bullets read it. +async fn activate_with_swap(setup: &Setup, quality: DataQuality) { + let mut config = setup + .storage + .configs + .get_version("CONFIG-RESTORE", 2) + .await + .unwrap() + .unwrap(); + config.version = 3; + config.analytics_curves.swap_curve = Some(convex_core::ids::CurveId::new("USD-SWAP")); + setup.storage.configs.save(&config).await.unwrap(); + let mut swap = products::projection(recipes::recipe()); + swap["key"]["id"] = "USD-SWAP".into(); + let mut record = recipes::record(swap); + record.quality = quality; + setup.storage.curves.save_recipe(&record).await.unwrap(); + configuration::activate_pricing(setup, &config).await; +} + +/// A started runtime over the products fixture and the cutoff of its first result. +async fn started(setup: &Setup) -> (Arc, String) { + let engine = make_engine( + setup, + archive(setup), + setup.plan.clone(), + setup.store.clone(), + RuntimeInput::Captured, + ); + engine.start().await.unwrap(); + let known_at = engine.runtime_results()[0].result.provenance().known_at; + (engine, cutoff(known_at)) +} + fn router(engine: Arc) -> axum::Router { convex_server::routes::create_router_with_access( engine, @@ -343,3 +378,99 @@ async fn a_server_without_a_runtime_refuses_callables_and_still_prices_bullets() "{body}" ); } + +/// D69: a request reads only the curves its product reads. Under a configuration whose +/// swap curve is stale at the cutoff, a bullet is refused, alone or in a batch. A +/// callable, which reads only the discount curve, prices alone and in the same batch, +/// and the batch lists only the curves it read. +#[tokio::test] +async fn a_stale_swap_curve_refuses_bullets_and_still_prices_callables() { + let (setup, callable, _) = products_setup().await; + let (engine, known_at) = started(&setup).await; + // A runtime without configuration use cases starts only with nothing activated. + activate_with_swap(&setup, DataQuality::Stale).await; + let app = router(engine.clone()); + + let bullet = json!({"bond": fixture::bond(), "settlement_date": SETTLEMENT, + "known_at": known_at, "market_price": "99.25"}); + let (status, _, body) = call(&app, "POST", "/api/v1/quote", Some(bullet)).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert_eq!(body["code"], "MISSING_INPUT", "{body}"); + assert!( + body["error"].as_str().unwrap().contains("USD-SWAP"), + "{body}" + ); + + let uri = format!( + "/api/v1/quotes/CALL-001?market_price=99.25&known_at={known_at}&settlement_date={SETTLEMENT}" + ); + let (status, headers, body) = call(&app, "GET", &uri, None).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body["oas_mid"].is_number(), "{body}"); + assert_eq!(headers["x-convex-pricing-config"], "CONFIG-RESTORE@3"); + assert_eq!(headers["x-convex-curve-versions"], "USD-FIXTURE@7"); + + let batch = json!({ + "bonds": [ + {"bond": fixture::bond(), "market_price": "99.25"}, + {"bond": callable, "market_price": "99.25"}, + ], + "settlement_date": SETTLEMENT, + "known_at": known_at, + }); + let (status, headers, body) = call(&app, "POST", "/api/v1/batch/price", Some(batch)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["errors"].as_array().unwrap().len(), 1, "{body}"); + assert_eq!(body["errors"][0]["instrument_id"], "BOND-001"); + assert!( + body["errors"][0]["error"] + .as_str() + .unwrap() + .contains("MISSING_INPUT"), + "{body}" + ); + assert_eq!(body["quotes"].as_array().unwrap().len(), 1, "{body}"); + assert_eq!(body["quotes"][0]["instrument_id"], "CALL-001"); + assert!(body["quotes"][0]["oas_mid"].is_number(), "{body}"); + assert_eq!(headers["x-convex-curve-versions"], "USD-FIXTURE@7"); + assert_eq!(body["context"]["curves"], json!(["USD-FIXTURE@7"])); + engine.shutdown().await.unwrap(); +} + +/// D69: a batch of a bullet, a callable and a note reads three curve sets, each once, +/// and lists every curve it read once, in first-use order: the bullet's discount and +/// swap curves, then the note's projection curve. +#[tokio::test] +async fn a_batch_lists_each_curve_it_read_once() { + let (setup, callable, note) = products_setup().await; + let (engine, known_at) = started(&setup).await; + activate_with_swap(&setup, DataQuality::Valid).await; + let app = router(engine.clone()); + let batch = json!({ + "bonds": [ + {"bond": fixture::bond(), "market_price": "99.25"}, + {"bond": callable, "market_price": "99.25"}, + {"bond": note, "market_price": "99.25"}, + ], + "settlement_date": SETTLEMENT, + "known_at": known_at, + }); + let (status, headers, body) = call(&app, "POST", "/api/v1/batch/price", Some(batch)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["errors"], json!([]), "{body}"); + assert_eq!(body["quotes"].as_array().unwrap().len(), 3); + assert!(body["quotes"][0]["i_spread_mid"].is_number(), "{body}"); + let read = ["USD-FIXTURE@7", "USD-SWAP@7", "USD-SOFR-PROJECTION@7"]; + assert_eq!(headers["x-convex-curve-versions"], read.join(",").as_str()); + assert_eq!(body["context"]["curves"], json!(read)); + // Each item's own snapshot lists the curves that product read. + assert_eq!( + body["quotes"][1]["snapshot"]["curves"], + json!(["USD-FIXTURE@7"]) + ); + assert_eq!( + body["quotes"][2]["snapshot"]["curves"], + json!(["USD-FIXTURE@7", "USD-SOFR-PROJECTION@7"]) + ); + engine.shutdown().await.unwrap(); +}