diff --git a/Cargo.lock b/Cargo.lock index bd3ceaf3..0cfb4b95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -724,8 +724,6 @@ dependencies = [ "async-trait", "convex-core", "convex-ports", - "csv", - "rust_decimal", "serde", "serde_json", "tempfile", diff --git a/crates/convex-engine/RUNTIME.md b/crates/convex-engine/RUNTIME.md index 86694627..9cb7f245 100644 --- a/crates/convex-engine/RUNTIME.md +++ b/crates/convex-engine/RUNTIME.md @@ -36,7 +36,7 @@ analytics remain their owning tasks. | --- | --- | --- | | Captured | Caller supplies normalized events and both cutoffs | Complete valid historical/captured context | | Replay | Pull source such as `FileQuoteReplay`, with original event order/time | Complete valid historical context; EOF sets `replay_complete` | -| Live | Configured `MarketDataProvider.quotes` subscription | All selected sources supplied new images in this process, data remains valid, output is acknowledged | +| Live | `RuntimeInput::Live` quote source subscription, such as the server's HTTP ingress | All selected sources supplied new images in this process, data remains valid, output is acknowledged | | Idle demo | Explicit `with_demo_mode`, no numerical runtime | Demo quality; readiness stays false | `Live` is a provider integration point. Its broadcast transport reports lag and diff --git a/crates/convex-engine/SNAPSHOTS.md b/crates/convex-engine/SNAPSHOTS.md index 2632faa9..8331ed10 100644 --- a/crates/convex-engine/SNAPSHOTS.md +++ b/crates/convex-engine/SNAPSHOTS.md @@ -172,8 +172,7 @@ an appended history. No prior-date fixing or forecast is substituted. } ``` -The earlier fixing CSV remains a current snapshot format. Its single timestamp -cannot be interpreted as publication history. Supply verified publication and +The earlier fixing CSV source is removed. Supply verified publication and recording metadata to use historical assembly. JSON model values use Serde's `float_roundtrip` parsing to preserve finite binary diff --git a/crates/convex-engine/src/builder.rs b/crates/convex-engine/src/builder.rs index 75df9490..f3a1fde7 100644 --- a/crates/convex-engine/src/builder.rs +++ b/crates/convex-engine/src/builder.rs @@ -3,8 +3,6 @@ use std::sync::Arc; use crate::ports::config::EngineConfig; -use crate::ports::market_data::MarketDataProvider; -use crate::ports::reference_data::ReferenceDataProvider; use crate::ports::storage::StorageAdapter; use crate::error::EngineError; @@ -13,8 +11,6 @@ use crate::PricingEngine; /// Builder for constructing a [`PricingEngine`]. pub struct PricingEngineBuilder { config: Option, - market_data: Option>, - reference_data: Option>, storage: Option>, runtime: Option, configurations: Option>, @@ -26,8 +22,6 @@ impl PricingEngineBuilder { pub fn new() -> Self { Self { config: None, - market_data: None, - reference_data: None, storage: None, runtime: None, configurations: None, @@ -41,18 +35,6 @@ impl PricingEngineBuilder { self } - /// Set the market data provider. - pub fn with_market_data(mut self, provider: Arc) -> Self { - self.market_data = Some(provider); - self - } - - /// Set the reference data provider. - pub fn with_reference_data(mut self, provider: Arc) -> Self { - self.reference_data = Some(provider); - self - } - /// Set the storage adapter. pub fn with_storage(mut self, storage: Arc) -> Self { self.storage = Some(storage); @@ -84,14 +66,6 @@ impl PricingEngineBuilder { pub fn build(self) -> Result { let config = self.config.unwrap_or_default(); - let market_data = self - .market_data - .ok_or_else(|| EngineError::ConfigError("market_data not configured".into()))?; - - let reference_data = self - .reference_data - .ok_or_else(|| EngineError::ConfigError("reference_data not configured".into()))?; - let storage = self .storage .ok_or_else(|| EngineError::ConfigError("storage not configured".into()))?; @@ -109,7 +83,7 @@ impl PricingEngineBuilder { "max_concurrent_calcs requires 1..=64".into(), )); } - let mut engine = PricingEngine::new(config, market_data, reference_data, storage); + let mut engine = PricingEngine::new(config, storage); engine.configurations = self.configurations; engine.runtime = crate::runtime::RuntimeController::new( self.runtime, diff --git a/crates/convex-engine/src/lib.rs b/crates/convex-engine/src/lib.rs index 90470bcc..adf2334c 100644 --- a/crates/convex-engine/src/lib.rs +++ b/crates/convex-engine/src/lib.rs @@ -31,8 +31,6 @@ //! //! ```ignore //! let engine = PricingEngineBuilder::new() -//! .with_market_data(market_data_provider) -//! .with_reference_data(ref_data_provider) //! .with_storage(storage_adapter) //! .with_config(engine_config) //! .with_runtime(runtime_services) @@ -57,12 +55,10 @@ pub mod curve_replay; pub mod curve_resolution; pub mod error; pub mod etf_pricing; -pub mod market_data_listener; pub mod market_ingestion; pub mod portfolio_analytics; pub mod pricing_router; pub mod runtime; -pub mod scheduler; pub mod snapshot; pub mod cache; @@ -79,24 +75,16 @@ pub use calc_graph::{ pub use curve_builder::BuiltCurve; pub use error::EngineError; pub use etf_pricing::EtfPricer; -pub use market_data_listener::{ - CurveInputUpdate, CurveUpdate, FxRateUpdate, IndexFixingUpdate, InflationFixingUpdate, - IngestionStatus, MarketDataListener, MarketDataPublisher, MarketDataUpdate, VolSurfaceUpdate, -}; pub use portfolio_analytics::{runtime_bond_quote, Portfolio, PortfolioAnalyzer, Position}; pub use pricing_router::{BatchPricingResult, PricingRouter}; pub use runtime::{ AcceptedUpdate, RuntimeError, RuntimeHealth, RuntimeInput, RuntimeMode, RuntimePhase, RuntimePlan, RuntimeServices, }; -pub use scheduler::{EodScheduler, IntervalScheduler, NodeUpdate, ThrottleManager, UpdateSource}; use std::sync::Arc; -use tracing::{debug, warn}; use crate::ports::config::EngineConfig; -use crate::ports::market_data::MarketDataProvider; -use crate::ports::reference_data::ReferenceDataProvider; use crate::ports::storage::StorageAdapter; /// Curves and the configuration consumed by one priced request, for reporting. @@ -129,12 +117,6 @@ pub struct PricingEngine { /// Portfolio analyzer portfolio_analyzer: Arc, - /// Market data provider - market_data: Arc, - - /// Auxiliary issuer, rating and ETF sources; bond terms come from storage. - reference_data: Arc, - /// Storage adapter, including the authoritative reference repository. storage: Arc, @@ -145,12 +127,7 @@ pub struct PricingEngine { impl PricingEngine { /// Create a new pricing engine. - pub fn new( - config: EngineConfig, - market_data: Arc, - reference_data: Arc, - storage: Arc, - ) -> Self { + pub fn new(config: EngineConfig, storage: Arc) -> Self { let concurrency = config.max_concurrent_calcs; let calc_graph = Arc::new(CalculationGraph::new()); let pricing_router = Arc::new(PricingRouter::new()); @@ -163,8 +140,6 @@ impl PricingEngine { pricing_router, etf_pricer, portfolio_analyzer, - market_data, - reference_data, storage, runtime: runtime::RuntimeController::new(None, false, concurrency), configurations: None, @@ -352,11 +327,6 @@ impl PricingEngine { &self.config } - /// Get auxiliary issuer, rating and ETF sources. - pub fn reference_data(&self) -> &Arc { - &self.reference_data - } - /// The sole authoritative source for reference reads and mutations. pub fn reference_repository( &self, @@ -421,7 +391,7 @@ impl PricingEngine { Ok(()) } - /// Commit a reference use case before invalidating dependent pricing work. + /// Commit a reference use case through the authoritative repository. pub async fn commit_reference( &self, command: &ports::reference_repository::ReferenceCommand, @@ -430,233 +400,7 @@ impl PricingEngine { ports::reference_repository::ReferenceCommit, ports::reference_repository::ReferenceError, > { - let result = self.storage.bonds.commit(command, recorded_at).await?; - for record in &result.records { - let node = NodeId::BondPrice { - instrument_id: record.instrument_id.clone(), - }; - if self.runtime_health().mode.is_none() && self.calc_graph.node_spec(&node).is_some() { - if let Err(error) = self.calc_graph.invalidate(&node) { - warn!("Reference invalidation failed: {error}"); - } - } - } - Ok(result) - } - - // ========================================================================= - // REACTIVE MARKET DATA HANDLERS - // ========================================================================= - - /// Handle a quote update for a bond. - /// - /// This method is called when a new quote arrives for a bond. - /// It invalidates the quote node in the calc graph, which propagates - /// to dependent bond price nodes. - pub fn on_quote_update( - &self, - instrument_id: &convex_core::ids::InstrumentId, - bid: Option, - ask: Option, - ) { - let node_id = NodeId::Quote { - instrument_id: instrument_id.clone(), - }; - - // Update quote in cache - let mid = match (bid, ask) { - (Some(b), Some(a)) => Some((b + a) / rust_decimal::Decimal::from(2)), - (Some(b), None) => Some(b), - (None, Some(a)) => Some(a), - (None, None) => None, - }; - - if let Err(error) = self - .calc_graph - .publish_input(&node_id, NodeValue::Quote { bid, ask, mid }) - { - warn!("Quote input publication failed: {error}"); - } - - debug!( - "Quote update for {}: bid={:?} ask={:?}", - instrument_id, bid, ask - ); - } - - /// Handle a curve update. - /// - /// This method is called when a curve is rebuilt. - /// It invalidates the curve node, which propagates to all bonds - /// that depend on this curve. - pub fn on_curve_update(&self, curve_id: &convex_core::ids::CurveId, curve: &BuiltCurve) { - let node_id = NodeId::Curve { - curve_id: curve_id.clone(), - }; - - let points = match curve.to_points() { - Ok(points) => points, - Err(error) => { - warn!("Curve input publication failed: {error}"); - return; - } - }; - // Update curve in cache - if let Err(error) = self - .calc_graph - .publish_input(&node_id, NodeValue::Curve { points }) - { - warn!("Curve input publication failed: {error}"); - } - - debug!("Curve update for {}", curve_id); - } - - /// Handle an index fixing update (for FRNs). - /// - /// This method is called when a floating rate index fixing arrives. - /// It invalidates the index fixing node, which propagates to all FRNs - /// that reference this index. - pub fn on_index_fixing( - &self, - index: &convex_core::ids::FloatingRateIndex, - date: convex_core::Date, - rate: rust_decimal::Decimal, - ) { - let node_id = NodeId::IndexFixing { - index: index.clone(), - date, - }; - - if let Err(error) = self - .calc_graph - .publish_input(&node_id, NodeValue::IndexFixing { rate }) - { - warn!("Input publication failed: {error}"); - } - - debug!("Index fixing for {} on {}: {}", index, date, rate); - } - - /// Handle an inflation fixing update (for ILBs). - /// - /// This method is called when an inflation index fixing arrives. - /// It invalidates the inflation fixing node, which propagates to all ILBs. - pub fn on_inflation_fixing( - &self, - index: &convex_core::ids::InflationIndex, - month: convex_core::ids::YearMonth, - value: rust_decimal::Decimal, - ) { - let node_id = NodeId::InflationFixing { - index: index.clone(), - month, - }; - - if let Err(error) = self - .calc_graph - .publish_input(&node_id, NodeValue::InflationFixing { value }) - { - warn!("Input publication failed: {error}"); - } - - debug!("Inflation fixing for {} ({}): {}", index, month, value); - } - - /// Handle an FX rate update. - /// - /// This method is called when an FX rate is updated. - /// It invalidates the FX rate node, which propagates to cross-currency - /// portfolio calculations. - pub fn on_fx_rate_update( - &self, - pair: &convex_core::ids::CurrencyPair, - mid: rust_decimal::Decimal, - ) { - let node_id = NodeId::FxRate { pair: pair.clone() }; - - if let Err(error) = self - .calc_graph - .publish_input(&node_id, NodeValue::FxRate { mid }) - { - warn!("Input publication failed: {error}"); - } - - debug!("FX rate update for {}: {}", pair, mid); - } - - /// Register a bond for reactive pricing. - /// - /// This sets up the node in the calc graph with dependencies on - /// the relevant curves and quote. - pub fn register_bond( - &self, - instrument_id: convex_core::ids::InstrumentId, - discount_curve: convex_core::ids::CurveId, - benchmark_curve: Option, - config: crate::ports::config::NodeConfig, - ) -> Result { - let bond_node = NodeId::BondPrice { - instrument_id: instrument_id.clone(), - }; - - // Build dependencies - let mut deps = vec![ - NodeId::Curve { - curve_id: discount_curve, - }, - NodeId::Quote { - instrument_id: instrument_id.clone(), - }, - ]; - - if let Some(bench_curve) = benchmark_curve { - deps.push(NodeId::Curve { - curve_id: bench_curve, - }); - } - - // Include only missing roots; preserve existing curve/calculation definitions. - let mut nodes: Vec<_> = deps - .iter() - .filter(|id| self.calc_graph.node_spec(id).is_none()) - .cloned() - .map(NodeSpec::input) - .collect(); - let mut roots = std::collections::HashSet::new(); - nodes.retain(|node| roots.insert(node.id.clone())); - let mut seen = std::collections::HashSet::new(); - deps.retain(|id| seen.insert(id.clone())); - nodes.push(NodeSpec::calculation(bond_node, deps).with_config(config)); - self.calc_graph.upsert_nodes(nodes) - } - - /// Register a currently unsupported ETF aggregation with explicit scheduling policy. - pub fn register_etf_inav(&self, etf_id: convex_core::ids::EtfId) -> Result { - self.calc_graph.upsert_nodes(vec![NodeSpec::calculation( - NodeId::EtfInav { etf_id }, - vec![], - ) - .with_config(crate::ports::config::NodeConfig::etf_inav())]) - } - /// Register end-of-day ETF work; mathematical holdings integration remains explicit. - pub fn register_etf_nav(&self, etf_id: convex_core::ids::EtfId) -> Result { - self.calc_graph.upsert_nodes(vec![NodeSpec::calculation( - NodeId::EtfNav { etf_id }, - vec![], - ) - .with_config(crate::ports::config::NodeConfig::etf_nav())]) - } - /// Register portfolio work; missing aggregation is never cached as a numerical result. - pub fn register_portfolio( - &self, - portfolio_id: convex_core::ids::PortfolioId, - ) -> Result { - self.calc_graph.upsert_nodes(vec![NodeSpec::calculation( - NodeId::Portfolio { portfolio_id }, - vec![], - ) - .with_config(crate::ports::config::NodeConfig::portfolio())]) + self.storage.bonds.commit(command, recorded_at).await } } diff --git a/crates/convex-engine/src/market_data_listener.rs b/crates/convex-engine/src/market_data_listener.rs deleted file mode 100644 index c6fa99d2..00000000 --- a/crates/convex-engine/src/market_data_listener.rs +++ /dev/null @@ -1,809 +0,0 @@ -//! Market data listener for reactive pricing. -//! -//! The [`MarketDataListener`] subscribes to market data updates and feeds them -//! into the calculation graph, triggering reactive repricing. - -use std::sync::Arc; - -use rust_decimal::Decimal; -use tokio::sync::broadcast; -use tracing::{debug, info, warn}; - -use convex_core::ids::*; -use convex_core::Date; - -use crate::calc_graph::{CalculationGraph, GraphError, NodeId, NodeValue}; -use crate::curve_builder::BuiltCurve; -use crate::market_ingestion::{DataState, IngestionOutcome, IngestionResult, MarketDataAssembler}; -use crate::scheduler::{NodeUpdate, ThrottleManager, UpdateSource}; -use convex_ports::market_data::{QuoteEvent, QuoteField, StreamReceiver}; - -// ============================================================================= -// UPDATE EVENTS -// ============================================================================= - -/// Market data update event. -#[derive(Debug, Clone)] -pub enum MarketDataUpdate { - /// Quote update for a bond - Quote(QuoteEvent), - - /// Curve input update - CurveInput(CurveInputUpdate), - - /// Built curve update - Curve(CurveUpdate), - - /// Index fixing update (for FRNs) - IndexFixing(IndexFixingUpdate), - - /// Inflation fixing update (for ILBs) - InflationFixing(InflationFixingUpdate), - - /// FX rate update - FxRate(FxRateUpdate), - - /// Volatility surface update - VolSurface(VolSurfaceUpdate), -} - -/// Curve input update. -#[derive(Debug, Clone)] -pub struct CurveInputUpdate { - /// Curve ID - pub curve_id: CurveId, - /// Instrument (e.g., "2Y", "5Y") - pub instrument: String, - /// Rate value - pub rate: Decimal, - /// Update timestamp - pub timestamp: i64, -} - -/// Built curve update. -#[derive(Debug, Clone)] -pub struct CurveUpdate { - /// Curve ID - pub curve_id: CurveId, - /// Built curve data - pub curve: BuiltCurve, - /// Update timestamp - pub timestamp: i64, -} - -/// Index fixing update. -#[derive(Debug, Clone)] -pub struct IndexFixingUpdate { - /// Rate index (e.g., SOFR, EURIBOR) - pub index: FloatingRateIndex, - /// Fixing date - pub date: Date, - /// Rate value - pub rate: Decimal, - /// Update timestamp - pub timestamp: i64, -} - -/// Inflation fixing update. -#[derive(Debug, Clone)] -pub struct InflationFixingUpdate { - /// Inflation index - pub index: InflationIndex, - /// Reference month - pub month: YearMonth, - /// Index value - pub value: Decimal, - /// Update timestamp - pub timestamp: i64, -} - -/// FX rate update. -#[derive(Debug, Clone)] -pub struct FxRateUpdate { - /// Currency pair - pub pair: CurrencyPair, - /// Mid rate - pub mid: Decimal, - /// Update timestamp - pub timestamp: i64, -} - -/// Volatility surface update. -#[derive(Debug, Clone)] -pub struct VolSurfaceUpdate { - /// Surface ID - pub surface_id: VolSurfaceId, - /// ATM vols by expiry - pub atm_vols: Vec<(f64, Decimal)>, - /// Update timestamp - pub timestamp: i64, -} - -/// Observable ingestion decisions. These do not imply that downstream pricing is certified. -#[derive(Debug, Clone)] -pub enum IngestionStatus { - /// Graph policy/role/activation rejected this input transition. - GraphRejected { - /// Exact typed graph error; no numerical fallback is published. - error: GraphError, - }, - /// One normalized event decision. - Event(IngestionResult), - /// Shared channel loss; every input is suspended. - Lagged { - /// Discarded messages. - missed: u64, - }, - /// End of delivery. - Closed, - /// Multiple suppliers require an explicit selection policy before graph use. - SourceSelectionRequired { - /// Ambiguous instrument. - instrument_id: InstrumentId, - }, -} - -// ============================================================================= -// MARKET DATA LISTENER -// ============================================================================= - -/// Listens to market data updates and feeds them into the calculation graph. -pub struct MarketDataListener { - /// Calculation graph - calc_graph: Arc, - - assembler: MarketDataAssembler, - status_tx: broadcast::Sender, - // Unsequenced auxiliary messages cannot recover from shared-channel loss. - auxiliary_suspended: bool, - - /// Throttle manager for debounced updates - throttle_manager: Arc, - - /// Market data update receiver - update_rx: broadcast::Receiver, - - /// Node update sender (for broadcasting processed updates) - node_update_tx: broadcast::Sender, - - /// Shutdown signal receiver - shutdown_rx: broadcast::Receiver<()>, -} - -impl MarketDataListener { - /// Create a new market data listener. - pub fn new( - calc_graph: Arc, - throttle_manager: Arc, - update_rx: broadcast::Receiver, - shutdown_rx: broadcast::Receiver<()>, - ) -> Self { - let (node_update_tx, _) = broadcast::channel(1000); - Self { - calc_graph, - assembler: MarketDataAssembler::default(), - status_tx: broadcast::channel(128).0, - auxiliary_suspended: false, - throttle_manager, - update_rx, - node_update_tx, - shutdown_rx, - } - } - - /// Subscribe to processed node updates. - pub fn subscribe(&self) -> broadcast::Receiver { - self.node_update_tx.subscribe() - } - - /// Configure ingestion before receiving any event. Runtime policy activation belongs to the configuration use case. - pub fn with_policy( - mut self, - policy: crate::market_ingestion::IngestionPolicy, - ) -> Result { - if !self.assembler.is_empty() { - return Err(convex_ports::TraitError::Conflict( - "ingestion policy requires a new listener", - )); - } - self.assembler = MarketDataAssembler::new(policy)?; - Ok(self) - } - - /// Run the market data listener. - /// - /// This method runs until a shutdown signal is received. - pub async fn run(&mut self) { - info!("Market data listener started"); - - let mut freshness = tokio::time::interval(std::time::Duration::from_millis(100)); - loop { - tokio::select! { - result = self.update_rx.recv() => { - match result { - Ok(update) => self.process_update(update, chrono::Utc::now().timestamp_millis()).await, - Err(broadcast::error::RecvError::Lagged(n)) => { - warn!("Market data listener lagged by {} messages; inputs suspended", n); - self.assembler.transport_gap(); - self.auxiliary_suspended = true; - self.graph_outcome(self.calc_graph.suspend_market_delivery()); - self.publish_status(IngestionStatus::Lagged { missed: n }); - } - Err(broadcast::error::RecvError::Closed) => { - info!("Market data channel closed"); - break; - } - } - } - _ = freshness.tick() => self.expire_quotes(chrono::Utc::now().timestamp_millis()), - _ = self.shutdown_rx.recv() => { - info!("Market data listener shutting down"); - break; - } - } - } - self.assembler.close(); - self.graph_outcome(self.calc_graph.suspend_market_delivery()); - self.publish_status(IngestionStatus::Closed); - } - - /// Process a market data update. - async fn process_update(&mut self, update: MarketDataUpdate, now_ms: i64) { - if self.auxiliary_suspended && !matches!(update, MarketDataUpdate::Quote(_)) { - warn!("Auxiliary input suspended after channel loss; restart/reload required"); - return; - } - match update { - MarketDataUpdate::Quote(quote) => self.on_quote_update(quote, now_ms), - MarketDataUpdate::CurveInput(input) => self.on_curve_input_update(input).await, - MarketDataUpdate::Curve(curve) => self.on_curve_update(curve).await, - MarketDataUpdate::IndexFixing(fixing) => self.on_index_fixing(fixing).await, - MarketDataUpdate::InflationFixing(fixing) => self.on_inflation_fixing(fixing).await, - MarketDataUpdate::FxRate(rate) => self.on_fx_rate_update(rate).await, - MarketDataUpdate::VolSurface(surface) => self.on_vol_surface_update(surface).await, - } - } - - /// Observe event decisions and shared transport loss. Receiver lag remains explicit. - pub fn subscribe_status(&self) -> StreamReceiver { - StreamReceiver::new(self.status_tx.subscribe()) - } - - fn publish_status(&self, status: IngestionStatus) { - if self.status_tx.send(status).is_err() { - debug!("No ingestion status subscribers"); - } - } - - fn on_quote_update(&mut self, event: QuoteEvent, now_ms: i64) { - let key = event.key.clone(); - let decision = self.assembler.ingest(event, now_ms); - self.publish_status(IngestionStatus::Event(decision.clone())); - let node_id = NodeId::Quote { - instrument_id: key.instrument_id.clone(), - }; - if decision.outcome == IngestionOutcome::CapacityExceeded { - self.graph_outcome(self.calc_graph.suspend_market_delivery()); - return; - } - if decision.state != DataState::Live { - self.graph_outcome(self.calc_graph.evict_cached(&node_id)); - return; - } - if decision.outcome != IngestionOutcome::Applied { - return; - } - let Some(snapshot) = self.assembler.snapshot(&key, now_ms) else { - return; - }; - // Existing graph has no source-selection policy. Refuse a second source for the same instrument. - if self.assembler.has_other_source(&key) { - self.graph_outcome(self.calc_graph.evict_cached(&node_id)); - self.publish_status(IngestionStatus::SourceSelectionRequired { - instrument_id: key.instrument_id, - }); - return; - } - let price = |field| { - snapshot - .fields - .get(&field) - .map(|observation| observation.value.value) - }; - if price(QuoteField::BidPrice).is_none() - && price(QuoteField::AskPrice).is_none() - && price(QuoteField::MidPrice).is_none() - { - self.graph_outcome(self.calc_graph.evict_cached(&node_id)); - return; - } - let Some(valid_for_ms) = self.assembler.fresh_for_ms(&key, now_ms) else { - return; - }; - let Ok(valid_for_ms) = u64::try_from(valid_for_ms) else { - return; - }; - let Some(deadline) = - std::time::Instant::now().checked_add(std::time::Duration::from_millis(valid_for_ms)) - else { - return; - }; - if !self.graph_outcome(self.calc_graph.publish_input_until( - &node_id, - NodeValue::Quote { - bid: price(QuoteField::BidPrice), - ask: price(QuoteField::AskPrice), - mid: price(QuoteField::MidPrice), - }, - Some(deadline), - )) { - return; - } - let bond_node = NodeId::BondPrice { - instrument_id: key.instrument_id, - }; - if self.throttle_manager.should_calculate(&bond_node) { - self.notify_update(bond_node, UpdateSource::Immediate); - } else { - self.throttle_manager.schedule(bond_node); - } - } - - fn expire_quotes(&self, now_ms: i64) { - for key in self.assembler.keys() { - if self - .assembler - .state(key, now_ms) - .is_some_and(|state| state != DataState::Live) - { - self.graph_outcome(self.calc_graph.evict_cached(&NodeId::Quote { - instrument_id: key.instrument_id.clone(), - })); - } - } - } - - /// Supply a normalized calibration input; recipe-driven curve construction is separate. - async fn on_curve_input_update(&self, update: CurveInputUpdate) { - let node_id = NodeId::CurveInput { - curve_id: update.curve_id.clone(), - instrument: update.instrument, - }; - if !self.graph_outcome( - self.calc_graph - .publish_input(&node_id, NodeValue::CurveInput { rate: update.rate }), - ) { - return; - } - let curve = NodeId::Curve { - curve_id: update.curve_id, - }; - if self.calc_graph.node_spec(&curve).is_some() { - self.graph_outcome(self.calc_graph.invalidate(&curve)); - } - } - /// Publish a supplied curve atomically; a calculated curve requires its own generation ticket. - async fn on_curve_update(&self, update: CurveUpdate) { - let points = match update.curve.to_points() { - Ok(points) => points, - Err(error) => { - warn!("Curve input publication failed: {error}"); - return; - } - }; - let node_id = NodeId::Curve { - curve_id: update.curve_id, - }; - if self.graph_outcome( - self.calc_graph - .publish_input(&node_id, NodeValue::Curve { points }), - ) { - self.notify_update(node_id, UpdateSource::Immediate); - } - } - /// Publish an externally supplied index fixing. - async fn on_index_fixing(&self, update: IndexFixingUpdate) { - let node_id = NodeId::IndexFixing { - index: update.index, - date: update.date, - }; - if self.graph_outcome( - self.calc_graph - .publish_input(&node_id, NodeValue::IndexFixing { rate: update.rate }), - ) { - self.notify_update(node_id, UpdateSource::Immediate); - } - } - /// Publish an externally supplied inflation fixing. - async fn on_inflation_fixing(&self, update: InflationFixingUpdate) { - let node_id = NodeId::InflationFixing { - index: update.index, - month: update.month, - }; - if self.graph_outcome(self.calc_graph.publish_input( - &node_id, - NodeValue::InflationFixing { - value: update.value, - }, - )) { - self.notify_update(node_id, UpdateSource::Immediate); - } - } - /// Publish a supplied currency rate. - async fn on_fx_rate_update(&self, update: FxRateUpdate) { - let node_id = NodeId::FxRate { pair: update.pair }; - if self.graph_outcome( - self.calc_graph - .publish_input(&node_id, NodeValue::FxRate { mid: update.mid }), - ) { - self.notify_update(node_id, UpdateSource::Immediate); - } - } - /// Publish an externally supplied volatility surface. - async fn on_vol_surface_update(&self, update: VolSurfaceUpdate) { - let node_id = NodeId::VolSurface { - surface_id: update.surface_id, - }; - if self.graph_outcome(self.calc_graph.publish_input( - &node_id, - NodeValue::VolSurface { - atm_vols: update.atm_vols, - }, - )) { - self.notify_update(node_id, UpdateSource::Immediate); - } - } - fn graph_outcome(&self, result: Result) -> bool { - match result { - Ok(_) => true, - Err(error) => { - warn!("Market graph transition rejected: {error}"); - self.publish_status(IngestionStatus::GraphRejected { error }); - false - } - } - } - - /// Notify subscribers of a node update. - fn notify_update(&self, node_id: NodeId, source: UpdateSource) { - let _ = self.node_update_tx.send(NodeUpdate { - node_id, - timestamp: chrono::Utc::now().timestamp(), - source, - completion: None, - }); - } -} - -// ============================================================================= -// MARKET DATA PUBLISHER -// ============================================================================= - -/// Result of publishing a market data update. -/// -/// The send error carries the un-delivered [`MarketDataUpdate`], which is large, -/// so it is boxed to keep the success path cheap (and satisfy `result_large_err`). -pub type PublishResult = Result<(), PublishError>; - -/// Observable publisher failure; rejected input is different from absent subscribers. -#[derive(Debug, thiserror::Error)] -pub enum PublishError { - /// Event validation failed before entering the bounded channel. - #[error("invalid market event: {0}")] - Invalid(#[from] convex_ports::TraitError), - /// No subscriber received this update. - #[error("no market data subscribers")] - NoSubscribers(Box), -} - -/// Publisher for market data updates. -/// -/// Use this to inject market data updates into the system. -#[derive(Clone)] -pub struct MarketDataPublisher { - update_tx: broadcast::Sender, -} - -impl MarketDataPublisher { - /// Create a new market data publisher. - pub fn new() -> (Self, broadcast::Receiver) { - let (update_tx, update_rx) = broadcast::channel(10000); - (Self { update_tx }, update_rx) - } - - /// Subscribe to market data updates. - pub fn subscribe(&self) -> broadcast::Receiver { - self.update_tx.subscribe() - } - - /// Publish a quote update. - pub fn publish_quote(&self, update: QuoteEvent) -> PublishResult { - update.validate()?; - self.publish(MarketDataUpdate::Quote(update)) - } - - /// Publish a curve update. - pub fn publish_curve(&self, update: CurveUpdate) -> PublishResult { - self.publish(MarketDataUpdate::Curve(update)) - } - - /// Publish a curve input update. - pub fn publish_curve_input(&self, update: CurveInputUpdate) -> PublishResult { - self.publish(MarketDataUpdate::CurveInput(update)) - } - - /// Publish an index fixing. - pub fn publish_index_fixing(&self, update: IndexFixingUpdate) -> PublishResult { - self.publish(MarketDataUpdate::IndexFixing(update)) - } - - /// Publish an inflation fixing. - pub fn publish_inflation_fixing(&self, update: InflationFixingUpdate) -> PublishResult { - self.publish(MarketDataUpdate::InflationFixing(update)) - } - - /// Publish an FX rate update. - pub fn publish_fx_rate(&self, update: FxRateUpdate) -> PublishResult { - self.publish(MarketDataUpdate::FxRate(update)) - } - - /// Publish a volatility surface update. - pub fn publish_vol_surface(&self, update: VolSurfaceUpdate) -> PublishResult { - self.publish(MarketDataUpdate::VolSurface(update)) - } - - /// Broadcasts a market data update, boxing the (large) send error. - fn publish(&self, update: MarketDataUpdate) -> PublishResult { - self.update_tx - .send(update) - .map(|_| ()) - .map_err(|error| PublishError::NoSubscribers(Box::new(error.0))) - } -} - -impl Default for MarketDataPublisher { - fn default() -> Self { - Self::new().0 - } -} - -#[cfg(test)] -mod tests { - use super::*; - use convex_ports::market_data::QuoteEventKind; - - fn event() -> QuoteEvent { - serde_json::from_str( - include_str!("../../convex-ext-file/tests/fixtures/sequenced-quotes.jsonl") - .lines() - .next() - .unwrap(), - ) - .unwrap() - } - - fn make_listener() -> (MarketDataListener, Arc) { - let graph = Arc::new(CalculationGraph::new()); - let throttle = Arc::new(ThrottleManager::new(graph.clone())); - let (_, update_rx) = broadcast::channel(1); - let (_, shutdown_rx) = broadcast::channel(1); - ( - MarketDataListener::new(graph.clone(), throttle, update_rx, shutdown_rx), - graph, - ) - } - - #[test] - fn test_market_data_publisher() { - let (publisher, _rx) = MarketDataPublisher::new(); - assert!(publisher.publish_quote(event()).is_ok()); - let mut invalid = event(); - invalid.schema_version = 2; - assert!(matches!( - publisher.publish_quote(invalid), - Err(PublishError::Invalid(_)) - )); - let publisher = MarketDataPublisher::default(); - assert!(matches!( - publisher.publish_quote(event()), - Err(PublishError::NoSubscribers(_)) - )); - } - - #[test] - fn test_quote_event_creation() { - let quote = event(); - assert_eq!(quote.key.instrument_id.as_str(), "BOND-001"); - assert_eq!((quote.session.as_str(), quote.sequence), ("A", 1)); - } - - #[test] - fn cache_expiry_is_enforced_without_a_maintenance_tick() { - let graph = CalculationGraph::new(); - let quote = NodeId::Quote { - instrument_id: "EXPIRING".into(), - }; - graph - .publish_input_until( - "e, - NodeValue::Quote { - bid: None, - ask: None, - mid: Some(Decimal::from(100)), - }, - Some(std::time::Instant::now() - std::time::Duration::from_millis(1)), - ) - .unwrap(); - assert!(graph.get_cached("e).is_none()); - graph - .publish_input( - "e, - NodeValue::Quote { - bid: None, - ask: None, - mid: Some(Decimal::from(100)), - }, - ) - .unwrap(); - assert!(graph.get_cached("e).is_some()); - let bond = NodeId::BondPrice { - instrument_id: "EXPIRING".into(), - }; - graph.add_node(bond.clone(), vec![quote.clone()]).unwrap(); - graph - .publish_input( - "e, - NodeValue::Quote { - bid: None, - ask: None, - mid: Some(Decimal::from(100)), - }, - ) - .unwrap(); - let ticket = graph.begin_calculation(&bond).unwrap(); - graph - .commit_result( - &ticket, - Ok(NodeValue::BondPrice { - clean_price_bid: None, - clean_price_mid: Some(Decimal::from(100)), - clean_price_ask: None, - accrued_interest: None, - ytm_bid: None, - ytm_mid: None, - ytm_ask: None, - z_spread_mid: None, - modified_duration: None, - dv01: None, - }), - ) - .unwrap(); - graph.evict_cached("e).unwrap(); - assert!(graph.get_cached("e).is_none()); - assert!(graph.get_cached(&bond).is_none()); - } - - #[test] - fn listener_uses_only_live_assembled_fields_and_evicts_unusable_inputs() { - let (mut listener, graph) = make_listener(); - let node = NodeId::Quote { - instrument_id: event().key.instrument_id, - }; - listener.on_quote_update(event(), 1000); - assert!(matches!( - &graph.get_cached(&node).unwrap().value, - NodeValue::Quote { - bid: Some(_), - ask: Some(_), - mid: Some(_) - } - )); - let mut delta = event(); - delta.sequence = 3; - delta.event = QuoteEventKind::Delta { fields: vec![] }; - listener.on_quote_update(delta, 1000); - assert!(graph.get_cached(&node).is_none()); - let mut repaired = event(); - repaired.sequence = 3; - listener.on_quote_update(repaired.clone(), 1000); - assert!(graph.get_cached(&node).is_some()); - listener.expire_quotes(6001); - assert!(graph.get_cached(&node).is_none()); - repaired.sequence = 4; - repaired.source_time_ms = 7000; - repaired.receive_time_ms = 7000; - listener.on_quote_update(repaired.clone(), 7000); - assert!(graph.get_cached(&node).is_some()); - repaired.sequence = 5; - repaired.entitled = false; - listener.on_quote_update(repaired, 7000); - assert!(graph.get_cached(&node).is_none()); - } - - #[test] - fn listener_refuses_supplier_composites_and_non_live_delivery() { - let (mut listener, graph) = make_listener(); - let first = event(); - let node = NodeId::Quote { - instrument_id: first.key.instrument_id.clone(), - }; - listener.on_quote_update(first.clone(), 1000); - let mut second = first.clone(); - second.key.source = "another-provider".into(); - listener.on_quote_update(second, 1000); - assert!(graph.get_cached(&node).is_none()); - let (mut listener, graph) = make_listener(); - let mut delayed = first; - delayed.quality = convex_ports::market_data::QuoteQuality::Delayed; - listener.on_quote_update(delayed, 1000); - assert!(graph.get_cached(&node).is_none()); - } - - #[tokio::test] - async fn actual_broadcast_lag_suspends_until_image_and_closed_is_distinct() { - let graph = Arc::new(CalculationGraph::new()); - let throttle = Arc::new(ThrottleManager::new(graph.clone())); - let (tx, update_rx) = broadcast::channel(1); - let (_shutdown_tx, shutdown_rx) = broadcast::channel(1); - let mut listener = MarketDataListener::new(graph.clone(), throttle, update_rx, shutdown_rx); - let mut image = event(); - let now = chrono::Utc::now().timestamp_millis() - 100; - image.source_time_ms = now; - image.receive_time_ms = now; - listener.on_quote_update(image.clone(), now); - let mut status = listener.subscribe_status(); - let mut delta = image.clone(); - delta.sequence = 2; - delta.event = QuoteEventKind::Delta { fields: vec![] }; - tx.send(MarketDataUpdate::Quote(delta.clone())).unwrap(); - delta.sequence = 3; - tx.send(MarketDataUpdate::Quote(delta)).unwrap(); - let handle = tokio::spawn(async move { - listener.run().await; - }); - let recv = |mut status: StreamReceiver| async move { - let value = tokio::time::timeout(std::time::Duration::from_secs(2), status.recv()) - .await - .unwrap() - .unwrap(); - (value, status) - }; - let (value, returned) = recv(status).await; - status = returned; - assert!(matches!(value, IngestionStatus::Lagged { missed: 1 })); - let (value, returned) = recv(status).await; - status = returned; - assert!(matches!( - value, - IngestionStatus::Event(IngestionResult { - state: DataState::Gap, - .. - }) - )); - let node = NodeId::Quote { - instrument_id: image.key.instrument_id.clone(), - }; - assert!(graph.get_cached(&node).is_none()); - image.sequence = 3; - tx.send(MarketDataUpdate::Quote(image)).unwrap(); - let (value, returned) = recv(status).await; - status = returned; - assert!(matches!( - value, - IngestionStatus::Event(IngestionResult { - state: DataState::Live, - .. - }) - )); - assert!(graph.market_delivery_suspended()); - assert!(graph.get_cached(&node).is_none()); - assert_eq!( - graph.inspect_node(&node).unwrap().retained_quality, - Some(convex_core::contracts::DataQuality::Stale) - ); - drop(tx); - assert!(matches!(recv(status).await.0, IngestionStatus::Closed)); - handle.await.unwrap(); - assert!(graph.get_cached(&node).is_none()); - } -} diff --git a/crates/convex-engine/src/runtime/actor.rs b/crates/convex-engine/src/runtime/actor.rs index d3b2eae3..72f81897 100644 --- a/crates/convex-engine/src/runtime/actor.rs +++ b/crates/convex-engine/src/runtime/actor.rs @@ -42,7 +42,7 @@ impl Actor { (&services.input, services.plan.mode), (RuntimeInput::Captured, RuntimeMode::Captured) | (RuntimeInput::Replay(_), RuntimeMode::Replay) - | (RuntimeInput::Live, RuntimeMode::Live) + | (RuntimeInput::Live(_), RuntimeMode::Live) ); if !input_matches || !(1..=64).contains(&engine.config.max_concurrent_calcs) @@ -180,8 +180,10 @@ impl Actor { services.fixings.clone(), services.snapshots.clone(), ); - let live_source = - (services.plan.mode == RuntimeMode::Live).then(|| engine.market_data.quotes.clone()); + let live_source = match &services.input { + RuntimeInput::Live(source) => Some(source.clone()), + _ => None, + }; let needs_images = if live_source.is_some() { services .plan @@ -666,7 +668,7 @@ impl Actor { async fn next_event(&mut self) -> Result { match &mut self.services.input { RuntimeInput::Replay(source) => source.next_event().await, - RuntimeInput::Live => match self.live.as_mut() { + RuntimeInput::Live(_) => match self.live.as_mut() { Some(source) => source.recv().await, None => Err(StreamError::Closed), }, diff --git a/crates/convex-engine/src/runtime/mod.rs b/crates/convex-engine/src/runtime/mod.rs index fe6539b8..233ceba2 100644 --- a/crates/convex-engine/src/runtime/mod.rs +++ b/crates/convex-engine/src/runtime/mod.rs @@ -12,7 +12,7 @@ use actor::Actor; use convex_core::contracts::*; use convex_ports::{ fixing::FixingRepository, - market_data::{QuoteEventSource, StreamError, StreamReceiver}, + market_data::{QuoteEventSource, QuoteSource, StreamError, StreamReceiver}, quote_event::QuoteEvent, runtime::{ PublishedValuation, ResultPublisher, RuntimeCheckpoint, RuntimeStore, MAX_RUNTIME_EVENTS, @@ -135,8 +135,8 @@ pub enum RuntimeInput { Captured, /// Pull-based canonical source, such as `FileQuoteReplay`. Replay(Box), - /// The builder's `MarketDataProvider.quotes` subscription. - Live, + /// Subscribed quote source, such as the server's HTTP quote ingress. + Live(Arc), } /// Runtime I/O dependencies. All pricing dependencies are resolved before CPU work begins. pub struct RuntimeServices { diff --git a/crates/convex-engine/src/scheduler.rs b/crates/convex-engine/src/scheduler.rs deleted file mode 100644 index aec0e170..00000000 --- a/crates/convex-engine/src/scheduler.rs +++ /dev/null @@ -1,776 +0,0 @@ -//! Schedulers for reactive pricing calculations. -//! -//! This module provides: -//! - [`IntervalScheduler`]: Fixed-interval calculations (e.g., iNAV every 15s) -//! - [`EodScheduler`]: End-of-day calculations (e.g., official NAV) -//! - [`ThrottleManager`]: Debounced calculations for illiquid instruments - -use std::sync::Arc; -use std::time::Duration; - -use chrono::{Local, NaiveTime, Timelike}; -use dashmap::{DashMap, DashSet}; -use tokio::sync::broadcast; -use tokio::time::{interval, Instant}; -use tracing::{debug, info, warn}; - -use crate::calc_graph::{CalculationGraph, NodeId}; - -// ============================================================================= -// INTERVAL SCHEDULER -// ============================================================================= - -/// Manages fixed-interval calculations. -/// -/// Nodes registered with the interval scheduler will be recalculated -/// on a fixed schedule, regardless of when their inputs change. -/// This is useful for regulatory requirements like iNAV every 15 seconds. -pub struct IntervalScheduler { - /// Nodes grouped by interval duration - interval_groups: DashMap>, - - /// Running interval tasks (duration -> shutdown sender) - tasks: DashMap>, - - /// Calculation graph reference - calc_graph: Arc, - - /// Callback for when nodes are calculated - update_tx: broadcast::Sender, -} - -/// Update notification when a node is recalculated. -#[derive(Debug, Clone)] -pub struct NodeUpdate { - /// The node that was updated - pub node_id: NodeId, - /// When the update occurred - pub timestamp: i64, - /// Update source - pub source: UpdateSource, - /// Some only for an actual generation-checked completion, never a timer/input request. - pub completion: Option, -} - -/// Source of the update. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UpdateSource { - /// Immediate recalculation from input change - Immediate, - /// Throttled recalculation - Throttled, - /// Fixed interval recalculation - Interval, - /// End-of-day recalculation - EndOfDay, - /// On-demand recalculation - OnDemand, -} - -impl IntervalScheduler { - /// Create a new interval scheduler. - pub fn new(calc_graph: Arc) -> Self { - let (update_tx, _) = broadcast::channel(1000); - Self { - interval_groups: DashMap::new(), - tasks: DashMap::new(), - calc_graph, - update_tx, - } - } - - /// Subscribe to node updates. - pub fn subscribe(&self) -> broadcast::Receiver { - self.update_tx.subscribe() - } - - /// Register a node for interval updates. - pub fn register(&self, node_id: NodeId, interval_duration: Duration) { - // Add to interval group - self.interval_groups - .entry(interval_duration) - .or_default() - .push(node_id.clone()); - - info!( - "Registered node {} for interval updates every {:?}", - node_id, interval_duration - ); - - // Start interval task if not running - if !self.tasks.contains_key(&interval_duration) { - self.start_interval_task(interval_duration); - } - } - - /// Unregister a node from interval updates. - pub fn unregister(&self, node_id: &NodeId) { - for mut entry in self.interval_groups.iter_mut() { - entry.value_mut().retain(|id| id != node_id); - } - } - - /// Start an interval task for a specific duration. - fn start_interval_task(&self, interval_duration: Duration) { - let calc_graph = self.calc_graph.clone(); - let groups = self.interval_groups.clone(); - let update_tx = self.update_tx.clone(); - - let (shutdown_tx, mut shutdown_rx) = broadcast::channel::<()>(1); - self.tasks.insert(interval_duration, shutdown_tx); - - tokio::spawn(async move { - let mut ticker = interval(interval_duration); - - loop { - tokio::select! { - _ = ticker.tick() => { - // Get nodes for this interval - if let Some(nodes) = groups.get(&interval_duration) { - let timestamp = chrono::Utc::now().timestamp(); - - for node_id in nodes.iter() { - // Check if node is dirty - if calc_graph.is_dirty(node_id) { - if let Err(error) = calc_graph.request_calculation(node_id) { warn!("Interval work rejected: {error}"); continue; } - debug!("Interval scheduler processing: {}", node_id); - - // Notify subscribers - let _ = update_tx.send(NodeUpdate { - node_id: node_id.clone(), - timestamp, - source: UpdateSource::Interval, - completion: None, - }); - } - } - } - } - _ = shutdown_rx.recv() => { - info!("Interval scheduler shutting down for {:?}", interval_duration); - break; - } - } - } - }); - - info!("Started interval scheduler for {:?}", interval_duration); - } - - /// Stop all interval tasks. - pub fn stop_all(&self) { - for entry in self.tasks.iter() { - let _ = entry.value().send(()); - } - self.tasks.clear(); - } - - /// Get all registered intervals. - pub fn get_intervals(&self) -> Vec { - self.interval_groups.iter().map(|e| *e.key()).collect() - } - - /// Get nodes for a specific interval. - pub fn get_nodes(&self, interval_duration: Duration) -> Vec { - self.interval_groups - .get(&interval_duration) - .map(|v| v.clone()) - .unwrap_or_default() - } -} - -// ============================================================================= -// EOD SCHEDULER -// ============================================================================= - -/// Manages end-of-day calculations. -/// -/// Nodes registered with the EOD scheduler will be recalculated -/// once per day at a specified time (e.g., 4:00 PM for official NAV). -pub struct EodScheduler { - /// Nodes grouped by EOD time - eod_nodes: DashMap>, - - /// Calculation graph reference - calc_graph: Arc, - - /// Update notification channel - update_tx: broadcast::Sender, - - /// Shutdown signal - shutdown_tx: Option>, -} - -impl EodScheduler { - /// Create a new EOD scheduler. - pub fn new(calc_graph: Arc) -> Self { - let (update_tx, _) = broadcast::channel(1000); - Self { - eod_nodes: DashMap::new(), - calc_graph, - update_tx, - shutdown_tx: None, - } - } - - /// Subscribe to node updates. - pub fn subscribe(&self) -> broadcast::Receiver { - self.update_tx.subscribe() - } - - /// Register a node for EOD updates at specified time. - /// - /// # Arguments - /// * `node_id` - The node to register - /// * `time` - Time string in "HH:MM:SS" format (local timezone) - pub fn register(&self, node_id: NodeId, time: &str) { - self.eod_nodes - .entry(time.to_string()) - .or_default() - .push(node_id.clone()); - - info!("Registered node {} for EOD at {}", node_id, time); - } - - /// Unregister a node from EOD updates. - pub fn unregister(&self, node_id: &NodeId) { - for mut entry in self.eod_nodes.iter_mut() { - entry.value_mut().retain(|id| id != node_id); - } - } - - /// Start the EOD scheduler. - pub fn start(&mut self) { - let calc_graph = self.calc_graph.clone(); - let eod_nodes = self.eod_nodes.clone(); - let update_tx = self.update_tx.clone(); - - let (shutdown_tx, mut shutdown_rx) = broadcast::channel::<()>(1); - self.shutdown_tx = Some(shutdown_tx); - - tokio::spawn(async move { - // Check every minute for EOD times - let mut ticker = interval(Duration::from_secs(60)); - - loop { - tokio::select! { - _ = ticker.tick() => { - let now = Local::now(); - let current_time = format!("{:02}:{:02}:00", now.hour(), now.minute()); - - // Check if any EOD time matches - if let Some(nodes) = eod_nodes.get(¤t_time) { - info!("EOD trigger at {}", current_time); - let timestamp = chrono::Utc::now().timestamp(); - - for node_id in nodes.iter() { - debug!("EOD scheduler processing: {}", node_id); - - // Mark as dirty to trigger recalculation - if let Err(error) = calc_graph.request_calculation(node_id) { warn!("Scheduled work rejected: {error}"); continue; } - - // Notify subscribers - let _ = update_tx.send(NodeUpdate { - node_id: node_id.clone(), - timestamp, - source: UpdateSource::EndOfDay, - completion: None, - }); - } - } - } - _ = shutdown_rx.recv() => { - info!("EOD scheduler shutting down"); - break; - } - } - } - }); - - info!("EOD scheduler started"); - } - - /// Stop the EOD scheduler. - pub fn stop(&self) { - if let Some(ref tx) = self.shutdown_tx { - let _ = tx.send(()); - } - } - - /// Get all registered EOD times. - pub fn get_times(&self) -> Vec { - self.eod_nodes.iter().map(|e| e.key().clone()).collect() - } - - /// Get nodes for a specific EOD time. - pub fn get_nodes(&self, time: &str) -> Vec { - self.eod_nodes - .get(time) - .map(|v| v.clone()) - .unwrap_or_default() - } - - /// Calculate seconds until next EOD time. - pub fn seconds_until_next_eod(&self) -> Option { - let now = Local::now(); - let current_seconds = now.hour() * 3600 + now.minute() * 60 + now.second(); - - let mut min_wait: Option = None; - - for entry in self.eod_nodes.iter() { - if let Ok(time) = NaiveTime::parse_from_str(entry.key(), "%H:%M:%S") { - let target_seconds = time.hour() * 3600 + time.minute() * 60 + time.second(); - let wait = if target_seconds > current_seconds { - (target_seconds - current_seconds) as u64 - } else { - // Tomorrow - (86400 - current_seconds + target_seconds) as u64 - }; - - min_wait = Some(min_wait.map_or(wait, |m| m.min(wait))); - } - } - - min_wait - } -} - -// ============================================================================= -// THROTTLE MANAGER -// ============================================================================= - -/// Manages throttled (debounced) calculations. -/// -/// Nodes with throttled frequency will be recalculated at most once -/// per interval, even if their inputs change more frequently. -pub struct ThrottleManager { - /// Last calculation time per node - last_calc_time: DashMap, - - /// Pending throttled nodes (waiting for interval) - pending: DashSet, - - /// Throttle intervals per node - intervals: DashMap, - - /// Calculation graph reference - calc_graph: Arc, - - /// Update notification channel - update_tx: broadcast::Sender, -} - -impl ThrottleManager { - /// Create a new throttle manager. - pub fn new(calc_graph: Arc) -> Self { - let (update_tx, _) = broadcast::channel(1000); - Self { - last_calc_time: DashMap::new(), - pending: DashSet::new(), - intervals: DashMap::new(), - calc_graph, - update_tx, - } - } - - /// Subscribe to node updates. - pub fn subscribe(&self) -> broadcast::Receiver { - self.update_tx.subscribe() - } - - /// Register a node for throttled updates. - pub fn register(&self, node_id: NodeId, interval: Duration) { - self.intervals.insert(node_id.clone(), interval); - debug!( - "Registered node {} for throttled updates (interval: {:?})", - node_id, interval - ); - } - - /// Unregister a node from throttled updates. - pub fn unregister(&self, node_id: &NodeId) { - self.intervals.remove(node_id); - self.pending.remove(node_id); - self.last_calc_time.remove(node_id); - } - - /// Check if a node should be calculated now (respecting throttle). - pub fn should_calculate(&self, node_id: &NodeId) -> bool { - if let Some(interval) = self.intervals.get(node_id) { - let now = Instant::now(); - self.last_calc_time - .get(node_id) - .map(|t| now.duration_since(*t) >= *interval) - .unwrap_or(true) - } else { - true // Not throttled - } - } - - /// Mark a node as calculated. - pub fn mark_calculated(&self, node_id: &NodeId) { - self.last_calc_time.insert(node_id.clone(), Instant::now()); - self.pending.remove(node_id); - } - - /// Schedule a throttled calculation for a node. - /// - /// If the throttle interval hasn't passed, the node is added to pending - /// and will be calculated when the interval expires. - pub fn schedule(&self, node_id: NodeId) { - if !self.should_calculate(&node_id) { - // Add to pending if not already - if self.pending.insert(node_id.clone()) { - // Spawn task to trigger after interval - let calc_graph = self.calc_graph.clone(); - let update_tx = self.update_tx.clone(); - let pending = self.pending.clone(); - - if let Some(interval) = self.intervals.get(&node_id).map(|i| *i) { - let node_id_clone = node_id.clone(); - tokio::spawn(async move { - tokio::time::sleep(interval).await; - - if pending.remove(&node_id_clone).is_some() { - debug!("Throttle expired, triggering: {}", node_id_clone); - - // Mark as dirty - if let Err(error) = calc_graph.request_calculation(&node_id_clone) { - warn!("Throttled work rejected: {error}"); - return; - } - - // Notify - let _ = update_tx.send(NodeUpdate { - node_id: node_id_clone, - timestamp: chrono::Utc::now().timestamp(), - source: UpdateSource::Throttled, - completion: None, - }); - } - }); - } - } - } else { - // Can calculate immediately - if let Err(error) = self.calc_graph.request_calculation(&node_id) { - warn!("Throttled work rejected: {error}"); - return; - } - - let _ = self.update_tx.send(NodeUpdate { - node_id, - timestamp: chrono::Utc::now().timestamp(), - source: UpdateSource::Throttled, - completion: None, - }); - } - } - - /// Get all pending nodes. - pub fn get_pending(&self) -> Vec { - self.pending.iter().map(|r| r.clone()).collect() - } - - /// Get the throttle interval for a node. - pub fn get_interval(&self, node_id: &NodeId) -> Option { - self.intervals.get(node_id).map(|i| *i) - } -} - -// ============================================================================= -// CRON SCHEDULER -// ============================================================================= - -/// Manages cron-based scheduled calculations. -/// -/// Nodes registered with the cron scheduler will be recalculated -/// according to a cron expression (e.g., "0 */15 * * * *" for every 15 minutes). -pub struct CronScheduler { - /// Nodes grouped by cron expression - cron_nodes: DashMap>, - - /// Parsed cron schedules - schedules: DashMap, - - /// Calculation graph reference - calc_graph: Arc, - - /// Update notification channel - update_tx: broadcast::Sender, - - /// Shutdown signal - shutdown_tx: Option>, -} - -impl CronScheduler { - /// Create a new cron scheduler. - pub fn new(calc_graph: Arc) -> Self { - let (update_tx, _) = broadcast::channel(1000); - Self { - cron_nodes: DashMap::new(), - schedules: DashMap::new(), - calc_graph, - update_tx, - shutdown_tx: None, - } - } - - /// Subscribe to node updates. - pub fn subscribe(&self) -> broadcast::Receiver { - self.update_tx.subscribe() - } - - /// Register a node for cron-based updates. - /// - /// # Arguments - /// * `node_id` - The node to register - /// * `cron_expr` - Cron expression (e.g., "0 */15 * * * *" for every 15 min) - /// - /// # Cron Expression Format - /// Standard 6-field cron format: second minute hour day-of-month month day-of-week - /// Examples: - /// - "0 */15 * * * *" - Every 15 minutes - /// - "0 0 9 * * MON-FRI" - 9 AM on weekdays - /// - "0 30 16 * * *" - 4:30 PM daily - pub fn register(&self, node_id: NodeId, cron_expr: &str) -> Result<(), String> { - use std::str::FromStr; - - // Parse cron expression - let schedule = cron::Schedule::from_str(cron_expr) - .map_err(|e| format!("Invalid cron expression '{}': {}", cron_expr, e))?; - - // Store schedule - self.schedules.insert(cron_expr.to_string(), schedule); - - // Add node to cron group - self.cron_nodes - .entry(cron_expr.to_string()) - .or_default() - .push(node_id.clone()); - - info!( - "Registered node {} for cron schedule: {}", - node_id, cron_expr - ); - Ok(()) - } - - /// Unregister a node from cron updates. - pub fn unregister(&self, node_id: &NodeId) { - for mut entry in self.cron_nodes.iter_mut() { - entry.value_mut().retain(|id| id != node_id); - } - } - - /// Start the cron scheduler. - pub fn start(&mut self) { - let calc_graph = self.calc_graph.clone(); - let cron_nodes = self.cron_nodes.clone(); - let schedules = self.schedules.clone(); - let update_tx = self.update_tx.clone(); - - let (shutdown_tx, mut shutdown_rx) = broadcast::channel::<()>(1); - self.shutdown_tx = Some(shutdown_tx); - - tokio::spawn(async move { - // Check every second for cron triggers - let mut ticker = interval(Duration::from_secs(1)); - let mut last_check = chrono::Utc::now(); - - loop { - tokio::select! { - _ = ticker.tick() => { - let now = chrono::Utc::now(); - - // Check each cron expression - for entry in schedules.iter() { - let cron_expr = entry.key(); - let schedule = entry.value(); - - // Get upcoming times between last check and now - let upcoming = schedule.after(&last_check); - for next_time in upcoming.take(1) { - if next_time <= now { - // Trigger! - if let Some(nodes) = cron_nodes.get(cron_expr) { - info!("Cron trigger for '{}' at {}", cron_expr, now); - let timestamp = now.timestamp(); - - for node_id in nodes.iter() { - debug!("Cron scheduler processing: {}", node_id); - - // Mark as dirty to trigger recalculation - if let Err(error) = calc_graph.request_calculation(node_id) { warn!("Scheduled work rejected: {error}"); continue; } - - // Notify subscribers - let _ = update_tx.send(NodeUpdate { - node_id: node_id.clone(), - timestamp, - source: UpdateSource::Interval, // Use Interval as closest match - completion: None, - }); - } - } - } - } - } - - last_check = now; - } - _ = shutdown_rx.recv() => { - info!("Cron scheduler shutting down"); - break; - } - } - } - }); - - info!("Cron scheduler started"); - } - - /// Stop the cron scheduler. - pub fn stop(&self) { - if let Some(ref tx) = self.shutdown_tx { - let _ = tx.send(()); - } - } - - /// Get all registered cron expressions. - pub fn get_expressions(&self) -> Vec { - self.cron_nodes.iter().map(|e| e.key().clone()).collect() - } - - /// Get nodes for a specific cron expression. - pub fn get_nodes(&self, cron_expr: &str) -> Vec { - self.cron_nodes - .get(cron_expr) - .map(|v| v.clone()) - .unwrap_or_default() - } - - /// Get next trigger time for a cron expression. - pub fn next_trigger(&self, cron_expr: &str) -> Option> { - self.schedules - .get(cron_expr) - .and_then(|schedule| schedule.upcoming(chrono::Utc).next()) - } -} - -// ============================================================================= -// TESTS -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - use convex_core::ids::*; - - #[tokio::test] - async fn test_interval_scheduler_registration() { - let calc_graph = Arc::new(CalculationGraph::new()); - let scheduler = IntervalScheduler::new(calc_graph); - - let node_id = NodeId::EtfInav { - etf_id: EtfId::new("LQD"), - }; - let interval = Duration::from_secs(15); - - scheduler.register(node_id.clone(), interval); - - let nodes = scheduler.get_nodes(interval); - assert_eq!(nodes.len(), 1); - assert_eq!(nodes[0], node_id); - } - - #[test] - fn test_eod_scheduler_registration() { - let calc_graph = Arc::new(CalculationGraph::new()); - let scheduler = EodScheduler::new(calc_graph); - - let node_id = NodeId::EtfNav { - etf_id: EtfId::new("LQD"), - }; - - scheduler.register(node_id.clone(), "16:00:00"); - - let nodes = scheduler.get_nodes("16:00:00"); - assert_eq!(nodes.len(), 1); - assert_eq!(nodes[0], node_id); - } - - #[test] - fn test_throttle_manager() { - let calc_graph = Arc::new(CalculationGraph::new()); - let manager = ThrottleManager::new(calc_graph); - - let node_id = NodeId::BondPrice { - instrument_id: InstrumentId::new("TEST"), - }; - let interval = Duration::from_secs(1); - - manager.register(node_id.clone(), interval); - - // First check - should calculate - assert!(manager.should_calculate(&node_id)); - - // Mark as calculated - manager.mark_calculated(&node_id); - - // Immediately after - should not calculate - assert!(!manager.should_calculate(&node_id)); - } - - #[test] - fn test_cron_scheduler_registration() { - let calc_graph = Arc::new(CalculationGraph::new()); - let scheduler = CronScheduler::new(calc_graph); - - let node_id = NodeId::Portfolio { - portfolio_id: PortfolioId::new("TEST_PORTFOLIO"), - }; - - // Register with valid cron expression (every 15 minutes) - let result = scheduler.register(node_id.clone(), "0 */15 * * * *"); - assert!(result.is_ok()); - - let nodes = scheduler.get_nodes("0 */15 * * * *"); - assert_eq!(nodes.len(), 1); - assert_eq!(nodes[0], node_id); - } - - #[test] - fn test_cron_scheduler_invalid_expression() { - let calc_graph = Arc::new(CalculationGraph::new()); - let scheduler = CronScheduler::new(calc_graph); - - let node_id = NodeId::Portfolio { - portfolio_id: PortfolioId::new("TEST_PORTFOLIO"), - }; - - // Invalid cron expression - let result = scheduler.register(node_id, "invalid cron"); - assert!(result.is_err()); - } - - #[test] - fn test_cron_scheduler_next_trigger() { - let calc_graph = Arc::new(CalculationGraph::new()); - let scheduler = CronScheduler::new(calc_graph); - - let node_id = NodeId::Portfolio { - portfolio_id: PortfolioId::new("TEST_PORTFOLIO"), - }; - - // Register with valid cron expression - scheduler.register(node_id, "0 0 * * * *").unwrap(); // Every hour - - // Should have a next trigger time - let next = scheduler.next_trigger("0 0 * * * *"); - assert!(next.is_some()); - - // Next trigger should be in the future - let next_time = next.unwrap(); - assert!(next_time > chrono::Utc::now()); - } -} diff --git a/crates/convex-engine/tests/configuration_activation.rs b/crates/convex-engine/tests/configuration_activation.rs index 2438489e..f634ab62 100644 --- a/crates/convex-engine/tests/configuration_activation.rs +++ b/crates/convex-engine/tests/configuration_activation.rs @@ -673,11 +673,7 @@ async fn live_override_expiry_schedules_a_policy_refresh_without_a_quote_or_peri .await .unwrap(); let source = Arc::new(subscription_source::SubscriptionSource::new()); - let mut market = runtime::market_data(); - Arc::get_mut(&mut market).unwrap().quotes = source.clone(); let engine = PricingEngineBuilder::new() - .with_market_data(market) - .with_reference_data(Arc::new(create_empty_reference_data())) .with_storage(setup.storage.clone()) .with_configuration_use_cases(service) .with_runtime(RuntimeServices { @@ -686,7 +682,7 @@ async fn live_override_expiry_schedules_a_policy_refresh_without_a_quote_or_peri fixings: Arc::new(JsonFixingRepository::empty()), store: setup.store.clone(), publisher: runtime::archive(&setup), - input: RuntimeInput::Live, + input: RuntimeInput::Live(source.clone()), }) .build() .unwrap(); diff --git a/crates/convex-engine/tests/runtime.rs b/crates/convex-engine/tests/runtime.rs index 1fb71ead..132b5a07 100644 --- a/crates/convex-engine/tests/runtime.rs +++ b/crates/convex-engine/tests/runtime.rs @@ -455,11 +455,9 @@ async fn concurrent_start_has_one_owner_and_shutdown_is_idempotent() { assert!(engine.start().await.is_err()); } #[tokio::test] -async fn empty_providers_need_explicit_demo_and_demo_is_never_ready() { +async fn an_engine_without_a_runtime_needs_explicit_demo_and_is_never_ready() { let s = setup().await; let engine = PricingEngineBuilder::new() - .with_market_data(market_data()) - .with_reference_data(Arc::new(create_empty_reference_data())) .with_storage(s.storage.clone()) .build() .unwrap(); @@ -467,8 +465,6 @@ async fn empty_providers_need_explicit_demo_and_demo_is_never_ready() { assert!(!engine.runtime_health().ready); engine.shutdown().await.unwrap(); let demo = PricingEngineBuilder::new() - .with_market_data(market_data()) - .with_reference_data(Arc::new(create_empty_reference_data())) .with_storage(s.storage.clone()) .with_demo_mode() .build() @@ -619,11 +615,7 @@ async fn live_subscription_requires_new_image_expires_without_polling_and_closur plan.assembly.curves[0].recorded_at = now; plan.assembly.curves[0].max_age_ms = 5000; plan.assembly.quotes[0].max_age_ms = 500; - let mut market = market_data(); - Arc::get_mut(&mut market).unwrap().quotes = source.clone(); let engine = PricingEngineBuilder::new() - .with_market_data(market) - .with_reference_data(Arc::new(create_empty_reference_data())) .with_storage(s.storage.clone()) .with_runtime(RuntimeServices { plan, @@ -631,7 +623,7 @@ async fn live_subscription_requires_new_image_expires_without_polling_and_closur fixings: Arc::new(JsonFixingRepository::empty()), store: s.store.clone(), publisher: archive(&s), - input: RuntimeInput::Live, + input: RuntimeInput::Live(source.clone()), }) .build() .unwrap(); @@ -701,11 +693,7 @@ fn live_engine( store: Arc, source: Arc, ) -> PricingEngine { - let mut market = market_data(); - Arc::get_mut(&mut market).unwrap().quotes = source; PricingEngineBuilder::new() - .with_market_data(market) - .with_reference_data(Arc::new(create_empty_reference_data())) .with_storage(s.storage.clone()) .with_runtime(RuntimeServices { plan, @@ -713,7 +701,7 @@ fn live_engine( fixings: Arc::new(JsonFixingRepository::empty()), store, publisher: archive(s), - input: RuntimeInput::Live, + input: RuntimeInput::Live(source), }) .build() .unwrap() diff --git a/crates/convex-engine/tests/support/configuration_setup.rs b/crates/convex-engine/tests/support/configuration_setup.rs index 55c361e6..2a2af3a2 100644 --- a/crates/convex-engine/tests/support/configuration_setup.rs +++ b/crates/convex-engine/tests/support/configuration_setup.rs @@ -47,8 +47,6 @@ pub fn fixtures(setup: &Setup) -> Vec { pub fn engine(setup: &Setup, cases: Arc) -> Arc { Arc::new( PricingEngineBuilder::new() - .with_market_data(runtime::market_data()) - .with_reference_data(Arc::new(create_empty_reference_data())) .with_storage(setup.storage.clone()) .with_configuration_use_cases(cases) .with_runtime(RuntimeServices { diff --git a/crates/convex-engine/tests/support/runtime_setup.rs b/crates/convex-engine/tests/support/runtime_setup.rs index f70811f1..8b5390c5 100644 --- a/crates/convex-engine/tests/support/runtime_setup.rs +++ b/crates/convex-engine/tests/support/runtime_setup.rs @@ -93,17 +93,6 @@ pub async fn setup() -> Setup { plan, } } -pub fn market_data() -> Arc { - Arc::new(MarketDataProvider { - quotes: Arc::new(EmptyQuoteSource), - curve_inputs: Arc::new(EmptyCurveInputSource), - index_fixings: Arc::new(EmptyIndexFixingSource), - volatility: Arc::new(EmptyVolatilitySource), - fx_rates: Arc::new(EmptyFxRateSource), - inflation_fixings: Arc::new(EmptyInflationFixingSource), - etf_quotes: Arc::new(EmptyEtfQuoteSource), - }) -} pub fn make_engine( s: &Setup, publisher: Arc, @@ -113,8 +102,6 @@ pub fn make_engine( ) -> Arc { Arc::new( PricingEngineBuilder::new() - .with_market_data(market_data()) - .with_reference_data(Arc::new(create_empty_reference_data())) .with_storage(s.storage.clone()) .with_runtime(RuntimeServices { plan, diff --git a/crates/convex-ext-file/Cargo.toml b/crates/convex-ext-file/Cargo.toml index f8b6d432..4342ea52 100644 --- a/crates/convex-ext-file/Cargo.toml +++ b/crates/convex-ext-file/Cargo.toml @@ -17,15 +17,12 @@ convex-ports = { workspace = true } convex-core = { workspace = true } # File parsing -csv = "1" serde_json = { workspace = true } # Async async-trait = "0.1" -tokio = { version = "1", features = ["sync", "fs"] } # Core dependencies -rust_decimal = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } tempfile = "3" diff --git a/crates/convex-ext-file/README.md b/crates/convex-ext-file/README.md index a4e9641e..861400c0 100644 --- a/crates/convex-ext-file/README.md +++ b/crates/convex-ext-file/README.md @@ -66,42 +66,22 @@ The assembler exposes Live, Delayed, Indicative, Manual, Stale, Gap, Invalid, Cl The assembler retains at most 12 fields and one accepted data envelope per key, plus a bounded supplier count index. Rejections return one decision to the caller; they do not accumulate an internal quarantine or trace queue. A caller requiring durable quarantine must persist the permitted evidence reference and decision in its chosen journal. Explicit unsubscribe/removal releases the key; reintroduction requires an image. -The listener sends only admitted Live price observations into the current calculation graph, bounds cache reads by the captured monotonic freshness deadline and removes expired entries on a 100 ms maintenance sweep, evicts suspended quote/bond caches and guards timer calculations against lost/replaced quote revisions. It refuses two suppliers for one instrument because the current graph has no source-selection policy. Shared-channel lag, closure or key-capacity overload halts all automatic calculations: quote images can reconstruct local observations, but unknown auxiliary dependencies require a new engine and full reload. There is no hot-resume method that assumes reconciliation. Complete runtime consolidation, exact dependency generations, source-selection policy and snapshot-consistent publication remain separate tasks. The present server still fails the enforced Empty-success and synthetic-curve probes; this ingestion work does not complete a production release gate. +The Live runtime takes its quote subscription through `RuntimeInput::Live`; the server +feeds it from `POST /api/v1/market/quotes`. The earlier `MarketDataListener`, its +scheduler and the engine's graph input handlers are removed. ## Resource and delivery limits Replay accepts regular files up to 256 MiB, at most 1,000,000 records and at most 64 KiB per record including its newline. It also checks bytes consumed if the file grows. Blank/malformed/unknown-field/numeric-decimal records produce `StreamError::Failure`; the cursor then closes rather than skipping evidence. Unsupported schema numbers decode into events and receive the assembler's Invalid decision. EOF is `StreamError::Closed`, including an empty file. Files must remain immutable during replay; no concurrent-file consistency or cryptographic authentication is claimed. -Streaming ports now share `StreamReceiver::recv() -> Result`; `Lagged { missed }`, `Closed` and adapter `Failure` are distinct. Quote subscriptions deliver `QuoteEvent`. The old per-type receiver wrappers and unsequenced quote update type are removed without aliases. Existing snapshot DTOs, REST v1 economic units, pure pricing and language-client calls retain their contract. Rust adapter callers migrate their subscribe signatures and handle every delivery result. Listener construction now takes graph, throttle manager, update receiver and shutdown receiver; the unused curve-builder argument is removed. Configure `with_policy` before the first event. [Pinned Tokio broadcast documentation](https://docs.rs/tokio/1.50.0/tokio/sync/broadcast/index.html) defines the underlying transient loss/closure behavior. +Streaming ports now share `StreamReceiver::recv() -> Result`; `Lagged { missed }`, `Closed` and adapter `Failure` are distinct. Quote subscriptions deliver `QuoteEvent`. The old per-type receiver wrappers and unsequenced quote update type are removed without aliases. Existing snapshot DTOs, REST v1 economic units, pure pricing and language-client calls retain their contract. Rust adapter callers migrate their subscribe signatures and handle every delivery result. [Pinned Tokio broadcast documentation](https://docs.rs/tokio/1.50.0/tokio/sync/broadcast/index.html) defines the underlying transient loss/closure behavior. -## Snapshot file migration +## Removed file snapshot sources -All three snapshot sources read regular files up to 16 MiB, reject unknown record fields and replace the complete cache only after successful validation. Missing files fail explicitly. A failed reload keeps the prior data with its prior timestamps; a successful reload removes keys absent from the replacement. Quote/fixing snapshots hold at most 100,000 records. Curve snapshots have at most 4,096 inputs per curve and 100,000 inputs total; curve tenors are positive, ASCII and bounded. Required instrument types are preserved rather than inferred as swaps. Calibration validity belongs to the curve construction layer. - -Quote CSV requires these headers and a recorded timestamp/source. Blank optional prices mean absent: - -```csv -instrument_id,bid_price,ask_price,mid_price,last_price,timestamp,source -BOND-001,99.125,99.25,99.1875,,1791086400100,file-eod -``` - -Curve JSON uses exact rate strings and explicit instrument types/timestamps: - -```json -[{"curve_id":"USD.TEST","inputs":[{"tenor":"2Y","rate":"0.04","instrument_type":"Swap","timestamp":1791086400100,"source":"file-eod"}]}] -``` - -Fixing CSV is implemented rather than returning an empty placeholder: - -```csv -index,date,rate,timestamp,source -SOFR,2026-10-02,0.04,1791086400100,file-eod -EURIBOR-3M,2026-10-02,0.025,1791086400100,file-eod -``` - -Index names SOFR, ESTR, SONIA, EURIBOR-tenor and TERM-SOFR-tenor map to native index types; other bounded names retain their explicit Other identity. Fixing ranges are inclusive and date-ordered; duplicate index/date records fail. This file snapshot does not provide fixing revision history or known-at filtering. - -Convert previous curve numeric rates to exact strings and supply authoritative instrument types, timestamps and source names. Previous quote files also need recorded timestamps and sources. There is no fallback that invents their provenance. An intentionally empty snapshot uses a header-only CSV or `[]` curve JSON; demo/test composition can explicitly use the existing empty sources. +The CSV quote, JSON curve-input and CSV fixing snapshot sources, and the `Empty*` +placeholder sources, are removed: nothing composed them. Live quotes reach the +runtime through `RuntimeInput::Live`, replay reads `FileQuoteReplay`, historical +fixings use `JsonFixingRepository` below, and curves come from stored recipes. ## Reference master imports @@ -127,5 +107,3 @@ low-latency guarantees are not certified. `JsonFixingRepository` loads a frozen, strict schema-1 history of up to 100,000 revisions from a regular file of at most 16 MiB. It requires actual publication and recording timestamps and filters both against the information cutoff. -The earlier CSV fixing source remains a current snapshot with no historical -publication contract; the two formats are not silently interchangeable. diff --git a/crates/convex-ext-file/src/lib.rs b/crates/convex-ext-file/src/lib.rs index 3e20ed2a..e1a41a7f 100644 --- a/crates/convex-ext-file/src/lib.rs +++ b/crates/convex-ext-file/src/lib.rs @@ -1,21 +1,13 @@ //! # Convex Ext File //! -//! File-based market data and reference data for the Convex pricing engine. -//! -//! This crate provides default implementations for testing, EOD loads, and static data: -//! - CSV-based quote source -//! - JSON-based curve input source -//! - CSV-based index fixing source -//! -//! Sequenced quote replay is available through `FileQuoteReplay`. Live provider -//! adapters require separate product and entitlement certification. +//! File adapters for the Convex ports: reference import and repository, fixings, +//! sequenced quote replay (`FileQuoteReplay`), and optional snapshot and result +//! files. The server composes replay and fixings; its durable state is in redb. #![warn(missing_docs)] #![warn(clippy::all)] mod fixing_repository; -mod market_data; -mod reference_data; mod reference_import; mod reference_repository; mod replay; @@ -23,44 +15,8 @@ mod result_publisher; mod snapshot_repository; pub use fixing_repository::*; -pub use market_data::*; -pub use reference_data::*; pub use reference_import::*; pub use reference_repository::*; pub use replay::*; pub use result_publisher::*; pub use snapshot_repository::*; - -use std::path::Path; -use std::sync::Arc; - -use convex_ports::error::TraitError; -use convex_ports::market_data::MarketDataProvider; -use convex_ports::reference_data::ReferenceDataProvider; - -/// Create a file-based market data provider. -pub fn create_file_market_data( - quotes_csv: impl AsRef, - curves_json: impl AsRef, - fixings_csv: impl AsRef, -) -> Result { - Ok(MarketDataProvider { - quotes: Arc::new(CsvQuoteSource::new(quotes_csv)?), - curve_inputs: Arc::new(JsonCurveInputSource::new(curves_json)?), - index_fixings: Arc::new(CsvIndexFixingSource::new(fixings_csv)?), - volatility: Arc::new(EmptyVolatilitySource), - fx_rates: Arc::new(EmptyFxRateSource), - inflation_fixings: Arc::new(EmptyInflationFixingSource), - etf_quotes: Arc::new(EmptyEtfQuoteSource), - }) -} - -/// Create empty auxiliary reference sources for explicit demo/test composition. -/// Bond reference terms always come from the configured repository. -pub fn create_empty_reference_data() -> ReferenceDataProvider { - ReferenceDataProvider { - issuers: Arc::new(EmptyIssuerReferenceSource), - ratings: Arc::new(EmptyRatingSource), - etf_holdings: Arc::new(EmptyEtfHoldingsSource), - } -} diff --git a/crates/convex-ext-file/src/market_data.rs b/crates/convex-ext-file/src/market_data.rs deleted file mode 100644 index 8f3ade2e..00000000 --- a/crates/convex-ext-file/src/market_data.rs +++ /dev/null @@ -1,675 +0,0 @@ -//! Deterministic file snapshots with exact decimals and atomic reload. - -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; -use std::sync::RwLock; - -use async_trait::async_trait; -use rust_decimal::Decimal; -use serde::Deserialize; - -use convex_core::ids::*; -use convex_core::Date; -use convex_ports::error::TraitError; -use convex_ports::market_data::*; - -const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; -const MAX_RECORDS: usize = 100_000; -const MAX_CURVE_INPUTS: usize = 4096; - -fn read_file(path: &Path) -> Result, TraitError> { - use std::io::Read; - let mut file = std::fs::File::open(path).map_err(|e| TraitError::IoError(e.to_string()))?; - let metadata = file - .metadata() - .map_err(|e| TraitError::IoError(e.to_string()))?; - if !metadata.is_file() || metadata.len() > MAX_FILE_BYTES { - return Err(TraitError::CapacityExceeded); - } - let mut bytes = Vec::new(); - file.by_ref() - .take(MAX_FILE_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|e| TraitError::IoError(e.to_string()))?; - if bytes.len() as u64 > MAX_FILE_BYTES { - return Err(TraitError::CapacityExceeded); - } - Ok(bytes) -} - -fn csv_headers( - reader: &mut csv::Reader<&[u8]>, - required: &[&str], - allowed: &[&str], -) -> Result<(), TraitError> { - let headers = reader - .headers() - .map_err(|e| TraitError::ParseError(e.to_string()))?; - let mut unique = HashSet::new(); - if headers - .iter() - .any(|header| !allowed.contains(&header) || !unique.insert(header)) - || required.iter().any(|header| !unique.contains(header)) - { - return Err(TraitError::InvalidInput( - "missing, duplicate or unknown market CSV header", - )); - } - Ok(()) -} - -fn decimal(value: &str) -> Result { - Decimal::from_str_exact(value).map_err(|e| TraitError::ParseError(e.to_string())) -} - -fn metadata(id: &str, source: &str, timestamp: i64) -> Result<(), TraitError> { - validate_text(id, 128)?; - validate_text(source, 128)?; - if timestamp < 0 { - return Err(TraitError::InvalidInput("negative market timestamp")); - } - Ok(()) -} - -fn lock_error(_: std::sync::PoisonError) -> TraitError { - TraitError::SourceNotAvailable("file snapshot lock poisoned".into()) -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct QuoteRecord { - instrument_id: String, - bid_price: Option, - ask_price: Option, - mid_price: Option, - last_price: Option, - timestamp: i64, - source: String, -} - -/// CSV bond quote snapshot. Explicit timestamp (UTC milliseconds) and source are required. -pub struct CsvQuoteSource { - file_path: PathBuf, - quotes: RwLock>, -} - -impl CsvQuoteSource { - /// Open and validate a complete snapshot. Missing files are errors. - pub fn new(file_path: impl AsRef) -> Result { - let source = Self { - file_path: file_path.as_ref().to_path_buf(), - quotes: RwLock::new(HashMap::new()), - }; - source.reload()?; - Ok(source) - } - - /// Parse the entire bounded file before replacing the snapshot. Failure leaves prior data intact. - pub fn reload(&self) -> Result<(), TraitError> { - let bytes = read_file(&self.file_path)?; - let mut reader = csv::Reader::from_reader(bytes.as_slice()); - csv_headers( - &mut reader, - &["instrument_id", "timestamp", "source"], - &[ - "instrument_id", - "bid_price", - "ask_price", - "mid_price", - "last_price", - "timestamp", - "source", - ], - )?; - let mut quotes = HashMap::new(); - for record in reader.deserialize() { - let record: QuoteRecord = record.map_err(|e| TraitError::ParseError(e.to_string()))?; - if quotes.len() >= MAX_RECORDS { - return Err(TraitError::CapacityExceeded); - } - metadata(&record.instrument_id, &record.source, record.timestamp)?; - let quote = RawQuote { - instrument_id: InstrumentId::new(record.instrument_id), - bid_price: record.bid_price.as_deref().map(decimal).transpose()?, - ask_price: record.ask_price.as_deref().map(decimal).transpose()?, - mid_price: record.mid_price.as_deref().map(decimal).transpose()?, - last_price: record.last_price.as_deref().map(decimal).transpose()?, - bid_yield: None, - ask_yield: None, - bid_size: None, - ask_size: None, - timestamp: record.timestamp, - source: record.source, - venue: None, - }; - if quote - .bid_price - .zip(quote.ask_price) - .is_some_and(|(bid, ask)| bid > ask) - { - return Err(TraitError::InvalidInput("crossed CSV quote")); - } - if quotes.insert(quote.instrument_id.clone(), quote).is_some() { - return Err(TraitError::Conflict("duplicate quote key")); - } - } - *self.quotes.write().map_err(lock_error)? = quotes; - Ok(()) - } -} - -#[async_trait] -impl QuoteSource for CsvQuoteSource { - fn source_type(&self) -> SourceType { - SourceType::File - } - async fn get_quote( - &self, - instrument_id: &InstrumentId, - ) -> Result, TraitError> { - Ok(self - .quotes - .read() - .map_err(lock_error)? - .get(instrument_id) - .cloned()) - } - async fn get_quotes( - &self, - instrument_ids: &[InstrumentId], - ) -> Result, TraitError> { - if instrument_ids.len() > MAX_RECORDS { - return Err(TraitError::CapacityExceeded); - } - let quotes = self.quotes.read().map_err(lock_error)?; - Ok(instrument_ids - .iter() - .filter_map(|id| quotes.get(id).cloned()) - .collect()) - } - async fn subscribe( - &self, - _ids: &[InstrumentId], - ) -> Result, TraitError> { - Err(TraitError::Unsupported( - "snapshot source; use FileQuoteReplay for sequenced events", - )) - } - async fn unsubscribe(&self, _ids: &[InstrumentId]) -> Result<(), TraitError> { - Ok(()) - } -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct CurveRecord { - curve_id: String, - inputs: Vec, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct InputRecord { - tenor: String, - rate: String, - instrument_type: CurveInstrumentType, - timestamp: i64, - source: String, -} - -/// JSON curve inputs with required instrument type, exact rate string and recorded time. -pub struct JsonCurveInputSource { - file_path: PathBuf, - inputs: RwLock>>, -} - -impl JsonCurveInputSource { - /// Open and validate a complete curve-input snapshot. - pub fn new(file_path: impl AsRef) -> Result { - let source = Self { - file_path: file_path.as_ref().to_path_buf(), - inputs: RwLock::new(HashMap::new()), - }; - source.reload()?; - Ok(source) - } - /// Replace all inputs atomically; no silent drops or invented Swap instrument types. - pub fn reload(&self) -> Result<(), TraitError> { - let curves: Vec = serde_json::from_slice(&read_file(&self.file_path)?) - .map_err(|e| TraitError::ParseError(e.to_string()))?; - if curves.len() > MAX_RECORDS { - return Err(TraitError::CapacityExceeded); - } - let mut all = HashMap::new(); - let mut total = 0; - for curve in curves { - validate_text(&curve.curve_id, 128)?; - if curve.inputs.len() > MAX_CURVE_INPUTS { - return Err(TraitError::CapacityExceeded); - } - total += curve.inputs.len(); - if total > MAX_RECORDS { - return Err(TraitError::CapacityExceeded); - } - let id = CurveId::new(curve.curve_id); - let mut inputs = Vec::new(); - for input in curve.inputs { - metadata(id.as_str(), &input.source, input.timestamp)?; - let tenor = tenor(&input.tenor)?; - match &input.instrument_type { - CurveInstrumentType::Future { contract_code } => { - validate_text(contract_code, 128)? - } - CurveInstrumentType::Fra { start_tenor } => validate_tenor(start_tenor)?, - CurveInstrumentType::BondYield { instrument_id } => { - validate_text(instrument_id.as_str(), 128)? - } - _ => {} - } - inputs.push(CurveInput { - curve_id: id.clone(), - tenor, - rate: decimal(&input.rate)?, - instrument_type: input.instrument_type, - timestamp: input.timestamp, - source: input.source, - }); - } - if all.insert(id, inputs).is_some() { - return Err(TraitError::Conflict("duplicate curve key")); - } - } - *self.inputs.write().map_err(lock_error)? = all; - Ok(()) - } -} - -#[async_trait] -impl CurveInputSource for JsonCurveInputSource { - fn source_type(&self) -> SourceType { - SourceType::File - } - async fn get_curve_inputs(&self, id: &CurveId) -> Result, TraitError> { - Ok(self - .inputs - .read() - .map_err(lock_error)? - .get(id) - .cloned() - .unwrap_or_default()) - } - async fn subscribe(&self, _ids: &[CurveId]) -> Result, TraitError> { - Err(TraitError::Unsupported( - "file curve input source is snapshot-only", - )) - } -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct FixingRecord { - index: String, - date: String, - rate: String, - timestamp: i64, - source: String, -} - -/// CSV fixing snapshots with actual date/index/rate records. Revisions require a replacement snapshot. -pub struct CsvIndexFixingSource { - file_path: PathBuf, - fixings: RwLock>, -} - -fn tenor(value: &str) -> Result { - // Guard the existing tenor parser's byte split and arithmetic at this external boundary. - if value.len() < 2 || value.len() > 12 || !value.is_ascii() { - return Err(TraitError::InvalidInput("invalid market tenor")); - } - let tenor = Tenor::parse(value).map_err(TraitError::ParseError)?; - validate_tenor(&tenor)?; - Ok(tenor) -} - -fn validate_tenor(tenor: &Tenor) -> Result<(), TraitError> { - let valid = match *tenor { - Tenor::Days(n) => n > 0 && n <= 365_000, - Tenor::Weeks(n) => n > 0 && n <= 52_000, - Tenor::Months(n) => n > 0 && n <= 12_000, - Tenor::Years(n) => n > 0 && n <= 1_000, - }; - if !valid { - return Err(TraitError::InvalidInput("market tenor out of bounds")); - } - Ok(()) -} - -fn index(value: &str) -> Result { - validate_text(value, 128)?; - match value { - "SOFR" => Ok(FloatingRateIndex::Sofr), - "ESTR" => Ok(FloatingRateIndex::Estr), - "SONIA" => Ok(FloatingRateIndex::Sonia), - _ if value.starts_with("EURIBOR-") => Ok(FloatingRateIndex::Euribor(tenor(&value[8..])?)), - _ if value.starts_with("TERM-SOFR-") => { - Ok(FloatingRateIndex::TermSofr(tenor(&value[10..])?)) - } - _ => Ok(FloatingRateIndex::Other(value.to_owned())), - } -} - -impl CsvIndexFixingSource { - /// Open and validate fixing records. Missing files are errors. - pub fn new(file_path: impl AsRef) -> Result { - let source = Self { - file_path: file_path.as_ref().to_path_buf(), - fixings: RwLock::new(HashMap::new()), - }; - source.reload()?; - Ok(source) - } - /// Reload atomically; duplicate index/date records and invalid records fail. - pub fn reload(&self) -> Result<(), TraitError> { - let bytes = read_file(&self.file_path)?; - let mut reader = csv::Reader::from_reader(bytes.as_slice()); - csv_headers( - &mut reader, - &["index", "date", "rate", "timestamp", "source"], - &["index", "date", "rate", "timestamp", "source"], - )?; - let mut fixings = HashMap::new(); - for record in reader.deserialize() { - let record: FixingRecord = record.map_err(|e| TraitError::ParseError(e.to_string()))?; - if fixings.len() >= MAX_RECORDS { - return Err(TraitError::CapacityExceeded); - } - metadata(&record.index, &record.source, record.timestamp)?; - let index = index(&record.index)?; - let date = - Date::parse(&record.date).map_err(|e| TraitError::ParseError(e.to_string()))?; - let fixing = IndexFixing { - index: index.clone(), - date, - rate: decimal(&record.rate)?, - source: record.source, - timestamp: record.timestamp, - }; - if fixings.insert((index.to_string(), date), fixing).is_some() { - return Err(TraitError::Conflict("duplicate fixing index/date")); - } - } - *self.fixings.write().map_err(lock_error)? = fixings; - Ok(()) - } -} - -#[async_trait] -impl IndexFixingSource for CsvIndexFixingSource { - fn source_type(&self) -> SourceType { - SourceType::File - } - async fn get_fixing( - &self, - index: &FloatingRateIndex, - date: Date, - ) -> Result, TraitError> { - Ok(self - .fixings - .read() - .map_err(lock_error)? - .get(&(index.to_string(), date)) - .cloned()) - } - async fn get_fixings( - &self, - index: &FloatingRateIndex, - from: Date, - to: Date, - ) -> Result, TraitError> { - if from > to { - return Err(TraitError::InvalidInput("inverted fixing date range")); - } - let fixings = self.fixings.read().map_err(lock_error)?; - let mut result: Vec<_> = fixings - .values() - .filter(|f| &f.index == index && f.date >= from && f.date <= to) - .cloned() - .collect(); - result.sort_by_key(|f| f.date); - Ok(result) - } - async fn subscribe( - &self, - _indices: &[FloatingRateIndex], - ) -> Result, TraitError> { - Err(TraitError::Unsupported( - "file fixing source is snapshot-only", - )) - } -} - -// ============================================================================= -// EMPTY IMPLEMENTATIONS -// ============================================================================= - -/// Empty volatility source. -pub struct EmptyVolatilitySource; - -#[async_trait] -impl VolatilitySource for EmptyVolatilitySource { - async fn get_surface( - &self, - _surface_id: &VolSurfaceId, - ) -> Result, TraitError> { - Ok(None) - } - - async fn get_atm_vol( - &self, - _surface_id: &VolSurfaceId, - _expiry: Tenor, - _underlying_tenor: Option, - ) -> Result, TraitError> { - Ok(None) - } - - async fn get_vol( - &self, - _surface_id: &VolSurfaceId, - _expiry: Tenor, - _underlying_tenor: Option, - _strike: Option, - ) -> Result, TraitError> { - Ok(None) - } - - async fn subscribe( - &self, - _surface_ids: &[VolSurfaceId], - ) -> Result, TraitError> { - Err(TraitError::SourceNotAvailable("Not implemented".into())) - } -} - -/// Empty FX rate source. -pub struct EmptyFxRateSource; - -#[async_trait] -impl FxRateSource for EmptyFxRateSource { - async fn get_spot(&self, _pair: &CurrencyPair) -> Result, TraitError> { - Ok(None) - } - - async fn get_spot_triangulated( - &self, - _base: convex_core::Currency, - _quote: convex_core::Currency, - ) -> Result, TraitError> { - Ok(None) - } - - async fn get_forward( - &self, - _pair: &CurrencyPair, - _tenor: Tenor, - ) -> Result, TraitError> { - Ok(None) - } - - async fn subscribe( - &self, - _pairs: &[CurrencyPair], - ) -> Result, TraitError> { - Err(TraitError::SourceNotAvailable("Not implemented".into())) - } -} - -/// Empty inflation fixing source. -pub struct EmptyInflationFixingSource; - -#[async_trait] -impl InflationFixingSource for EmptyInflationFixingSource { - async fn get_fixing( - &self, - _index: &InflationIndex, - _month: YearMonth, - ) -> Result, TraitError> { - Ok(None) - } - - async fn get_latest_fixing( - &self, - _index: &InflationIndex, - ) -> Result, TraitError> { - Ok(None) - } - - async fn get_fixings_range( - &self, - _index: &InflationIndex, - _from: YearMonth, - _to: YearMonth, - ) -> Result, TraitError> { - Ok(vec![]) - } - - async fn get_interpolated_index( - &self, - _index: &InflationIndex, - _date: Date, - _interpolation: InflationInterpolation, - ) -> Result, TraitError> { - Ok(None) - } - - async fn subscribe( - &self, - _indices: &[InflationIndex], - ) -> Result, TraitError> { - Err(TraitError::SourceNotAvailable("Not implemented".into())) - } -} - -/// Empty ETF quote source. -pub struct EmptyEtfQuoteSource; - -#[async_trait] -impl EtfQuoteSource for EmptyEtfQuoteSource { - async fn get_quote(&self, _etf_id: &EtfId) -> Result, TraitError> { - Ok(None) - } - - async fn subscribe(&self, _etf_ids: &[EtfId]) -> Result, TraitError> { - Err(TraitError::SourceNotAvailable("Not implemented".into())) - } -} - -/// Empty quote source for testing. -pub struct EmptyQuoteSource; - -#[async_trait] -impl QuoteSource for EmptyQuoteSource { - fn source_type(&self) -> SourceType { - SourceType::Manual - } - - async fn get_quote( - &self, - _instrument_id: &InstrumentId, - ) -> Result, TraitError> { - Ok(None) - } - - async fn get_quotes( - &self, - _instrument_ids: &[InstrumentId], - ) -> Result, TraitError> { - Ok(vec![]) - } - - async fn subscribe( - &self, - _instrument_ids: &[InstrumentId], - ) -> Result, TraitError> { - Err(TraitError::SourceNotAvailable("Not implemented".into())) - } - - async fn unsubscribe(&self, _instrument_ids: &[InstrumentId]) -> Result<(), TraitError> { - Ok(()) - } -} - -/// Empty curve input source for testing. -pub struct EmptyCurveInputSource; - -#[async_trait] -impl CurveInputSource for EmptyCurveInputSource { - fn source_type(&self) -> SourceType { - SourceType::Manual - } - - async fn get_curve_inputs(&self, _curve_id: &CurveId) -> Result, TraitError> { - Ok(vec![]) - } - - async fn subscribe( - &self, - _curve_ids: &[CurveId], - ) -> Result, TraitError> { - Err(TraitError::SourceNotAvailable("Not implemented".into())) - } -} - -/// Empty index fixing source for testing. -pub struct EmptyIndexFixingSource; - -#[async_trait] -impl IndexFixingSource for EmptyIndexFixingSource { - fn source_type(&self) -> SourceType { - SourceType::Manual - } - - async fn get_fixing( - &self, - _index: &FloatingRateIndex, - _date: Date, - ) -> Result, TraitError> { - Ok(None) - } - - async fn get_fixings( - &self, - _index: &FloatingRateIndex, - _from: Date, - _to: Date, - ) -> Result, TraitError> { - Ok(vec![]) - } - - async fn subscribe( - &self, - _indices: &[FloatingRateIndex], - ) -> Result, TraitError> { - Err(TraitError::SourceNotAvailable("Not implemented".into())) - } -} diff --git a/crates/convex-ext-file/src/reference_data.rs b/crates/convex-ext-file/src/reference_data.rs deleted file mode 100644 index efe814f5..00000000 --- a/crates/convex-ext-file/src/reference_data.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! File-based reference data sources. - -use async_trait::async_trait; - -use convex_core::ids::*; -use convex_core::Date; -use convex_ports::error::TraitError; -use convex_ports::reference_data::*; - -// ============================================================================= -// EMPTY IMPLEMENTATIONS -// ============================================================================= - -/// Empty issuer reference source. -pub struct EmptyIssuerReferenceSource; - -#[async_trait] -impl IssuerReferenceSource for EmptyIssuerReferenceSource { - async fn get_issuer(&self, _issuer_id: &str) -> Result, TraitError> { - Ok(None) - } - - async fn get_by_lei(&self, _lei: &str) -> Result, TraitError> { - Ok(None) - } - - async fn search(&self, _query: &str, _limit: usize) -> Result, TraitError> { - Ok(vec![]) - } - - async fn get_by_sector(&self, _sector: &str) -> Result, TraitError> { - Ok(vec![]) - } -} - -/// Empty rating source. -pub struct EmptyRatingSource; - -#[async_trait] -impl RatingSource for EmptyRatingSource { - async fn get_rating( - &self, - _issuer_id: &str, - _agency: RatingAgency, - ) -> Result, TraitError> { - Ok(None) - } - - async fn get_all_ratings(&self, _issuer_id: &str) -> Result, TraitError> { - Ok(vec![]) - } - - async fn get_composite_rating( - &self, - _issuer_id: &str, - ) -> Result, TraitError> { - Ok(None) - } - - async fn get_rating_history( - &self, - _issuer_id: &str, - _agency: RatingAgency, - _limit: usize, - ) -> Result, TraitError> { - Ok(vec![]) - } -} - -/// Empty ETF holdings source. -pub struct EmptyEtfHoldingsSource; - -#[async_trait] -impl EtfHoldingsSource for EmptyEtfHoldingsSource { - async fn get_holdings(&self, _etf_id: &EtfId) -> Result, TraitError> { - Ok(None) - } - - async fn get_holdings_as_of( - &self, - _etf_id: &EtfId, - _as_of_date: Date, - ) -> Result, TraitError> { - Ok(None) - } - - async fn list_etfs(&self) -> Result, TraitError> { - Ok(vec![]) - } - - async fn subscribe(&self, _etf_ids: &[EtfId]) -> Result { - Err(TraitError::SourceNotAvailable("Not implemented".into())) - } -} diff --git a/crates/convex-ext-file/tests/market_snapshots.rs b/crates/convex-ext-file/tests/market_snapshots.rs deleted file mode 100644 index 645e847e..00000000 --- a/crates/convex-ext-file/tests/market_snapshots.rs +++ /dev/null @@ -1,160 +0,0 @@ -use convex_core::{ids::*, Date}; -use convex_ext_file::{CsvIndexFixingSource, CsvQuoteSource, JsonCurveInputSource}; -use convex_ports::market_data::*; -use rust_decimal::Decimal; - -const HEADER: &str = "instrument_id,bid_price,ask_price,mid_price,last_price,timestamp,source\n"; - -#[tokio::test] -async fn quote_snapshots_are_exact_and_atomic_and_reload_removes_absent_keys() { - let file = tempfile::NamedTempFile::new().unwrap(); - std::fs::write( - file.path(), - format!( - "{HEADER}A,99.12345678901234567890123456,101,100,,1000,file\nB,98,100,99,,1000,file\n" - ), - ) - .unwrap(); - let source = CsvQuoteSource::new(file.path()).unwrap(); - let before = source.get_quote(&"A".into()).await.unwrap().unwrap(); - assert_eq!( - before.bid_price.unwrap().to_string(), - "99.12345678901234567890123456" - ); - assert_eq!(before.timestamp, 1000); - for invalid in [ - format!("{HEADER}A,97,99,98,,1100,file\nB,NaN,100,99,,1100,file\n"), - format!("{HEADER}A,102,101,100,,1100,file\n"), - format!("{HEADER}A,99,101,100,,1100,file\nA,98,100,99,,1100,file\n"), - "instrument_id,bid_price,ask_price\nA,99,101\n".into(), - ] { - std::fs::write(file.path(), invalid).unwrap(); - assert!(source.reload().is_err()); - assert_eq!( - source - .get_quote(&"A".into()) - .await - .unwrap() - .unwrap() - .bid_price, - before.bid_price - ); - assert!(source.get_quote(&"B".into()).await.unwrap().is_some()); - } - std::fs::write(file.path(), format!("{HEADER}A,98,100,99,,2000,file\n")).unwrap(); - source.reload().unwrap(); - assert!(source.get_quote(&"B".into()).await.unwrap().is_none()); - assert_eq!( - source - .get_quote(&"A".into()) - .await - .unwrap() - .unwrap() - .timestamp, - 2000 - ); - std::fs::remove_file(file.path()).unwrap(); - assert!(source.reload().is_err()); - assert_eq!( - source - .get_quote(&"A".into()) - .await - .unwrap() - .unwrap() - .timestamp, - 2000 - ); -} - -#[tokio::test] -async fn curve_inputs_require_explicit_terms_time_and_exact_rates_and_never_drop_bad_rows() { - let file = tempfile::NamedTempFile::new().unwrap(); - let valid = r#"[{"curve_id":"USD.TEST","inputs":[{"tenor":"2Y","rate":"-0.0012345678901234567890123456","instrument_type":"Swap","timestamp":1000,"source":"fixture"}]}]"#; - std::fs::write(file.path(), valid).unwrap(); - let source = JsonCurveInputSource::new(file.path()).unwrap(); - let before = source.get_curve_inputs(&"USD.TEST".into()).await.unwrap(); - assert_eq!(before.len(), 1); - assert_eq!( - before[0].rate.to_string(), - "-0.0012345678901234567890123456" - ); - assert_eq!(before[0].timestamp, 1000); - for invalid in [ - valid.replace("2Y", "nonsense"), - valid.replace("2Y", "0Y"), - valid.replace("2Y", "💥"), - valid.replace("\"-0.0012345678901234567890123456\"", "0.04"), - valid.replace("\"instrument_type\":\"Swap\",", ""), - ] { - std::fs::write(file.path(), invalid).unwrap(); - assert!(source.reload().is_err()); - assert_eq!( - source.get_curve_inputs(&"USD.TEST".into()).await.unwrap()[0].rate, - before[0].rate - ); - } - std::fs::write(file.path(), "[]").unwrap(); - source.reload().unwrap(); - assert!(source - .get_curve_inputs(&"USD.TEST".into()) - .await - .unwrap() - .is_empty()); -} - -#[tokio::test] -async fn actual_fixing_records_preserve_date_order_negative_rates_and_fail_atomically() { - let file = tempfile::NamedTempFile::new().unwrap(); - let header = "index,date,rate,timestamp,source\n"; - std::fs::write(file.path(), format!("{header}SOFR,2026-10-02,0.04,2000,file\nSOFR,2026-10-01,-0.001,1000,file\nEURIBOR-3M,2026-10-01,0.03,1000,file\n")).unwrap(); - let source = CsvIndexFixingSource::new(file.path()).unwrap(); - let from = Date::parse("2026-10-01").unwrap(); - let to = Date::parse("2026-10-02").unwrap(); - let fixings = source - .get_fixings(&FloatingRateIndex::Sofr, from, to) - .await - .unwrap(); - assert_eq!(fixings.len(), 2); - assert_eq!( - (fixings[0].date, fixings[0].rate, fixings[0].timestamp), - (from, Decimal::new(-1, 3), 1000) - ); - assert!(source - .get_fixing(&FloatingRateIndex::Euribor(Tenor::Months(3)), from) - .await - .unwrap() - .is_some()); - assert!(source - .get_fixings(&FloatingRateIndex::Sofr, to, from) - .await - .is_err()); - for invalid in [ - format!("{header}SOFR,2026-10-01,0.1,3000,file\nSOFR,bad,0.04,3000,file\n"), - format!("{header}SOFR,2026-10-01,0.1,3000,file\nSOFR,2026-10-01,0.2,3000,file\n"), - ] { - std::fs::write(file.path(), invalid).unwrap(); - assert!(source.reload().is_err()); - assert_eq!( - source - .get_fixing(&FloatingRateIndex::Sofr, from) - .await - .unwrap() - .unwrap() - .rate, - fixings[0].rate - ); - } -} - -#[test] -fn missing_and_oversized_snapshot_files_fail_explicitly() { - let dir = tempfile::tempdir().unwrap(); - assert!(CsvQuoteSource::new(dir.path().join("missing")).is_err()); - assert!(JsonCurveInputSource::new(dir.path().join("missing")).is_err()); - assert!(CsvIndexFixingSource::new(dir.path().join("missing")).is_err()); - let file = tempfile::NamedTempFile::new().unwrap(); - file.as_file().set_len(16 * 1024 * 1024 + 1).unwrap(); - assert!(CsvQuoteSource::new(file.path()).is_err()); - assert!(JsonCurveInputSource::new(file.path()).is_err()); - assert!(CsvIndexFixingSource::new(file.path()).is_err()); -} diff --git a/crates/convex-ports/src/market_data.rs b/crates/convex-ports/src/market_data.rs index 0afd323f..b6192310 100644 --- a/crates/convex-ports/src/market_data.rs +++ b/crates/convex-ports/src/market_data.rs @@ -1,14 +1,9 @@ -//! Market data source traits. +//! Live quote source port. //! -//! These traits define interfaces for market data providers: -//! - [`QuoteSource`]: Bond quotes (bid/ask/last) -//! - [`CurveInputSource`]: Curve inputs (deposits, swaps, futures) -//! - [`IndexFixingSource`]: Floating rate index fixings -//! - [`VolatilitySource`]: Volatility surfaces -//! - [`FxRateSource`]: FX spot and forward rates -//! - [`InflationFixingSource`]: Inflation index fixings -//! -//! All sources support both snapshot (request/response) and streaming modes. +//! [`QuoteSource`] supplies bond quotes to the Live runtime, by snapshot or +//! subscription. Curve inputs, fixings and other market inputs reach valuation +//! through stored recipes, the fixing repository and snapshots, not through +//! source traits. use async_trait::async_trait; use rust_decimal::Decimal; @@ -17,7 +12,6 @@ use serde::{Deserialize, Serialize}; use crate::error::TraitError; pub use crate::quote_event::*; use convex_core::ids::*; -use convex_core::Date; /// Source type for market data. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -132,515 +126,3 @@ pub trait QuoteSource: Send + Sync { /// Unsubscribe from quote updates. async fn unsubscribe(&self, instrument_ids: &[InstrumentId]) -> Result<(), TraitError>; } - -// ============================================================================= -// CURVE INPUT SOURCE -// ============================================================================= - -/// Curve instrument type. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub enum CurveInstrumentType { - /// Money market deposit - Deposit, - /// Interest rate future - Future { - /// Contract code - contract_code: String, - }, - /// Forward rate agreement - Fra { - /// Start tenor - start_tenor: Tenor, - }, - /// Interest rate swap - Swap, - /// Basis swap - BasisSwap, - /// OIS swap - OisSwap, - /// Cross-currency swap - CrossCurrencySwap, - /// Bond yield - BondYield { - /// Bond instrument ID - instrument_id: InstrumentId, - }, -} - -/// Input for curve building. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CurveInput { - /// Curve identifier - pub curve_id: CurveId, - /// Instrument type - pub instrument_type: CurveInstrumentType, - /// Tenor - pub tenor: Tenor, - /// Rate value - pub rate: Decimal, - /// Timestamp - pub timestamp: i64, - /// Source - pub source: String, -} - -/// Trait for curve input providers. -#[async_trait] -pub trait CurveInputSource: Send + Sync { - /// Source type. - fn source_type(&self) -> SourceType; - - /// Get all inputs for a curve. - async fn get_curve_inputs(&self, curve_id: &CurveId) -> Result, TraitError>; - - /// Subscribe to curve input updates. - async fn subscribe( - &self, - curve_ids: &[CurveId], - ) -> Result, TraitError>; -} - -// ============================================================================= -// INDEX FIXING SOURCE -// ============================================================================= - -/// Index fixing (SOFR, EURIBOR, etc.). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IndexFixing { - /// Index identifier - pub index: FloatingRateIndex, - /// Fixing date - pub date: Date, - /// Rate value - pub rate: Decimal, - /// Source - pub source: String, - /// Timestamp - pub timestamp: i64, -} - -/// Trait for index fixing providers. -#[async_trait] -pub trait IndexFixingSource: Send + Sync { - /// Source type. - fn source_type(&self) -> SourceType; - - /// Get fixing for a specific date. - async fn get_fixing( - &self, - index: &FloatingRateIndex, - date: Date, - ) -> Result, TraitError>; - - /// Get historical fixings. - async fn get_fixings( - &self, - index: &FloatingRateIndex, - from: Date, - to: Date, - ) -> Result, TraitError>; - - /// Subscribe to new fixings. - async fn subscribe( - &self, - indices: &[FloatingRateIndex], - ) -> Result, TraitError>; -} - -// ============================================================================= -// VOLATILITY SOURCE -// ============================================================================= - -/// Volatility surface type. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum VolSurfaceType { - /// Swaption volatility surface (expiry x underlying tenor) - Swaption, - /// Cap/floor volatility curve - CapFloor, - /// Yield volatility - YieldVol, -} - -/// Vol quote type. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum VolQuoteType { - /// Normal (basis point) volatility - Normal, - /// Lognormal (Black) volatility - Lognormal, - /// SABR parameters - Sabr { - /// Alpha - alpha: Decimal, - /// Beta - beta: Decimal, - /// Rho - rho: Decimal, - /// Nu - nu: Decimal, - }, -} - -/// Volatility point. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VolPoint { - /// Option expiry - pub expiry: Tenor, - /// Underlying tenor (for swaptions) - pub underlying_tenor: Option, - /// Strike (None = ATM) - pub strike: Option, - /// Volatility value - pub vol: Decimal, - /// Timestamp - pub timestamp: i64, -} - -/// Volatility surface. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VolatilitySurface { - /// Surface identifier - pub surface_id: VolSurfaceId, - /// Currency - pub currency: convex_core::Currency, - /// Surface type - pub surface_type: VolSurfaceType, - /// As-of timestamp - pub as_of: i64, - /// Volatility points - pub points: Vec, - /// Quote type - pub quote_type: VolQuoteType, - /// Source - pub source: String, -} - -/// Trait for volatility data providers. -#[async_trait] -pub trait VolatilitySource: Send + Sync { - /// Get full volatility surface. - async fn get_surface( - &self, - surface_id: &VolSurfaceId, - ) -> Result, TraitError>; - - /// Get ATM vol for specific expiry/tenor. - async fn get_atm_vol( - &self, - surface_id: &VolSurfaceId, - expiry: Tenor, - underlying_tenor: Option, - ) -> Result, TraitError>; - - /// Get interpolated vol for any point. - async fn get_vol( - &self, - surface_id: &VolSurfaceId, - expiry: Tenor, - underlying_tenor: Option, - strike: Option, - ) -> Result, TraitError>; - - /// Subscribe to vol updates. - async fn subscribe( - &self, - surface_ids: &[VolSurfaceId], - ) -> Result, TraitError>; -} - -// ============================================================================= -// FX RATE SOURCE -// ============================================================================= - -/// FX rate quote. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FxRate { - /// Currency pair - pub pair: CurrencyPair, - /// Bid rate - pub bid: Option, - /// Ask rate - pub ask: Option, - /// Mid rate - pub mid: Decimal, - /// Timestamp - pub timestamp: i64, - /// Source - pub source: String, -} - -/// Trait for FX rate providers. -#[async_trait] -pub trait FxRateSource: Send + Sync { - /// Get spot rate for currency pair. - async fn get_spot(&self, pair: &CurrencyPair) -> Result, TraitError>; - - /// Get spot rate with triangulation if direct pair unavailable. - async fn get_spot_triangulated( - &self, - base: convex_core::Currency, - quote: convex_core::Currency, - ) -> Result, TraitError>; - - /// Get forward rate for tenor. - async fn get_forward( - &self, - pair: &CurrencyPair, - tenor: Tenor, - ) -> Result, TraitError>; - - /// Subscribe to FX rate updates. - async fn subscribe(&self, pairs: &[CurrencyPair]) - -> Result, TraitError>; -} - -// ============================================================================= -// INFLATION FIXING SOURCE -// ============================================================================= - -/// Inflation interpolation method. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub enum InflationInterpolation { - /// 3-month lag linear (standard for TIPS) - ThreeMonthLagLinear, - /// 2-month lag linear - TwoMonthLagLinear, - /// Flat (use latest fixing) - Flat, -} - -/// Inflation index fixing. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InflationFixing { - /// Index identifier - pub index: InflationIndex, - /// Reference month - pub reference_month: YearMonth, - /// Index value - pub value: Decimal, - /// Publication date - pub publication_date: Date, - /// Is preliminary - pub is_preliminary: bool, - /// Source - pub source: String, -} - -/// Trait for inflation fixing providers. -#[async_trait] -pub trait InflationFixingSource: Send + Sync { - /// Get fixing for specific month. - async fn get_fixing( - &self, - index: &InflationIndex, - month: YearMonth, - ) -> Result, TraitError>; - - /// Get latest available fixing. - async fn get_latest_fixing( - &self, - index: &InflationIndex, - ) -> Result, TraitError>; - - /// Get range of fixings. - async fn get_fixings_range( - &self, - index: &InflationIndex, - from: YearMonth, - to: YearMonth, - ) -> Result, TraitError>; - - /// Get interpolated index value for a date. - async fn get_interpolated_index( - &self, - index: &InflationIndex, - date: Date, - interpolation: InflationInterpolation, - ) -> Result, TraitError>; - - /// Subscribe to new fixings. - async fn subscribe( - &self, - indices: &[InflationIndex], - ) -> Result, TraitError>; -} - -// ============================================================================= -// ETF QUOTE SOURCE -// ============================================================================= - -/// ETF quote (NAV, iNAV, price). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EtfQuote { - /// ETF identifier - pub etf_id: EtfId, - /// Net Asset Value - pub nav: Option, - /// Indicative NAV - pub inav: Option, - /// Last traded price - pub price: Option, - /// Premium/discount to NAV - pub premium_discount: Option, - /// Timestamp - pub timestamp: i64, - /// Source - pub source: String, -} - -/// Trait for ETF quote providers. -#[async_trait] -pub trait EtfQuoteSource: Send + Sync { - /// Get current ETF quote. - async fn get_quote(&self, etf_id: &EtfId) -> Result, TraitError>; - - /// Subscribe to ETF quote updates. - async fn subscribe(&self, etf_ids: &[EtfId]) -> Result, TraitError>; -} - -// ============================================================================= -// BOND QUOTE FOR PRICING -// ============================================================================= - -/// Bond quote for pricing purposes. -/// -/// Simplified view of market data specifically for pricing calculations. -/// Contains bid/mid/ask prices and yields. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BondQuote { - /// Instrument identifier - pub instrument_id: InstrumentId, - /// Bid price (clean) - pub bid_price: Option, - /// Mid price (clean) - pub mid_price: Option, - /// Ask price (clean) - pub ask_price: Option, - /// Bid yield - pub bid_yield: Option, - /// Mid yield - pub mid_yield: Option, - /// Ask yield - pub ask_yield: Option, - /// Timestamp of the quote - pub timestamp: i64, - /// Source of the quote - pub source: String, - /// Is the quote stale (based on time or source status) - pub is_stale: bool, -} - -impl BondQuote { - /// Create from RawQuote. - pub fn from_raw(raw: &RawQuote) -> Self { - Self { - instrument_id: raw.instrument_id.clone(), - bid_price: raw.bid_price, - mid_price: raw.mid_price, - ask_price: raw.ask_price, - bid_yield: raw.bid_yield, - // Derive mid yield if not available - mid_yield: None, // Will be calculated by pricing - ask_yield: raw.ask_yield, - timestamp: raw.timestamp, - source: raw.source.clone(), - is_stale: false, - } - } - - /// Get price for the specified side. - pub fn price_for_side(&self, side: crate::storage::QuoteSide) -> Option { - use crate::storage::QuoteSide; - match side { - QuoteSide::Bid => self.bid_price, - QuoteSide::Mid => self.mid_price, - QuoteSide::Ask => self.ask_price, - } - } - - /// Get yield for the specified side. - pub fn yield_for_side(&self, side: crate::storage::QuoteSide) -> Option { - use crate::storage::QuoteSide; - match side { - QuoteSide::Bid => self.bid_yield, - QuoteSide::Mid => self.mid_yield, - QuoteSide::Ask => self.ask_yield, - } - } -} - -/// Curve data for pricing (zero rates at tenors). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CurveData { - /// Curve identifier - pub curve_id: CurveId, - /// As-of date - pub as_of_date: Date, - /// Curve points (tenor in days -> zero rate as decimal) - pub points: Vec<(u32, Decimal)>, - /// Timestamp - pub timestamp: i64, - /// Source - pub source: String, -} - -impl CurveData { - /// Get zero rate at a specific tenor (interpolated). - pub fn zero_rate_at_days(&self, days: u32) -> Option { - if self.points.is_empty() { - return None; - } - - // Find surrounding points - let mut prev = None; - let mut next = None; - - for (tenor, rate) in &self.points { - if *tenor == days { - return Some(*rate); - } - if *tenor < days { - prev = Some((*tenor, *rate)); - } else if next.is_none() { - next = Some((*tenor, *rate)); - } - } - - // Linear interpolation - match (prev, next) { - (Some((t1, r1)), Some((t2, r2))) => { - let weight = Decimal::from(days - t1) / Decimal::from(t2 - t1); - Some(r1 + weight * (r2 - r1)) - } - (Some((_, r)), None) => Some(r), // Extrapolate flat - (None, Some((_, r))) => Some(r), // Extrapolate flat - (None, None) => None, - } - } -} - -// ============================================================================= -// COMPOSITE MARKET DATA PROVIDER -// ============================================================================= - -use std::sync::Arc; - -/// Combined market data provider (concrete struct holding all sources). -pub struct MarketDataProvider { - /// Quote source - pub quotes: Arc, - /// Curve input source - pub curve_inputs: Arc, - /// Index fixing source - pub index_fixings: Arc, - /// Volatility source - pub volatility: Arc, - /// FX rate source - pub fx_rates: Arc, - /// Inflation fixing source - pub inflation_fixings: Arc, - /// ETF quote source - pub etf_quotes: Arc, -} diff --git a/crates/convex-ports/src/reference_data.rs b/crates/convex-ports/src/reference_data.rs index 8ec2546d..d6d9bbbf 100644 --- a/crates/convex-ports/src/reference_data.rs +++ b/crates/convex-ports/src/reference_data.rs @@ -1,18 +1,11 @@ -//! Reference data source traits. +//! Reference data types. //! -//! These traits define interfaces for reference data providers: //! Bond terms are persisted through [`crate::reference_repository::ReferenceRepository`]. -//! - [`IssuerReferenceSource`]: Issuer information -//! - [`RatingSource`]: Credit ratings -//! - [`EtfHoldingsSource`]: ETF holdings and constituents -//! -//! Reference data is static/semi-static (updated daily, not real-time). +//! [`EtfHoldings`] are supplied with each ETF request. -use async_trait::async_trait; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -use crate::error::TraitError; use convex_core::ids::*; use convex_core::{Currency, Date}; @@ -253,125 +246,6 @@ impl BondReferenceData { } } -// ============================================================================= -// ISSUER REFERENCE DATA -// ============================================================================= - -/// Issuer information. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IssuerInfo { - /// Issuer identifier - pub issuer_id: String, - /// Legal entity identifier (LEI) - pub lei: Option, - /// Issuer name - pub name: String, - /// Short name/ticker - pub short_name: Option, - /// Parent company ID - pub parent_id: Option, - /// Ultimate parent ID - pub ultimate_parent_id: Option, - /// Country of incorporation - pub country: String, - /// Sector - pub sector: String, - /// Industry group - pub industry: String, - /// Is sovereign - pub is_sovereign: bool, - /// Is financial - pub is_financial: bool, - /// Last updated - pub last_updated: i64, - /// Source - pub source: String, -} - -/// Trait for issuer reference data providers. -#[async_trait] -pub trait IssuerReferenceSource: Send + Sync { - /// Get issuer by ID. - async fn get_issuer(&self, issuer_id: &str) -> Result, TraitError>; - - /// Get issuer by LEI. - async fn get_by_lei(&self, lei: &str) -> Result, TraitError>; - - /// Search issuers. - async fn search(&self, query: &str, limit: usize) -> Result, TraitError>; - - /// Get all issuers in a sector. - async fn get_by_sector(&self, sector: &str) -> Result, TraitError>; -} - -// ============================================================================= -// CREDIT RATINGS -// ============================================================================= - -/// Rating agency. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RatingAgency { - /// Moody's - Moodys, - /// S&P Global - SP, - /// Fitch - Fitch, -} - -/// Credit rating. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CreditRating { - /// Issuer ID - pub issuer_id: String, - /// Rating agency - pub agency: RatingAgency, - /// Long-term rating - pub long_term_rating: Option, - /// Short-term rating - pub short_term_rating: Option, - /// Outlook - pub outlook: Option, - /// Watch status - pub watch: Option, - /// Rating date - pub rating_date: Date, - /// Last action - pub last_action: Option, - /// Is investment grade - pub is_investment_grade: bool, - /// Numeric score (1-21, 1=AAA) - pub numeric_score: Option, -} - -/// Trait for credit rating providers. -#[async_trait] -pub trait RatingSource: Send + Sync { - /// Get rating for issuer from specific agency. - async fn get_rating( - &self, - issuer_id: &str, - agency: RatingAgency, - ) -> Result, TraitError>; - - /// Get all ratings for an issuer. - async fn get_all_ratings(&self, issuer_id: &str) -> Result, TraitError>; - - /// Get composite rating (lowest of all agencies). - async fn get_composite_rating( - &self, - issuer_id: &str, - ) -> Result, TraitError>; - - /// Get rating history. - async fn get_rating_history( - &self, - issuer_id: &str, - agency: RatingAgency, - limit: usize, - ) -> Result, TraitError>; -} - // ============================================================================= // ETF HOLDINGS // ============================================================================= @@ -417,56 +291,3 @@ pub struct EtfHoldings { /// Source pub source: String, } - -/// Trait for ETF holdings providers. -#[async_trait] -pub trait EtfHoldingsSource: Send + Sync { - /// Get current holdings for an ETF. - async fn get_holdings(&self, etf_id: &EtfId) -> Result, TraitError>; - - /// Get historical holdings. - async fn get_holdings_as_of( - &self, - etf_id: &EtfId, - as_of_date: Date, - ) -> Result, TraitError>; - - /// List all available ETFs. - async fn list_etfs(&self) -> Result, TraitError>; - - /// Subscribe to holdings updates. - async fn subscribe(&self, etf_ids: &[EtfId]) -> Result; -} - -/// Receiver for ETF holdings updates. -pub struct EtfHoldingsReceiver { - rx: tokio::sync::broadcast::Receiver, -} - -impl EtfHoldingsReceiver { - /// Create a new ETF holdings receiver. - pub fn new(rx: tokio::sync::broadcast::Receiver) -> Self { - Self { rx } - } - - /// Receive the next update. - pub async fn recv(&mut self) -> Option { - self.rx.recv().await.ok() - } -} - -// ============================================================================= -// COMPOSITE REFERENCE DATA PROVIDER -// ============================================================================= - -use std::sync::Arc; - -/// Combined reference data provider. -pub struct ReferenceDataProvider { - /// Issuer reference source - pub issuers: Arc, - /// Rating source - pub ratings: Arc, - /// ETF holdings source - pub etf_holdings: Arc, -} diff --git a/crates/convex-ports/src/storage.rs b/crates/convex-ports/src/storage.rs index f6668e74..f6a8158e 100644 --- a/crates/convex-ports/src/storage.rs +++ b/crates/convex-ports/src/storage.rs @@ -126,7 +126,6 @@ pub enum BenchmarkReference { /// Specification for how to derive a bond's price. /// /// This is the core configuration that determines the pricing methodology. -/// The actual market data is fetched via `MarketDataProvider` based on this spec. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum PricingSpec { diff --git a/crates/convex-server/ACCESS.md b/crates/convex-server/ACCESS.md index ceb1d458..f50ee85a 100644 --- a/crates/convex-server/ACCESS.md +++ b/crates/convex-server/ACCESS.md @@ -1,6 +1,6 @@ # Convex access control and readiness -Convex checks authentication, current role and resource grants, and quotas before dispatching protected requests. Every router constructor defaults to denied access. The executable resolves its identity integration and access policy before opening application storage. This is the R0 access boundary for local replay and development; production still refuses the uncertified live provider composition, and strategy readiness stays false. +Convex checks authentication, current role and resource grants, and quotas before dispatching protected requests. Every router constructor defaults to denied access. The executable resolves its identity integration and access policy before opening application storage. This is the R0 access boundary. The production profile requires it: `access.mode = "required"` with a policy file and introspection, behind `tls_boundary = "proxy"`. Strategy readiness stays false. ## Start an authenticated replay server diff --git a/crates/convex-server/README.md b/crates/convex-server/README.md index 10de0d1d..0d3038a5 100644 --- a/crates/convex-server/README.md +++ b/crates/convex-server/README.md @@ -4,7 +4,7 @@ The [issuer-default workflow](../../tools/ISSUER-EVENTS.md) reuses this server's reference, curve, fixing, snapshot and result ports for bonds/CDS, risk, hedges and P&L, including atomic book updates and restart recovery. -The server supports an explicit demo profile and loopback-only historical replay with an immutable plan and canonical quote file. Production is the default and fails before opening storage because production provider composition is not certified. Demo responses contain demonstration inputs; they do not establish production readiness. The [runtime guide](../convex-engine/RUNTIME.md) covers replay, readiness, canonical results and joined recovery. +The server supports an explicit demo profile and loopback-only historical replay with an immutable plan and canonical quote file. Production is the default. It runs the Live runtime of a stored activated configuration, and starts only with required access (policy file and introspection) behind the `proxy` TLS boundary; otherwise it names the first missing setting and fails before opening storage. Live valuations stay display-only; official durable results are core-workflow snapshot runs. Demo responses contain demonstration inputs; they do not establish production readiness. The [runtime guide](../convex-engine/RUNTIME.md) covers replay, readiness, canonical results and joined recovery. Authentication defaults to required verification. The [access guide](ACCESS.md) documents authenticated replay, exact role/book/data grants, identity/secret rotation, TLS proxy boundaries, quotas and distinct health states. For a local demonstration, run from the repository root: @@ -91,8 +91,8 @@ identity and request quotas also apply. Only the isolated demo command selects `local_development` access. This extends the supplied-input HTTP workflow. The managed replay runtime still -has its documented fixed-bullet scope, and the production executable still -requires a completed provider composition. The example uses rate futures; full +has its documented fixed-bullet scope, and the production profile runs only a +stored activated configuration behind required access. The example uses rate futures; full delivery-basket/CTD operations remain available through the native library contract and this same endpoint. Live provider admission, distributed ownership, measured latency and production release qualification remain separate work. @@ -145,9 +145,10 @@ ports. Unsupported adapter methods fail explicitly. The offline `convex-core-inputs DATABASE CAPTURED_INPUTS.json` importer also requires a stopped server; interrupted imports recover by retrying the identical captured records. -This is a synthetic, persisted core workflow. Production startup, managed live -provider composition, distributed ownership and measured latency retain their -existing release requirements. +This is a synthetic, persisted core workflow. Under the production profile its +snapshot runs are the official durable results, while live valuations stay +display-only. Vendor feed certification, distributed ownership and measured +latency remain open. The [provider-neutral market-data adapter](../../tools/MARKET-DATA-ADAPTER.md) publishes normalized JSONL curve/fixing captures through these existing write @@ -202,14 +203,14 @@ These process settings require a restart. Versioned quantitative/integration/pol | TOML key | Environment variable | Behavior | |---|---|---| -| profile | CONVEX_PROFILE | `production` by default; explicit `demo`, or loopback `replay` or `live`, can start | +| profile | CONVEX_PROFILE | `production` by default: needs `configuration_id`, `access.mode = required` with policy and introspection, and `access.tls_boundary = proxy`. Explicit `demo`, or loopback `replay` or `live`, start locally | | host | CONVEX_HOST | IPv4/IPv6 literal; default `0.0.0.0` must be changed to loopback for the current HTTP executable; hostnames fail | | port | CONVEX_PORT | Integer 0–65535; default 8080; zero selects an ephemeral port | | websocket_enabled | CONVEX_WEBSOCKET_ENABLED | Boolean; environment accepts true/false or 1/0, case insensitive; default true | | websocket_port | CONVEX_WEBSOCKET_PORT | Optional; accepted only when WebSockets are enabled and it equals the HTTP port | | storage_path | CONVEX_STORAGE_PATH | Nonempty local redb path; default `./data/convex.redb`; parent directory must exist | | runtime_plan | CONVEX_RUNTIME_PLAN | Required explicit plan for replay; for live, this or `configuration_id`; fixed-bullet mode, exact build, no seed events | -| configuration_id | CONVEX_CONFIGURATION_ID | Live (instead of `runtime_plan`) or demo: run the stored active configuration with this id live; under demo, Demo-quality inputs are valued and labelled | +| configuration_id | CONVEX_CONFIGURATION_ID | Production (required), live (instead of `runtime_plan`) or demo: run the stored active configuration with this id live; under demo, Demo-quality inputs are valued and labelled | | quotes_file | CONVEX_QUOTES_FILE | Required canonical JSONL replay in replay mode; unsupported in other modes | | curves_file | CONVEX_CURVES_FILE | Rejected: file provider composition is not implemented | | fixings_file | CONVEX_FIXINGS_FILE | Rejected: file provider composition is not implemented | @@ -388,7 +389,7 @@ Other portfolio analytics routes take inline positions only. The redb reference tables are `reference_current_v2`, `reference_history_v2`, `reference_commits_v2`, `reference_master_current_v2` and `reference_master_history_v2`. A complete earlier three-table repository adds the master tables atomically while retaining exact prior content and receipts. Terms without `contractual_terms` require an explicit verified update before valuation. Back up before upgrade and restore into isolation to roll back. A nonempty previous unversioned `bonds` table fails startup with an explicit export/import requirement. Preserve its database and export full terms with a compatible reader before importing them into a new versioned repository; do not erase the existing table to bypass validation. Unversioned data needs explicit export/import; missing prior history is not reconstructed. -Implement `ReferenceRepository` to slot in another backend and run both shared reference conformance suites. Required historical/master methods are described in the [port guide](../convex-ports/REFERENCE_MASTER.md#http-and-embedded-use). `ReferenceDataProvider` covers auxiliary issuer, rating and ETF sources. Default router helpers deny protected access. Supply `create_router_with_access` with the verified boundary; the access guide describes embedding migration. +Implement `ReferenceRepository` to slot in another backend and run both shared reference conformance suites. Required historical/master methods are described in the [port guide](../convex-ports/REFERENCE_MASTER.md#http-and-embedded-use). Default router helpers deny protected access. Supply `create_router_with_access` with the verified boundary; the access guide describes embedding migration. `create_test_storage` names the file-backed redb test helper accurately. Prefer `create_redb_storage` with an owned temporary directory when deterministic file cleanup is required. The validated analytics risk wrapper is now `ValidatedRiskResponse`; the former name has no compatibility alias. Pure pricing formulas, units and signs are unchanged. @@ -396,7 +397,7 @@ The [local redb storage guide](../convex-ext-redb/README.md) documents immutable ## Market replay and file inputs -[The file adapter contract](../convex-ext-file/README.md) documents sequenced quote replay, image/delta recovery, exact units/timestamps, field clears, bounds and snapshot-file migration. Run its standalone trace example before wiring an ingestion adapter. File snapshots require recorded timestamps and sources; missing files or malformed records fail explicitly. The owned runtime uses snapshot-consistent pricing; existing v1 synthetic-curve behavior remains the enforced G04 production gap. +[The file adapter contract](../convex-ext-file/README.md) documents sequenced quote replay, image/delta recovery, exact units/timestamps, field clears and bounds. Run its standalone trace example before wiring an ingestion adapter. The owned runtime uses snapshot-consistent pricing; existing v1 synthetic-curve behavior remains the enforced G04 production gap. ## Configuration operator diff --git a/crates/convex-server/src/composition.rs b/crates/convex-server/src/composition.rs index 66cf2996..0a2087d0 100644 --- a/crates/convex-server/src/composition.rs +++ b/crates/convex-server/src/composition.rs @@ -8,19 +8,12 @@ use async_trait::async_trait; use tracing::warn; use convex_engine::ports::config::EngineConfig; -use convex_engine::ports::market_data::{MarketDataProvider, QuoteSource}; -use convex_engine::ports::reference_data::ReferenceDataProvider; use convex_engine::ports::runtime::{PublishedValuation, ResultPublisher}; use convex_engine::ports::snapshot::{MarketSnapshot, SnapshotRepository}; use convex_engine::ports::TraitError; use convex_engine::{ PricingEngine, PricingEngineBuilder, RuntimeInput, RuntimeMode, RuntimePlan, RuntimeServices, }; -use convex_ext_file::{ - EmptyCurveInputSource, EmptyEtfHoldingsSource, EmptyEtfQuoteSource, EmptyFxRateSource, - EmptyIndexFixingSource, EmptyInflationFixingSource, EmptyIssuerReferenceSource, - EmptyQuoteSource, EmptyRatingSource, EmptyVolatilitySource, -}; use crate::market::QuoteIngress; use crate::{ConfigError, Server, ServerConfig, ServerProfile, StartupError}; @@ -82,41 +75,10 @@ pub async fn compose(config: ServerConfig) -> Result core_build_id()?, )); - // Live quotes arrive only through the HTTP ingress, scoped to the plan's quote keys. - let ingress = plan - .as_ref() - .filter(|plan| plan.mode == RuntimeMode::Live) - .map(|plan| { - Arc::new(QuoteIngress::new( - plan.assembly.quotes.iter().map(|q| q.key.clone()), - )) - }); - let quotes: Arc = match &ingress { - Some(ingress) => ingress.clone(), - None => Arc::new(EmptyQuoteSource), - }; - // Unused auxiliary product ports are explicit; replay reads the selected canonical event source. - let market_data = MarketDataProvider { - quotes, - curve_inputs: Arc::new(EmptyCurveInputSource), - index_fixings: Arc::new(EmptyIndexFixingSource), - volatility: Arc::new(EmptyVolatilitySource), - fx_rates: Arc::new(EmptyFxRateSource), - inflation_fixings: Arc::new(EmptyInflationFixingSource), - etf_quotes: Arc::new(EmptyEtfQuoteSource), - }; - let reference_data = ReferenceDataProvider { - issuers: Arc::new(EmptyIssuerReferenceSource), - ratings: Arc::new(EmptyRatingSource), - etf_holdings: Arc::new(EmptyEtfHoldingsSource), - }; - - let mut builder = PricingEngineBuilder::new() + let builder = PricingEngineBuilder::new() .with_config(EngineConfig::default()) - .with_market_data(Arc::new(market_data)) - .with_reference_data(Arc::new(reference_data)) .with_storage(storage.clone()); - builder = match plan { + let (builder, ingress) = match plan { Some(plan) => { let durable = Durable { runtime: runtime_store, @@ -125,7 +87,7 @@ pub async fn compose(config: ServerConfig) -> Result }; with_runtime(builder, &config, plan, &storage, durable).await? } - None => builder.with_demo_mode(), + None => (builder.with_demo_mode(), None), }; let engine = Arc::new(builder.build()?); @@ -185,10 +147,10 @@ async fn active_plan( } /// A plan runs only in its profile's mode and under this executable's build identity. -/// The demo profile runs a stored configuration live, on labelled demonstration data. +/// Production and demo run a stored configuration live; demo output is labelled demo. fn checked(config: &ServerConfig, plan: RuntimePlan) -> Result { let mode = match config.profile { - ServerProfile::Live | ServerProfile::Demo => RuntimeMode::Live, + ServerProfile::Live | ServerProfile::Demo | ServerProfile::Production => RuntimeMode::Live, _ => RuntimeMode::Replay, }; if plan.mode != mode || plan.build_id.as_str() != crate::SERVER_BUILD_ID { @@ -223,14 +185,21 @@ struct Durable { results: Arc, } +/// The runtime over the durable ports, and in Live the HTTP quote ingress that feeds it. async fn with_runtime( builder: PricingEngineBuilder, config: &ServerConfig, plan: RuntimePlan, storage: &convex_engine::ports::storage::StorageAdapter, durable: Durable, -) -> Result { +) -> Result<(PricingEngineBuilder, Option>), StartupError> { let live = plan.mode == RuntimeMode::Live; + // Live quotes arrive only through the HTTP ingress, scoped to the plan's quote keys. + let ingress = live.then(|| { + Arc::new(QuoteIngress::new( + plan.assembly.quotes.iter().map(|q| q.key.clone()), + )) + }); let snapshots: Arc = if live { Arc::new(UnstoredSnapshots) } else { @@ -271,21 +240,25 @@ async fn with_runtime( ..EngineConfig::default() }); } - let (publisher, input): (Arc, RuntimeInput) = if live { - (Arc::new(DisplayOnlyPublisher), RuntimeInput::Live) - } else { - let quotes = config - .quotes_file - .as_ref() - .ok_or(ConfigError::InvalidValue { - field: "quotes_file", - expected: "canonical JSONL quote events", - })?; - let replay = - convex_ext_file::FileQuoteReplay::open(quotes).map_err(stage("quote_replay"))?; - (durable.results, RuntimeInput::Replay(Box::new(replay))) + let (publisher, input): (Arc, RuntimeInput) = match &ingress { + Some(ingress) => ( + Arc::new(DisplayOnlyPublisher), + RuntimeInput::Live(ingress.clone()), + ), + None => { + let quotes = config + .quotes_file + .as_ref() + .ok_or(ConfigError::InvalidValue { + field: "quotes_file", + expected: "canonical JSONL quote events", + })?; + let replay = + convex_ext_file::FileQuoteReplay::open(quotes).map_err(stage("quote_replay"))?; + (durable.results, RuntimeInput::Replay(Box::new(replay))) + } }; - Ok(builder + let builder = builder .with_configuration_use_cases(cases) .with_runtime(RuntimeServices { plan, @@ -294,7 +267,8 @@ async fn with_runtime( store: durable.runtime, publisher, input, - })) + }); + Ok((builder, ingress)) } /// Live market snapshots are not stored. The Live runtime never publishes one, and diff --git a/crates/convex-server/src/config.rs b/crates/convex-server/src/config.rs index becab170..e6e32b11 100644 --- a/crates/convex-server/src/config.rs +++ b/crates/convex-server/src/config.rs @@ -13,7 +13,8 @@ use thiserror::Error; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ServerProfile { - /// Requires real provider composition before the server may start. + /// The Live runtime of a stored activated configuration, behind required access and an + /// external TLS proxy. The default; startup names the first of these that is missing. #[default] Production, /// Explicitly permits the existing demonstration providers. @@ -91,7 +92,8 @@ pub struct ServerConfig { pub quotes_file: Option, /// Complete bounded JSON runtime plan; required in replay mode. pub runtime_plan: Option, - /// Live only, instead of `runtime_plan`: plan the stored active configuration. + /// Plan the stored active configuration: production requires it, live may use it + /// instead of `runtime_plan`, and demo runs it on demonstration data. pub configuration_id: Option, /// Reserved file integration; rejected until the provider is wired. pub curves_file: Option, @@ -379,10 +381,10 @@ impl ServerConfig { }); } if let Some(id) = &self.configuration_id { - if !matches!(self.profile, ServerProfile::Live | ServerProfile::Demo) { + if self.profile == ServerProfile::Replay { return Err(ConfigError::Unsupported { field: "configuration_id", - reason: "a stored configuration plans only the live or demo profile", + reason: "replay runs a plan file, not a stored configuration", }); } if convex_core::contracts::SourceId::new(id.as_str()).is_err() { @@ -408,11 +410,11 @@ impl ServerConfig { Ok(()) } - /// Refuse the current placeholder composition in production, before opening storage. + /// Refuse an incomplete composition before opening storage. pub fn validate_startup(&self) -> Result<(), ConfigError> { self.validate()?; if self.profile == ServerProfile::Production { - return Err(ConfigError::Unsupported { field: "profile", reason: "live production provider composition is not implemented; demo or local replay requires explicit opt-in" }); + self.validate_production()?; } if !self.bind_address()?.ip().is_loopback() { return Err(ConfigError::Unsupported { @@ -444,6 +446,27 @@ impl ServerConfig { Ok(()) } + /// Production runs the Live runtime of a stored activated configuration (D23: live + /// valuations display-only, official results from core-workflow snapshot runs), behind + /// required access and an external TLS proxy. Each missing condition is its own error. + fn validate_production(&self) -> Result<(), ConfigError> { + use crate::access::identity::{AccessMode, TlsBoundary}; + let missing = |field, expected| Err(ConfigError::InvalidValue { field, expected }); + if self.configuration_id.is_none() { + return missing( + "configuration_id", + "a stored activated configuration in production", + ); + } + if self.access.mode != AccessMode::Required { + return missing("access.mode", "required in production"); + } + if self.access.tls_boundary != TlsBoundary::Proxy { + return missing("access.tls_boundary", "proxy in production"); + } + Ok(()) + } + /// Resolve the exact listener address; never fall back to a different interface. pub fn bind_address(&self) -> Result { let host = self @@ -675,13 +698,64 @@ mod tests { )); assert!(matches!( ServerConfig::default().validate_startup(), - Err(ConfigError::Unsupported { - field: "profile", + Err(ConfigError::InvalidValue { + field: "configuration_id", .. }) )); } + #[test] + fn production_needs_a_stored_configuration_required_access_and_the_tls_proxy() { + use crate::access::identity::{AccessMode, IntrospectionSettings, TlsBoundary}; + let refused = |config: &ServerConfig| match config.validate_startup() { + Err( + ConfigError::InvalidValue { field, .. } | ConfigError::Unsupported { field, .. }, + ) => field, + other => panic!("{other:?}"), + }; + let mut config = ServerConfig { + host: "127.0.0.1".into(), + ..Default::default() + }; + assert_eq!(config.profile, ServerProfile::Production); + assert_eq!(refused(&config), "configuration_id"); + config.configuration_id = Some("BOOK-USD".into()); + config.access.mode = AccessMode::LocalDevelopment; + assert_eq!(refused(&config), "access.mode"); + config.access.mode = AccessMode::Required; + assert_eq!(refused(&config), "access.tls_boundary"); + config.access.tls_boundary = TlsBoundary::Proxy; + assert_eq!(refused(&config), "access"); + config.access.policy_file = Some("/access.json".into()); + config.access.introspection = Some(IntrospectionSettings { + endpoint: "https://identity.example.invalid/introspect".into(), + issuer: "https://identity.example.invalid/issuer".into(), + audience: "convex-api".into(), + client_id: "convex".into(), + client_secret_ref: "env://IDENTITY_SECRET".into(), + allow_http_loopback: false, + }); + config.validate_startup().unwrap(); + // A plan file is replay or local live input, never production's. + config.runtime_plan = Some("/plan.json".into()); + assert_eq!(refused(&config), "runtime_plan"); + config.runtime_plan = None; + // The HTTP identity fixture exception stays local-only. + let introspection = config.access.introspection.as_mut().unwrap(); + introspection.endpoint = "http://127.0.0.1:9/introspect".into(); + introspection.allow_http_loopback = true; + assert_eq!(refused(&config), "access.introspection"); + let introspection = config.access.introspection.as_mut().unwrap(); + introspection.endpoint = "https://identity.example.invalid/introspect".into(); + introspection.allow_http_loopback = false; + // TLS terminates at the proxy; the executable still binds loopback only. + config.host = "0.0.0.0".into(); + assert_eq!(refused(&config), "host"); + config.host = "127.0.0.1".into(); + config.validate_startup().unwrap(); + } + #[test] fn shared_websocket_port_and_all_supported_booleans_work() { for (value, enabled) in [ diff --git a/crates/convex-server/tests/api_integration_tests.rs b/crates/convex-server/tests/api_integration_tests.rs index 26b8e9ae..f0548603 100644 --- a/crates/convex-server/tests/api_integration_tests.rs +++ b/crates/convex-server/tests/api_integration_tests.rs @@ -17,22 +17,14 @@ use convex_core::contracts::{RequestId, UtcTimestamp, Version}; use convex_core::ids::InstrumentId; use convex_core::{Currency, Date}; use convex_engine::ports::config::EngineConfig; -use convex_engine::ports::market_data::MarketDataProvider; use convex_engine::ports::output::BondQuoteOutput; -use convex_engine::ports::reference_data::{ - BondReferenceData, BondType, IssuerType, ReferenceDataProvider, -}; +use convex_engine::ports::reference_data::{BondReferenceData, BondType, IssuerType}; use convex_engine::ports::reference_master::ExternalIdentifier; use convex_engine::ports::reference_repository::{ ReferenceCommand, ReferenceCommit, ReferenceError, ReferenceRecord, ReferenceRepository, }; use convex_engine::ports::storage::{BondFilter, Page, Pagination}; use convex_engine::PricingEngineBuilder; -use convex_ext_file::{ - EmptyCurveInputSource, EmptyEtfHoldingsSource, EmptyEtfQuoteSource, EmptyFxRateSource, - EmptyIndexFixingSource, EmptyInflationFixingSource, EmptyIssuerReferenceSource, - EmptyQuoteSource, EmptyRatingSource, EmptyVolatilitySource, -}; // These economic/API fixtures explicitly use local development. Access denial has its own suite. fn create_router(engine: Arc) -> axum::Router { convex_server::routes::create_router_with_access( @@ -873,26 +865,8 @@ fn create_test_engine_with_storage( .now_or_never() .unwrap() .unwrap(); - let market_data = MarketDataProvider { - quotes: Arc::new(EmptyQuoteSource), - curve_inputs: Arc::new(EmptyCurveInputSource), - index_fixings: Arc::new(EmptyIndexFixingSource), - volatility: Arc::new(EmptyVolatilitySource), - fx_rates: Arc::new(EmptyFxRateSource), - inflation_fixings: Arc::new(EmptyInflationFixingSource), - etf_quotes: Arc::new(EmptyEtfQuoteSource), - }; - - let reference_data = ReferenceDataProvider { - issuers: Arc::new(EmptyIssuerReferenceSource), - ratings: Arc::new(EmptyRatingSource), - etf_holdings: Arc::new(EmptyEtfHoldingsSource), - }; - let engine = PricingEngineBuilder::new() .with_config(EngineConfig::default()) - .with_market_data(Arc::new(market_data)) - .with_reference_data(Arc::new(reference_data)) .with_storage(Arc::new(storage)) .build() .expect("Failed to build engine"); diff --git a/crates/convex-server/tests/application_path.rs b/crates/convex-server/tests/application_path.rs index 8b790b98..575c6161 100644 --- a/crates/convex-server/tests/application_path.rs +++ b/crates/convex-server/tests/application_path.rs @@ -5,7 +5,10 @@ //! Inputs are the committed showcase seed (`deploy/showcase/seed.json`), so the //! fixture the Fly container imports is exercised by CI. Steps that need a later //! phase of `.ai/server-plan/IMPLEMENTATION_PLAN.md` are `#[ignore]`d with that phase. -use convex_server::access::identity::{AccessMode, AccessSettings}; +#[path = "support/access_fixture.rs"] +pub mod fixture; +use convex_server::access::identity::{AccessMode, AccessSettings, TlsBoundary}; +use convex_server::access::AccessError; use convex_server::{compose, ServerConfig, ServerProfile, StartupError}; use reqwest::{Client, StatusCode}; use serde_json::{json, Value}; @@ -563,6 +566,49 @@ async fn file_snapshot_state_beside_the_database_is_refused() { } } +/// Production composes the Live path above behind required access and the TLS proxy. No +/// HTTPS identity fixture runs in process (D24), so this shows a production configuration +/// clears every gate and stops only at the identity probe, before storage opens. +#[tokio::test] +async fn production_clears_every_gate_and_stops_only_at_the_identity_probe() { + let issuer = fixture::IssuerFixture::start().await; + let (directory, path) = storage(); + let policy = directory.path().join("access.json"); + std::fs::write(&policy, serde_json::to_vec(&fixture::policy()).unwrap()).unwrap(); + // Production introspection is HTTPS only; the fixture speaks plain HTTP, so the TLS + // handshake fails before any request or credential is sent. + let mut introspection = issuer.settings.clone(); + introspection.endpoint = introspection.endpoint.replacen("http://", "https://", 1); + introspection.allow_http_loopback = false; + let production = ServerConfig { + profile: ServerProfile::Production, + configuration_id: Some("SHOWCASE-USD".into()), + access: AccessSettings { + mode: AccessMode::Required, + policy_file: Some(policy.to_string_lossy().into_owned()), + introspection: Some(introspection), + cors_origins: vec![], + tls_boundary: TlsBoundary::Proxy, + }, + ..config(&path) + }; + production.validate_startup().unwrap(); + let error = compose(production).await.err().unwrap(); + assert!( + matches!(error, StartupError::Access(AccessError::Unavailable)), + "{error}" + ); + assert_eq!( + issuer.state.calls.load(std::sync::atomic::Ordering::SeqCst), + 0 + ); + assert!( + !path.exists(), + "storage opened before identity verification" + ); + issuer.finish().await; +} + /// P2b-2, the showcase boot: `deploy/showcase/live.sh activate` records the book /// and fresh Demo curves, then compiles, validates, stages and activates under the /// demo profile's capabilities. The demo profile runs that configuration live: Demo diff --git a/crates/convex-server/tests/book_capacity.rs b/crates/convex-server/tests/book_capacity.rs index 1784f46b..5bfcdfc1 100644 --- a/crates/convex-server/tests/book_capacity.rs +++ b/crates/convex-server/tests/book_capacity.rs @@ -24,8 +24,6 @@ use tower::ServiceExt; fn router(f: &fixture::Fixture, access: Arc) -> Router { let engine = PricingEngineBuilder::new() .with_storage(f.storage.clone()) - .with_market_data(runtime_setup::market_data()) - .with_reference_data(Arc::new(convex_ext_file::create_empty_reference_data())) .build() .unwrap(); Server::with_access_control( diff --git a/crates/convex-server/tests/core_workflow.rs b/crates/convex-server/tests/core_workflow.rs index 184d2e86..4496c596 100644 --- a/crates/convex-server/tests/core_workflow.rs +++ b/crates/convex-server/tests/core_workflow.rs @@ -31,8 +31,6 @@ fn router(f: &fixture::Fixture, access: Arc, core: Arc Router { - let market = MarketDataProvider { - quotes: Arc::new(EmptyQuoteSource), - curve_inputs: Arc::new(EmptyCurveInputSource), - index_fixings: Arc::new(EmptyIndexFixingSource), - volatility: Arc::new(EmptyVolatilitySource), - fx_rates: Arc::new(EmptyFxRateSource), - inflation_fixings: Arc::new(EmptyInflationFixingSource), - etf_quotes: Arc::new(EmptyEtfQuoteSource), - }; - let reference = ReferenceDataProvider { - issuers: Arc::new(EmptyIssuerReferenceSource), - ratings: Arc::new(EmptyRatingSource), - etf_holdings: Arc::new(EmptyEtfHoldingsSource), - }; let engine = PricingEngineBuilder::new() .with_config(EngineConfig::default()) - .with_market_data(Arc::new(market)) - .with_reference_data(Arc::new(reference)) .with_storage(Arc::new(convex_ext_redb::create_test_storage().unwrap())) .build() .unwrap(); diff --git a/crates/convex-server/tests/portfolio_aggregation.rs b/crates/convex-server/tests/portfolio_aggregation.rs index c4c4c97e..27399e7b 100644 --- a/crates/convex-server/tests/portfolio_aggregation.rs +++ b/crates/convex-server/tests/portfolio_aggregation.rs @@ -4,42 +4,15 @@ use axum::{ http::{Request, StatusCode}, Router, }; -use convex_engine::{ - ports::{ - config::EngineConfig, market_data::MarketDataProvider, - reference_data::ReferenceDataProvider, - }, - PricingEngineBuilder, -}; -use convex_ext_file::{ - EmptyCurveInputSource, EmptyEtfHoldingsSource, EmptyEtfQuoteSource, EmptyFxRateSource, - EmptyIndexFixingSource, EmptyInflationFixingSource, EmptyIssuerReferenceSource, - EmptyQuoteSource, EmptyRatingSource, EmptyVolatilitySource, -}; +use convex_engine::{ports::config::EngineConfig, PricingEngineBuilder}; use http_body_util::BodyExt; use serde_json::Value; use std::sync::Arc; use tower::ServiceExt; fn app() -> Router { - let market = MarketDataProvider { - quotes: Arc::new(EmptyQuoteSource), - curve_inputs: Arc::new(EmptyCurveInputSource), - index_fixings: Arc::new(EmptyIndexFixingSource), - volatility: Arc::new(EmptyVolatilitySource), - fx_rates: Arc::new(EmptyFxRateSource), - inflation_fixings: Arc::new(EmptyInflationFixingSource), - etf_quotes: Arc::new(EmptyEtfQuoteSource), - }; - let reference = ReferenceDataProvider { - issuers: Arc::new(EmptyIssuerReferenceSource), - ratings: Arc::new(EmptyRatingSource), - etf_holdings: Arc::new(EmptyEtfHoldingsSource), - }; let engine = PricingEngineBuilder::new() .with_config(EngineConfig::default()) - .with_market_data(Arc::new(market)) - .with_reference_data(Arc::new(reference)) .with_storage(Arc::new(convex_ext_redb::create_test_storage().unwrap())) .build() .unwrap(); diff --git a/crates/convex-server/tests/retired_liquidity_route.rs b/crates/convex-server/tests/retired_liquidity_route.rs index cb09882b..551e961e 100644 --- a/crates/convex-server/tests/retired_liquidity_route.rs +++ b/crates/convex-server/tests/retired_liquidity_route.rs @@ -5,42 +5,15 @@ use axum::{ Router, }; use axum::{extract::MatchedPath, middleware::Next, response::Response}; -use convex_engine::{ - ports::{ - config::EngineConfig, market_data::MarketDataProvider, - reference_data::ReferenceDataProvider, - }, - PricingEngineBuilder, -}; -use convex_ext_file::{ - EmptyCurveInputSource, EmptyEtfHoldingsSource, EmptyEtfQuoteSource, EmptyFxRateSource, - EmptyIndexFixingSource, EmptyInflationFixingSource, EmptyIssuerReferenceSource, - EmptyQuoteSource, EmptyRatingSource, EmptyVolatilitySource, -}; +use convex_engine::{ports::config::EngineConfig, PricingEngineBuilder}; use http_body_util::BodyExt; use serde_json::Value; use std::sync::Arc; use tower::ServiceExt; fn app() -> Router { - let market = MarketDataProvider { - quotes: Arc::new(EmptyQuoteSource), - curve_inputs: Arc::new(EmptyCurveInputSource), - index_fixings: Arc::new(EmptyIndexFixingSource), - volatility: Arc::new(EmptyVolatilitySource), - fx_rates: Arc::new(EmptyFxRateSource), - inflation_fixings: Arc::new(EmptyInflationFixingSource), - etf_quotes: Arc::new(EmptyEtfQuoteSource), - }; - let reference = ReferenceDataProvider { - issuers: Arc::new(EmptyIssuerReferenceSource), - ratings: Arc::new(EmptyRatingSource), - etf_holdings: Arc::new(EmptyEtfHoldingsSource), - }; let engine = PricingEngineBuilder::new() .with_config(EngineConfig::default()) - .with_market_data(Arc::new(market)) - .with_reference_data(Arc::new(reference)) .with_storage(Arc::new(convex_ext_redb::create_test_storage().unwrap())) .build() .unwrap(); diff --git a/crates/convex-server/tests/retired_stress_routes.rs b/crates/convex-server/tests/retired_stress_routes.rs index 162bbf33..9b9e5d68 100644 --- a/crates/convex-server/tests/retired_stress_routes.rs +++ b/crates/convex-server/tests/retired_stress_routes.rs @@ -5,42 +5,15 @@ use axum::{ Router, }; use axum::{extract::MatchedPath, middleware::Next, response::Response}; -use convex_engine::{ - ports::{ - config::EngineConfig, market_data::MarketDataProvider, - reference_data::ReferenceDataProvider, - }, - PricingEngineBuilder, -}; -use convex_ext_file::{ - EmptyCurveInputSource, EmptyEtfHoldingsSource, EmptyEtfQuoteSource, EmptyFxRateSource, - EmptyIndexFixingSource, EmptyInflationFixingSource, EmptyIssuerReferenceSource, - EmptyQuoteSource, EmptyRatingSource, EmptyVolatilitySource, -}; +use convex_engine::{ports::config::EngineConfig, PricingEngineBuilder}; use http_body_util::BodyExt; use serde_json::Value; use std::sync::Arc; use tower::ServiceExt; fn app() -> Router { - let market = MarketDataProvider { - quotes: Arc::new(EmptyQuoteSource), - curve_inputs: Arc::new(EmptyCurveInputSource), - index_fixings: Arc::new(EmptyIndexFixingSource), - volatility: Arc::new(EmptyVolatilitySource), - fx_rates: Arc::new(EmptyFxRateSource), - inflation_fixings: Arc::new(EmptyInflationFixingSource), - etf_quotes: Arc::new(EmptyEtfQuoteSource), - }; - let reference = ReferenceDataProvider { - issuers: Arc::new(EmptyIssuerReferenceSource), - ratings: Arc::new(EmptyRatingSource), - etf_holdings: Arc::new(EmptyEtfHoldingsSource), - }; let engine = PricingEngineBuilder::new() .with_config(EngineConfig::default()) - .with_market_data(Arc::new(market)) - .with_reference_data(Arc::new(reference)) .with_storage(Arc::new(convex_ext_redb::create_test_storage().unwrap())) .build() .unwrap(); diff --git a/crates/convex-server/tests/startup_configuration_tests.rs b/crates/convex-server/tests/startup_configuration_tests.rs index 9a6665a7..aec9b285 100644 --- a/crates/convex-server/tests/startup_configuration_tests.rs +++ b/crates/convex-server/tests/startup_configuration_tests.rs @@ -248,7 +248,7 @@ fn invalid_files_and_explicit_missing_file_are_rejected() { } #[test] -fn production_default_and_override_refuse_placeholder_providers() { +fn production_default_and_override_refuse_without_a_stored_configuration() { for (settings, file) in [ (vec![], ConfigFile::Absent), (vec![("CONVEX_PROFILE", "production")], ConfigFile::Absent), @@ -260,7 +260,8 @@ fn production_default_and_override_refuse_placeholder_providers() { let mut probe = Probe::spawn(&settings, file); let output = probe.rejected(); assert!( - output.contains("UNSUPPORTED_CONFIG") && output.contains("profile"), + output.contains("INVALID_CONFIG_VALUE: configuration_id") + && output.contains("in production"), "{output}" ); } diff --git a/crates/convex-server/tests/websocket_integration_tests.rs b/crates/convex-server/tests/websocket_integration_tests.rs index e8a5e8b3..0937ea6d 100644 --- a/crates/convex-server/tests/websocket_integration_tests.rs +++ b/crates/convex-server/tests/websocket_integration_tests.rs @@ -18,14 +18,7 @@ use tokio::time::timeout; use tokio_tungstenite::{connect_async, tungstenite::Message}; use convex_engine::ports::config::EngineConfig; -use convex_engine::ports::market_data::MarketDataProvider; -use convex_engine::ports::reference_data::ReferenceDataProvider; use convex_engine::PricingEngineBuilder; -use convex_ext_file::{ - EmptyCurveInputSource, EmptyEtfHoldingsSource, EmptyEtfQuoteSource, EmptyFxRateSource, - EmptyIndexFixingSource, EmptyInflationFixingSource, EmptyIssuerReferenceSource, - EmptyQuoteSource, EmptyRatingSource, EmptyVolatilitySource, -}; fn create_router(engine: Arc) -> axum::Router { convex_server::routes::create_router_with_access( engine, @@ -66,28 +59,10 @@ async fn complete_batch_reference(request: &mut Value, engine: &convex_engine::P /// Create a test engine with empty providers. fn create_test_engine() -> Arc { - let market_data = MarketDataProvider { - quotes: Arc::new(EmptyQuoteSource), - curve_inputs: Arc::new(EmptyCurveInputSource), - index_fixings: Arc::new(EmptyIndexFixingSource), - volatility: Arc::new(EmptyVolatilitySource), - fx_rates: Arc::new(EmptyFxRateSource), - inflation_fixings: Arc::new(EmptyInflationFixingSource), - etf_quotes: Arc::new(EmptyEtfQuoteSource), - }; - - let reference_data = ReferenceDataProvider { - issuers: Arc::new(EmptyIssuerReferenceSource), - ratings: Arc::new(EmptyRatingSource), - etf_holdings: Arc::new(EmptyEtfHoldingsSource), - }; - let storage = convex_ext_redb::create_test_storage().expect("Failed to create memory storage"); let engine = PricingEngineBuilder::new() .with_config(EngineConfig::default()) - .with_market_data(Arc::new(market_data)) - .with_reference_data(Arc::new(reference_data)) .with_storage(Arc::new(storage)) .build() .expect("Failed to build engine"); diff --git a/tools/library/library-inventory.json b/tools/library/library-inventory.json index b5080efb..8fa0704f 100644 --- a/tools/library/library-inventory.json +++ b/tools/library/library-inventory.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "inventory_version": 157, + "inventory_version": 158, "reviewed_at": "2026-10-21", "reviewed_source_commit": "4594729efc300e73d2286833ee2f107d752d8159", "scope_note": "Reviewed pending coverage inventory, not a library certificate. Proposed contract packs require exact documents/fixtures/model approval in their owning tasks. All 30 mandatory areas are preserved. No record is Verified. CVX-054 inspects candidate shared primitive implementations and updates only affected source pins. Original mandatory dimensions/owners/statuses stay pending. New native APIs have engineering fixture evidence; full product migration, market packs, all surface parity and independent certificates remain required. CVX-056 adds checked native curve/dated helper candidate evidence. All 30 areas,174 combinations,2154 Planned operation/surface records,12 exclusions and original owner/gate scope remain. Complex product/global calibration, schema/host dispatch and independent certificates remain pending. CVX-060 numerical candidate evidence adds native scaled dated money-market fit and exact views/declared rolls. All original mandatory records remain Planned; full models, market packs, shared host dispatch and independent certificates remain pending. CVX-017 implements shared supplied fixed/zero quotes, exact payment-step pricing, checked explicit curve roles and missing inputs, actual government benchmark yields and native/FFI/admitted REST parity. The fresh retained comparison passes 392/392 at unchanged expected values/tolerances, but remains unqualified. All original requirements, combinations, operation/surface records and owners remain Planned; no independent model/market or LB0/LB1 certificate is inferred. CVX-055 adds checked full supplied BondTerms/native JSON reconstruction, independent stepped/amortizing/floating cash with eligible history, exact expected principal allocation and complete supported fixed-price exercise/notice terms. Pure facade prices plain expected cash on separate supplied projection/discount roles and rejects optional valuation without a model. Full product model/market, host/schema, inverse/risk/lifecycle and independent certificates remain pending; all mandatory records and owners remain Planned. CVX-059 adds checked optional-bond HW1F/sigma calibration, actual dated fixed notice/call/put branches, restricted joint term-FRN common-state pricing, fractional OAS and resolved/converged fixed-model factor risk. Explicit unsupported combinations remain gaps; prior compatibility paths retain separate qualification. Strict economic JSON rejects ignored fixed cap/floor fields. Full market/model/host, lifecycle and independent certificates remain pending; no mandatory record is promoted. CVX-018 adds supplied native IRS/OIS contracts and eligible expected cash engineering candidates. Full discounted pricing, curve calibration/rebuild, inverse/risk/lifecycle, host schemas/dispatch and market/model/library certificates remain owning work. CVX-019 adds native full-contract multi-curve IRS/OIS PV/accrued/par, checked common three-PV lineage and bounded product piecewise/global fitting with explicit overnight zero-basis view. Manual independent references do not satisfy external ORE/QuantLib AC2, which remains pending. All original scope, economics, models, owners, surfaces, gates and Planned records remain unchanged; risk/lifecycle/hosts and all certificates remain owning work. CVX-019 AC2 now has pinned matched full-contract inputs, a real native export and prepared checksum-verified QuantLib capture/build-provenance/offline replay tooling. QuantLib calls remain unexecuted; runtime connection failures do not satisfy AC2. All mandatory rows and delivery gates stay Planned. CVX-019 AC2 execution now records a publisher/installed-byte verified QuantLib 1.40 run: ten matched full-term cases,973 numeric values and exact coupon/reset/observation/payment lineage at unchanged predeclared bounds. A separately pinned complete capture supports current-native offline CI on other platforms without installing QuantLib. Scoped external_comparison_pending is closed; all mandatory records stay Planned and independent certificates, market packs, risk/lifecycle and hosts remain owning work. CVX-020 provides callable native single-name CDS terms/schedules, premium/protection/accrual-on-default and running/clean/dirty conversions through convex::credit. Original executed1.8.3 model and all7164 original USD RFR rows match at declared bounds. Full calibration/CS01/JTD, lifecycle, host/schema and independent market/model certificates remain owning work. Preserve all original mandatory economics/owners/surfaces/gates and the exact2154 Planned table suffix. CVX-021 adds actual full CDS quote calibration, signed rates/CS01/upfront/recovery scenarios, conditional immediate JTD and checked bond-CDS mapping/basis inputs. Original ISDA1.8.3 execution supplies23 engineering scenarios/276valuations/1150checks. Bond credit pricing, executable hedge optimization, lifecycle/hosts and independent market/model certificates remain owning work. Preserve the exact2154 Planned requirement suffix, economics, owners, surfaces and gates. CVX-022 adds actual dated futures/basket/cash reference inputs, native exact units and source/availability admission, authoritative import/replay and explicit indicative advisor status. Published exchange factors are archived; calendar/cash registration and modified test controls are engineering assumptions. Complete futures/CTD/carry/fixing/risk/lifecycle/host workflow and independent certificates remain pending. Preserve every2154 Planned requirement and exact suffix/economics/owner/surface/gate. CVX-023 adds full supplied deterministic bond-future cash/carry/rank/switch risk and partly-fixed overnight/term rate valuation, explicit convexity/final precision, actual cash hedge model/side estimates, normal margin/tick/integer/supplied roll diagnostics. All outputs are indicative. Optional delivery/margin financing, complete convention/model packs, canonical portfolio risk, lifecycle/hosts and independent certificates remain pending. Preserve every2154 Planned requirement and mandatory suffix bytes/economics/owners/surfaces/gates. CVX-057 adds complete supplied mixed rate and two-leg tenor-basis native routes, original native quote diagnostics, independent discount/projection fits and exact dependent rebuilds. Explicit overnight zero-basis views, duplicate retention, rank/work/history/horizon failures remain model assumptions and controls. Synthetic Decimal reference engineering is not ORE execution or market/model/host/quantitative/library/production/latency certification. Preserve every mandatory suffix byte, economics/owner/surface/gate and all2154 Planned records. CVX-058 adds full original cash government/issuer-basis/repo native fitting, settlement quote conversion, separate credit/par recovery model and actual dated repo/haircut/coupon carry. Original native residuals, graph role/model preflight and exact dependent rebuilds are scoped engineering. Synthetic Decimal cash references and retained actual CDS dependency are not ORE engine equivalence, complete convention/model/host integration or release certificates. Preserve every mandatory suffix byte/economics/owner/surface/gate and all2154 Planned records. CVX-024 adds complete-key signed reference risk, actual full native graph bump/recalibration, optional model guards and per-currency expected portfolio/FX coverage. Original numerical/model/market bounds and all mandatory records/suffix/economics/owners/surfaces/gates stay preserved. Scoped engineering does not certify all conventions/models/host/native facade/lifecycle/PnL, independently approved risk policies or deployment/latency. CVX-061 adds pure complete supplied workflow routes and a full quotes-to-curves-to-mixed-book facade example; original residual/history/quantity/model guards retained. Actual future quote levels differ from discounted PV, and expected cash from posted cash. Exact mandatory scope/suffix/owners/surfaces/gates remain unchanged; all2154 records Planned. Model/market/convention/host/lifecycle/PnL, executable hedge and independent certificate gaps remain. CVX-062 adds scoped pure original-contract lifecycle with actual proposed/recorded cash, pending default/auction/payment and native futures delivery facts. Exact mandatory scope/suffix/economics/owners/surfaces/gates remain unchanged; all2154 records Planned. Full amended/successor/legal conventions, authoritative ledger/PnL, host dispatch and independent quantitative/library/production/latency certificates remain open. CVX-063 complete original rate/CDS/cash calibration and quote-risk capture/replay. Schema3 retains actual full native inputs, source-order product valuations, fitted recipes, solver/rank/work diagnostics and exact independently supplied/fitted/view pins. Pure native builders and original risk dispatch own economics; ports retain bounded metadata/JSON and engine admits time/content/output before configuration, runtime, publication and recovery. Existing schema1/2 identities and original32 calibration/risk requests stay exact; 18success/14native failure outcomes plus original global/missing-history/work failures are replayed. Two predeclared Decimal(60) dated-deposit controls are independent engineering controls, not an approval. All344 earlier numerical rows/bytes/defaults,7149historical files and literal30area/174combination/2154Planned tables,50routes/3197requests and26Cexports remain. One inward dependency on the existing pure facade, no new package/version or pure dependency change. Generic replay does not qualify every curve method/model, market or host. All mandatory records, CVX-063 and certificates remain open. CVX-063 original full product-price NS/Svensson calibration in existing rate/cash owners. Complete original Helpers graphs and actual analytic parameters/horizons, source-order native product valuations, quote revisions and full rank/condition/work diagnostics survive strict shared dispatch, original dependent quote-risk and schema3 capture/reopen/runtime/recovery. Caller supplies complete native parameter/quote scales, bounds, weights and numerical policy. No sampled targets/grids, financial formula/default/solver change, automatic retries, new route/C export/package or provider/storage/MQ/clock/order authority. Independent fictional Decimal60 financial controls were declared before candidate execution; distinct contractual admission, scaling/objective and local-start declarations preserve all earlier failures and original answer/native/work bounds. Original mixed FRA/IRS/OIS/futures/basis callbacks are composition checks, not independent new financial answers. All356 prior numerical rows/physical bytes/defaults and7392 historical private files, literal30areas/174combinations/2154Planned tables,50routes/3197old requests,146pure normal dependencies and26Cexports stay unchanged. Global convergence, complete market/method/model/host qualification and all independent certificates remain open; CVX-063 stays partial and unaccepted. Actual Windows/.NET/Excel and wasm32/browser execution remains unverified. CVX-063 full original rate/Government/Repo exponential UFR parameter calibration over exact immutable analytic base/LLP. Explicit original mixed-unit starts/bounds/scales/weights/work/rank and every full-product residual survive native/shared/quote-risk/schema3 replay/runtime. Required one-sided native joined forwards and actual fitted domain policy retain exact original anchor and left LLP ownership. Incorrect recipe-source analytic admission is removed; prior complete parametric product fits retain Helpers with real analytic owners. Original exponential kernel/solvers/product formulas/defaults/dependencies/packages/26Cexports remain unchanged; no regulatory/zero/forward objective substitution, guesses, retries or partial accepted output. 18 independent Decimal60 graphs/36 full quotes/126 analytic points and all accuracy/domain controls were declared before execution; distinct original origin admission and retained failures preserve original expectations. Mixed FRA/IRS/OIS/futures/basis native-generated controls are integration only. Canonical 1bp tail risk and original failed larger/successful explicit smaller base scenarios remain distinct. All378 prior numerical rows/physical bytes/defaults and7868 preceding private evidence files, literal30areas/174combinations/2154Planned,50routes/3197oldrequests/146pure normal packages stay unchanged. CVX-063 remains partial/unaccepted/uncertified; complete market/method/model/host/operation and independent certificates open. Actual Windows/.NET/Excel/Java/JNI/wasm32/browser remain unverified. No provider/storage/MQ/clock/order authority or generic framework enters pure analytics. CVX-063 scoped original fixed-bond boundary amendments and conserved signed security lot splits. Complete checked original terms/calendars, historical profiles/principal/earned cash/receipts and exact later legal revisions remain; future profiles and all original required boundaries are replayed by the same lifecycle owner. Two or more explicit full same-security cohorts conserve exact face without fills or consideration; original earned/residual claims remain with their original owners. Full causal mixed-book pricing/models/curve/history, current/pending/projected/executed exposure, actual FX, all-member CTD ties/switches, nonlinear interaction and unclamped P&L residuals remain. No new dependencies, service/provider/storage/MQ/clock, ledger/order authority or changed financial bounds. Broader amendments/novation, earned-claim transfers to split children, physical-delivery asset splits, managed/browser hosts, scale/latency and independent certificates remain open. CVX-063 remains partial, unaccepted and uncertified. Local implementation/private/pointer commits only; no independent human quantitative approval. CVX-063 scoped original earned/residual cash-claim transfers across complete legal cohorts, including conserved security-lot successors. Original complete native claims/proposals/entitlements, exact prior receipt cutoffs and original signed cash remain; current shares conserve outstanding ownership and every actual receipt stays with its then-current recipient. Same original cash discount/overdue/FX owners, full native causal mixed-book models and all CTD winners/ties/switches remain. Prepared states bind full native terms, resolved dates, original proposals and immutable receipt histories. No security execution, consideration, fees, payments, dependencies, providers, DB/MQ/clock or ledger/order authority is inferred. All earlier financial controls/bounds/defaults and mandatory definitions remain exact. Broader amendments/novation, physical delivery asset partitions/invoice transfers, conditional future claims, delivery options/margin financing, actual managed/browser hosts, scale/latency and independent certificates remain open. CVX-063 remains partial, unaccepted and uncertified. Local commits only; no independent human quantitative approval. CVX-063 scoped conserved physical delivery custody and original invoice claim ownership. Complete checked delivery/notice/security/CF/accrual/rounding, exact signed proposed/received/outstanding face and full actual partial receipt provenance remain. Legal same-security native children partition custody with original earned cash retained. The original future remains invoice proposal/receipt origin; the linked asset starts as cash owner and explicit transfers allocate only outstanding cash. Each actual paid receipt retains its then-current recipient, independently of custody. Original native pricers, full causal mixed books and all CTD ties/switches survive. No new dependency, provider, DB/MQ/clock, ledger or execution authority. Prior financial bytes/bounds/defaults and all mandatory Planned definitions remain. Further physical successor partition/disposal, broader amendments/novation/conditional delivery, managed/browser execution, scale/latency and independent certificates remain open. CVX-063 remains partial/unaccepted/uncertified; local commits only. CVX-063 scoped successive physical custody lineage and actual received-stock disposal. Complete original delivery/notice/basket/security/CF/accrual/rounding and immutable actual receipt histories remain. Same-security successors partition remaining received stock and delivery receivables after actual acquisition. Explicit sourced disposal facts bind original real fills to exact available received-security allocations; consideration and fees remain with the executing cohort, delivery receivables and invoice cash remain independent, and every paid cash recipient stays fixed. Original full pricing, causal mixed books and all CTD ties/switches are preserved. No pricer/framework/dependency/provider/DB/MQ/clock/ledger/execution owner is added. All old numerical bytes/bounds/defaults, routes, complete requests and mandatory Planned definitions stay protected. Broader novation/amendments/conditional claims, delivery options/margin financing, further asset models, actual managed/browser hosts, scale/latency and independent certificates remain open. CVX-063 remains partial/unaccepted/uncertified; local commits only. CVX-063 scoped separately dated original earned fixed-bond coupon/principal ownership and actual paid cash reconciliation through custody, disposal and contractual boundaries. Supplied original native proposal/source, positive residual fraction, exact prior receipt prefix and applied Position points remain independent of security acquisition, custody weights, actual fills and invoice cash. Original residual and earned-transfer owners retain full native cash and paid recipients. Bullet-delivery coupon/redemption and standalone cash-bond amortization use original native lifecycle/pricing; amortizing physical invoice requests retain the original Unsupported convention guard. All full original causal models and CTD ties/switches remain. No pricer/framework/dependency/provider/DB/MQ/clock/ledger/execution owner is added. Every preceding numerical byte/bound/default, complete request and mandatory Planned definition is protected. Broader conditional/floating/credit/novation/amendment cash, delivery options/margin financing, further models, actual managed/browser hosts, scale/latency and independent certificates remain open. CVX-063 remains partial/unaccepted/uncertified; local commits only. CVX-063 scoped full pending-delivery coupon/principal cash and independently available dated Position experiments. The same original preparation, proposal/residual/share/custody/disposal and full native pricing/model/history/CTD owners execute unchanged. Independently supplied legal fractions and exact paid prefixes remain separate from received/outstanding delivery face and invoice ownership. Original due-date, full immutable receipt cutoff, pricing DF/spread versus collateral cash DF, overdue/FX and bullet invoice guards remain. Ten complete financial books/833 native revaluations/17 typed failures across27 full requests are predeclared; original full checkers remain unchanged. No production source, DTO/schema/operation/C export, pricer/framework/dependency/provider/DB/MQ/clock/ledger/execution authority is added. Every preceding numerical byte/bound/default, complete request and mandatory Planned definition is protected. Explicit paid cash on custody children and independent cash-transfer prerequisites need further qualification. Amortizing physical invoices, broader conditional/floating/credit/novation/amendment cash, delivery options/margin financing, further models, actual managed/browser hosts, scale/latency and independent certificates remain open. CVX-063 remains partial/unaccepted/uncertified; local commits only. CVX-063 scoped explicitly dated fixed-bond coupon cash on acquired first and successive physical custody children, original paid-prefix exclusions, actual subsequent receipts and independently available earned-claim transfers with complete baseline cash/custody prerequisites. The existing native proposal/residual/share/custody/disposal and full product/model/history/CTD owners are reused. Only two original admission checks change: exact dated residual ownership can exclude historical child receipts, and original dated validation proves full earned proposal/source/party/cutoff before any cash valuation. Separate key/receipt sets retain bounded work. Later actual Event payments cannot enter independent Position experiments. Six signed whole child books plus ten preserved whole original pending/mixed/CTD books,1349 native revaluations and19 typed failures on35 complete requests are predeclared. All old full financial checkers, bounds/defaults/rows/requests and mandatory Planned definitions remain. No native type/schema/operation/wire/C export/dependency or pricer/framework/provider/DB/MQ/clock/ledger/execution owner is added. Child principal/maturity cross-composition, broader conditional/floating/credit/novation/amendment claims, amortizing invoices, delivery financing/options, further models/managed/browser hosts/scale/latency/production and independent certificates remain open. CVX063 stays partial/unaccepted/uncertified; local commits only. CVX-063 scoped original earned fixed bullet principal and delayed final coupon on previously acquired first/successive physical custody children through native maturity, retaining full paid-prefix exclusions, original paid recipients, acquired-party claim transfers, earlier actual received-stock disposal, pending nominal delivery and original invoice rights. Six signed new whole books plus sixteen unchanged full child/pending/mixed model/history/credit/OIS/CTD books execute1973 original product revaluations and20 typed failures on42 full requests. Two calendar days of cash delay are explicitly supplied in every complete new security copy; collateral cash DF and original due-date/overdue/FX remain distinct from ended security model exposure. Earlier custody/disposal have full independently available baseline experiments. Later maturity/cash Position experiments are explicitly Unavailable; original Time/Event history cannot be borrowed. Original active-security acquisition/disposal guard remains. No production source, pricing/model/admission body, type/schema/operation/wire/C export/dependency/provider/DB/MQ/clock/ledger/execution owner changes. Original failed declarations and separate source-frozen repairs remain. All preceding numeric rows/files/defaults, financial checkers/requests and literal mandatory Planned definitions remain. Full conservation/native/4adapters/schema/fresh C parity/37943 comparisons/workspace/lint/docs/fmt/metadata/private harness are required. Broader floating/conditional/credit/novation/amendment claims, amortizing invoices, delivery financing/options, additional models/managed/browser hosts/production/scale/latency and all independent certificates remain open. CVX063 partial/unaccepted/uncertified; local commits only. CVX-063 scoped simultaneous original principal/final-coupon cash on acquired first/successive custody children, distinct paid prefixes/residuals and sequential/returned shares through native maturity. Six new signed full books with four claims and12 transfers each plus22 whole unchanged maturity/child/pending/mixed model/history/credit/OIS/CTD books execute2645 original product revaluations and28 typed failures on56 full requests. Every original paid recipient, earlier received-stock disposal, nominal pending delivery and separate original invoice ownership remains. Initial invoice-entitlement and complete baseline future-replay trials remain with separate independent declarations. Actual concurrent controls expose Decimal precision loss in the original cash-partition remainder; only that helper now uses the finest common representable total scale and bounded96-bit integer long division, with exact final remainder. Signed integer, tiny quantum, high precision and maximum96-bit controls prove exact conservation; no financial tolerance or original financial input changes. Native full pricing/model/history/admission/custody/guard/receipt bodies and all preceding checkers remain; no type/schema/wire/operation/C export/dependency/provider/DB/MQ/clock/ledger/execution authority changes. Earlier independent custody/disposal has complete baseline facts; later maturity/cash Position is explicitly Unavailable. All1467 preceding numeric rows/files/defaults,4874 requests/77routes and literal mandatory Planned definitions remain. All old/new writers/native/four adapters/schema/fresh C parity/37943 comparisons/workspace/lint/docs/fmt/API/assurance/library/private harness/source/physical conservation are required. CVX063 partial/unaccepted/uncertified; broader claims/amortizing invoices/models/managed/browser hosts/production/scale/latency and all independent certificates remain open. CVX025/031 owns authoritative authenticated cash/fills/FX/postings/limits/reservations/fencing/orders/execution. Local commits only. CVX-063 user requests all outstanding work. Complete current original child principal/final-coupon settlement and native Matured versus Closed qualification; six signed delayed/prefix/staggered full books plus28 complete unchanged preceding financial books execute3317 product revaluations and11 typed failures on45 complete requests. Selected claims exhaust while nominal delivery remains pending; all original obligations including explicitly excluded past coupons settle before Closed. Original paid recipients, zero current-share cash, distinct proposals/prefixes/residuals, custody/disposal/invoice and full mixed models/history/credit/OIS/CTD remain. Only the original physical-child cash receipt guard admits later explicitly excluded original cash after acquired custody, without P&L ownership; earlier paid history and original Active/exhaustion guards remain. All other original production owners and financial checkers unchanged. New remaining-work reporter joins all2154 original records and exact mappings without inventing qualification. Actual JDK26 Java/C45-request parity and actual wasm32 Node45 full original financial checks pass; strict WASM/native envelope parity fails on original model/OIS floating intermediates and remains open, with actual raw outputs retained and no tolerance relaxed. All1553 old numeric rows/files/defaults,4930 requests/77routes, mandatory literal definitions and accepted engineering through062 remain. New declared trials and actual failures retain separate source freezes. Full old/new writer/native/four-adapter/schema/fresh C parity/37943 original comparisons/workspace/lint/docs/fmt/metadata/private/source/physical gates required; final binaries bound after builders. All outstanding exact operations/models/claims/hosts/independent certificates remain assigned in a runnable report and private record-ID backlog. No schema/type/operation/route/dependency/C export/provider/DB/MQ/clock/ledger/execution authority changes. CVX063 partial/unaccepted/uncertified; local commits only. CVX-063 complete current local Java host continuation: every original4975 shared request on77 exact routes is retained as a complete native/JSON transport row and executed through actual Java FFM/C with exact full-envelope checks. The original financial controls/formulas/units/errors/provenance and1591 numeric rows/files/defaults remain unchanged. The complete existing Java suite requires fresh original per-consumer parity; the missing monotone CI generation is repaired. New actual captures are lossless gzip JSON-lines, no sampling/tolerance/truncation. Runtime/source/native/C hashes, raw commands and complete reports must bind execution before scoped host qualification. No new financial model/type/wire/operation/route/export/dependency or execution authority; all2154 required records/owners and remaining native/model/convention/Windows/Excel/WASM/certificate gaps remain. CVX063 stays partial/unaccepted/uncertified; local commits only. CVX-063 explicit Treasury bill convention: full supplied original USD face/redemption, issue/settlement/maturity and complete zero/calendar/metadata survive pure native and shared price, bank-discount and investment quote routes. Independent Decimal60 published-equation controls precede candidate; short/long/365/366/seasoned/par/premium, both inverses, actual residual and h/h2 analytic signed sensitivities have separate checks. Auction rounding stays separate. Checked arithmetic and96-step refinement fail without fallback; February29 anniversary and other unadmitted economics fail explicitly. Original money-market/zero kernels and bill_conversions guards remain, no mathematical bodies/models/calendars/claims/hedges are changed. Preserve1595 old numeric rows/bytes/defaults,4975 original requests/77routes and literal2154Planned records. New full controls and3exact subset routes are additive only; actual host/runtime/source/binary and financial evidence must bind gates. No convention/model/market/library/production approval or execution authority. CVX063 remains partial/unaccepted/uncertified. CVX-063 checked date-free bill cleanup: remove the wrong Decimal long-bill branch and two unused floating-point bill functions/reexports. Retain fixed365 simple percent yield for1..182days with exact arithmetic ordering, positive cash and checked overflow failure. Actual predeclared controls reproduce the old negative long-bill answer and invalid-face success;25exact-fraction/Decimal60 short answers,6long rejections and invalid/overflow cases remain independent. Full original dated Treasury owner, zero/shared API/80routes/5375requests/models/history/claims/hedges and all1611old numeric rows/files/defaults stay unchanged. Remaining infallible ShortDateCalculator/ZeroCouponBond bill methods still require checked migration; no original convention/model/host requirement is waived. No new export/dependency/clock/provider/runtime/ledger/order authority. CVX063 remains partial/unaccepted/uncertified; engineering checks cannot supply independent certificates. CVX-063 complete bill-method migration: six object methods now return checked Results and delegate to one pure Decimal owner for explicit face/360 bank discount, price/360 money market and fixed365 simple rate/conversion. Preserve decimal object/kernel versus percent analytics units, signed discount/par/premium values, exact bounded integer admission at the MMY pole, and positive cash/active original zero dates/face-redemption/overflow failures. Remove two unused infallible f64 discount functions and reexports; no successful fallback or alias. Long investment-rate terms and Treasury365/366 use the unchanged full original-date Treasury owner. Forty cash/42rate independent Fraction/Decimal60 controls precede production edits; nine supplementary pole controls precede their first native comparison, after that code edit. Preserve all1614original numerical rows/files/defaults and full5375requests/80routes plus literal2154Planned requirements. No generic zero pricing/compounding/calendar/model/curve/claim/hedge/CTD/math change outside the selected conversion owner, no new dependency/C export/wire/provider/clock/MQ/ledger/order authority. Full actual source-bound checks determine qualification; CVX063 remains partial/unaccepted/uncertified, Windows/Excel and broader original convention/model/claim/host/production/latency/certificate scope stay open. CVX-063 checked zero-coupon methods: original absolute contractual redemption cash, active original dates/positive face/redemption, checked context-free year fractions and five original compounding variants. Price/yield/continuous/convert_yield and discrete-period APIs return typed Results; no zero/face fallback or continuous-period panic. Exact Decimal admission and cash residuals precede binary64 projection; preserve small nonzero rates and positive periodic tails. Same-convention conversion validates its domain and preserves the original Decimal. Independent Decimal80 declaration precedes production edits:300prices/375inverses/125conversions/12poles, original absolute1e-12/relative5e-13 bounds and strict nonzero signs. Preserve1621old numerical rows/files/defaults,5375requests/80routes and literal2154Planned records. Existing bill/Treasury/shared per100 quote/curve/model/claim/hedge/CTD owners, constructors/serde/trait mechanics and dependencies/C exports/wires remain unchanged. Current full source-bound native/C/real Java FFM and scoped wasm32 runs determine engineering qualification. The user authorizes continuing remaining CVX063 local work and explicitly deferring unavailable platform checks; no native requirement or independent certificate is waived. CVX-063 lossless raw zero-coupon terms: serialize/decode issue price, face and redemption using existing ExactDecimal strings; retain all11original fields and exact scale. Typed day-count serde replaces two manual mapping helpers and rejects unknown conventions. Raw zero objects and common identifier containers reject ignored fields. Structural metadata decoding stays separate from checked economic/pricing admission. Original151metadata roundtrips/40rejections precede edits;24supplementary ExactDecimal ingress rejections are declared after repair and before their comparison. Preserve every1626old numerical row/file/default and all5375requests/80routes, original30areas/174combinations/2154Planned definitions. Pricing/curve/model/history/bill/Treasury/claim/hedge/CTD bodies, all old positive unit controls, constructors, dependencies and C exports remain. No raw-number compatibility alias, unknown-daycount substitute, ignored payoff or native financial oracle. Existing full shared ZeroInput already uses ExactDecimal; its wire/schema remain unchanged. User authorizes continuing remaining CVX063 local work, retaining unavailable platform checks as deferred with no native-scope waiver; independent certificates/production/latency keep their owners. CVX-063 shared portable numerical kernels: existing locked libm0.2.15 force-soft-floats and nalgebra0.34.1 libm-force; no external package/version, SVD algorithm, model parameter, financial answer, tolerance or work bound changes. Initial48files/164calls and one supplemental advertised WASM curve call retain original operators/arguments/bounds/metadata/positive controls under structural source audit. Independent373exact-binary Decimal controls use a separately predeclared4ULP primitive bound; original financial bounds remain. Retain complete failed host captures and mechanical receiver/clamp repair. Actual current native/C/Java/WASM fields and diagnostics remain unfiltered. Every1633original numeric row/file/default,5375requests/80routes and30areas/174combinations/2154Planned definitions stay. Correct only36stale LC027 pure solver assessments; authenticated fills/orders/limits remain CVX025/031. Full remaining native model/convention/claim/operation and advertised convenience scope, unavailable platforms, independent CVX064/065 and measured production/latency gates retain their owners. Engineering evidence never supplies independent approval. CVX-063 exact supplied term Decimal ingress: reuse existing ExactDecimal through scalar/nullable serde adapters for seven native Decimal fields and five IRS/OIS dated profile paths. Preserve all native arithmetic, economic admission, metadata fields, optional null/omission and original bounded profiles; remove duplicated rounding profile wire parser. Independently predeclare150complete metadata roundtrips,260invalid/lossy inputs,7duplicate rejections and48compatible compact aliases before the corresponding production grammar repair; retain both first declarations and original positive curve-regression failures, retaining original numeric-ingress failure. Preserve all1640original numerical rows/defaults/files/protected sections,5375requests/80routes and literal30areas/174combinations/2154Planned records. Complete original native models/conventions/claims/operations and advertised convenience scope remains; only unavailable platforms deferred. Independent CVX064/065 and measured production/latency owners remain; authenticated authority remains CVX025/031. No certificate or engineering acceptance promotion. CVX-063 capability validation checks the unchanged mandatory dimensions/status/owners before physical source reads. Valid metadata still verifies every source hash and code/test anchor on each invocation. No cache, source-evidence shortcut, numerical/native/host/dependency change or acceptance/certificate promotion. Three independently predeclared controls cover invalid scope before I/O, every valid pin, and changed source between calls. Retain all original30areas/174combinations/2154Planned records,80routes/5375requests,1647numerical rows/defaults/files/protected sections and preceding sealed packets. Remaining native models/conventions/claims/operations and advertised conveniences retain original scope; only unexecutable platforms deferred. CVX-063 exact original8Rust-only LC001/LC002 schedule/profile/fixing operations bind actual public methods, original positive/boundary/negative controls and all original default/no-default/schema profiles through tools/library/native-operations.json. No new JSON operation or required surface waiver. A standalone composed native consumer preserves known/revision/receipt/future distinctions and independently dated coupon/spread/gearing/notional steps, actual delayed coupon/principal cash and forecast-only shocks. Independently declared Decimal80 six coupon/three principal answers retain1e-24cash bound before comparison. First metadata control caught unrelated Rust-operation assignment; exact original operation identity is now required. Every original1647numeric row/default/file/section,80shared routes/5375requests and literal30areas/174combinations/2154Planned requirement remains. Five additive controls give1652. Eight new native mappings supplement192shared mappings without certification. Full remaining native model/convention/claim/physical/operation and advertised convenience scope stays; only unexecutable platforms deferred. CVX-063 retire the incorrect callable WASM convenience: finite call-date list labeledAmerican/default1%volatility/Z-spread substituted asOAS on model failure. RemoveCallScheduleEntry/call_schedule/volatility andcallable-only output fields, branch, demo senders/panel/placeholder displays/deadstyles. Strict fixed BondParams rejects retired/null/empty/unknown payoff fields before straight pricing. Complete shared optional_bond original fullterms/model/calendar/history/notice/OAS/risk/diagnostics remain unchanged; nativeAmerican/joint/capfloor and other mandatory scopes remain required. Oldfixed normal inputs andnative financial operators/references/bounds remain; other convenience parsers/units/unsupported semantics remain explicit work. Preserve1652oldnumeric rows/default/files/sections,80shared/8native routes/5375requests andliteral30areas/174combinations/2154Planned requirements. One new admission pin gives1653. Actualavailable wasm/browser must execute; only unexecutable platforms deferred. No certificate or acceptance promotion. Actual browser exposed serde-wasm-bindgen struct-field enumeration bypassing deny_unknown_fields. Shared bounded own-field ingress guards all six fixed bond functions, rejects symbols/nonplain inputs and prevents inherited values. Invalid cashflows return error objects; actual full JS admission required. Preserve first native/JS failures and first frozen source;1652old numeric pins+2new=1654. No native math change or requirement waiver. CVX-063 checked fixed WASM numeric/enum ingress and units: preserve finite JSNumber shortest decimal, exact coefficient percent conversion and old positive aliases/assertions; no round10dp/zero/unknown enum/failed yield fallback. Bound own curve records, reject nonfixed classes, respect explicit overrides after registered presets and fail invalid/nonfinite pricing domains. Per100 price/yield/risk/accrued/current yield separate from absolute currency cash; explicit supplied face redemption repairs original terminal cash bug. Retire unused yield curve argument/caller and unsupported fixed UI payoff choices.66negative/15quote-face/3cash-face/16domain/10curve/1override controls and5native controls are scoped engineering. Old native formulas/solver bounds and1654numeric rows/defaults/files/sections remain;2new give1656. Literal30areas/174combinations/2154Planned and80shared/8native/5375requests remain. Known broader legacy convention/quote-risk limits and full original native models/claims/operations are not waived. Only unavailable platforms deferred; no certificate/acceptance. Exact original Rust-only LC-015 curve operations bind all forty records across ten original combinations to concrete native methods and full positive/boundary/negative controls. Every original curve consumer runs all default/no-default/schema profiles; a direct facade consumer preserves29original construction and9parametric-fit failures with exact terms and error categories. Original8primitive mappings/commands stay. Forty candidate engineering assessments cannot promote any Planned certificate or waive required JSON/C/model/convention/claim scope. No native numerical source/fixture/dependency/solver bounds changes. Original1656numeric rows/defaults/files/sections remain;1new control pin gives1657. Original30areas/174combinations/2154Planned and80shared/5375requests stay;12native routes give228unique exact mapped and1926unmapped records. Full original financial and actual available hosts must qualify compiled capability metadata. No acceptance/certificate. CVX-063 convenience output retirement: remove unbound price-only ASW from WASM/v1YAS wire types, curated schema and browser/Excel grid callers; remove unused v1YAS OAS output. Retained legacy native YAS diagnostics stay unqualified, while genuine complete native/library ASW/OAS/model/risk/hedge requirements remain. Browser supplies native measures without zero substitution and scales per100DV01 loss to explicit invoice face; estimated benchmark/risk/proceeds hedges, inert DV10/DV25 selector and seven dead style blocks are removed. Actual C/JS output/schema and browser controls qualify this scoped retirement. All1657old numerical rows/defaults/files/sections remain;2new give1659. All30areas/174combinations/2154Planned,80shared/12native/5375requests/228unique mapped1926unmapped remain. No native requirement waiver or independent approval. Excel source cleanup is reviewable but Windows/Excel execution remains deferred. CVX-063 native fixed WASM cash components: actual CashFlowType replaces amount thresholds; actual fixed redemption and checked Decimal split combined coupon/principal, preserving absolute amount/date and fee identity without zero substitution. Browser consumes native components; retire5percent fallback/semiannual/row-index PV,100principal assumption, PV/cumulative fields and dead styles. Non-array native cash errors are explicit and clear dependent state. Literal25.015625coupon/1000.625principal/1025.640625terminal and1e-12bounds precede candidate execution across faces/frequencies. Original1659numerical rows/defaults/files/sections remain;2new give1661. Literal30areas/174combinations/2154Planned,80shared/12native/5375requests/228unique mappings1926unmapped remain. Genuine native conventions/models/claims/physical/operations remain required. No capability promotion/acceptance/certificate; only unexecutable platform checks deferred. CVX-063 checked native dated-cash PV and same-basis yield derivatives/risk. Absolute cash, actual settlement-to-payment context-free time and selected annual/semiannual/quarterly/monthly/daily/continuous/simple discounting bind price, first/second annual-rate derivatives, PV-weighted time, modified duration, convexity and DV01 loss. Periodic log1p preserves small rates without changing prior kernels. WASM solves and reprices the same cash/basis at unchanged1e-8per100 bound; unrelated YAS risk and money-market rollforward are retired with unused conversion/UI. Output identifies actual Dated cash basis, and Z-spread failure has a separate explicit diagnostic. Full-context ICMA, ActualPeriod, bill-discount and positive ex-dividend settings cannot be silently substituted. Original native YAS/solver/math bodies, original1661numeric rows/defaults/files and every mandatory30areas/174combinations/2154Planned record remain. Four new independent/control pins give1665. All80shared/12native/5375requests/228mapped1926unmapped definitions stay. No native scope waiver, independent approval, acceptance or certificate; only unexecutable platform checks defer.", @@ -1126,7 +1126,6 @@ "crates/convex-engine/src/curve_builder.rs", "crates/convex-engine/src/curve_replay.rs", "crates/convex-engine/src/lib.rs", - "crates/convex-engine/src/market_data_listener.rs", "crates/convex-engine/src/pricing_router.rs", "crates/convex-engine/src/runtime/worker.rs", "crates/convex-engine/src/snapshot.rs",