From 91cc40f2f314ae7c94f3c04abe34c1cbb36247a6 Mon Sep 17 00:00:00 2001 From: Ewig Midori Date: Tue, 19 May 2026 08:18:18 +0000 Subject: [PATCH 1/2] feat: add initial execution framework --- crates/exh-kit/examples/adaptive_ioc_port.rs | 651 ++++++++++++++++++ .../examples/basket_parent_allocator.rs | 123 ++++ crates/exh-kit/examples/deadline_catchup.rs | 142 ++++ .../examples/hierarchical_iceberg_oehrl.rs | 505 ++++++++++++++ crates/exh-kit/examples/maker_ladder.rs | 114 +++ .../examples/market_limit_rl_switch.rs | 239 +++++++ crates/exh-kit/examples/robust_vwap.rs | 181 +++++ crates/exh-kit/examples/target_aware_pov.rs | 222 ++++++ crates/exh-kit/src/adapters.rs | 30 + crates/exh-kit/src/child_order.rs | 271 ++++++++ crates/exh-kit/src/constraints.rs | 426 ++++++++++++ crates/exh-kit/src/features.rs | 190 +++++ crates/exh-kit/src/lib.rs | 13 + crates/exh-kit/src/lifecycle.rs | 102 +++ crates/exh-kit/src/multi_asset.rs | 18 + crates/exh-kit/src/multi_asset/coordinator.rs | 208 ++++++ crates/exh-kit/src/multi_asset/helpers.rs | 60 ++ crates/exh-kit/src/multi_asset/lifecycle.rs | 184 +++++ crates/exh-kit/src/multi_asset/schema.rs | 159 +++++ crates/exh-kit/src/multi_asset/state.rs | 110 +++ crates/exh-kit/src/primitives.rs | 75 ++ crates/exh-kit/src/progress.rs | 89 +++ crates/exh-kit/src/schedule.rs | 325 +++++++++ crates/exh-kit/src/schema.rs | 169 +++++ crates/exh-kit/src/signals.rs | 57 ++ crates/exh-kit/src/strategy_prelude.rs | 32 + crates/exh-kit/src/testing.rs | 393 +++++++++++ crates/exh-kit/tests/child_order.rs | 383 +++++++++++ crates/exh-kit/tests/constraints.rs | 179 +++++ crates/exh-kit/tests/features.rs | 138 ++++ crates/exh-kit/tests/lifecycle.rs | 161 +++++ crates/exh-kit/tests/multi_asset.rs | 375 ++++++++++ crates/exh-kit/tests/progress.rs | 66 ++ crates/exh-kit/tests/schedule.rs | 211 ++++++ crates/exh-kit/tests/schema.rs | 173 +++++ crates/exh-kit/tests/strategy_prelude.rs | 214 ++++++ crates/exh-kit/tests/testing.rs | 234 +++++++ crates/exh/src/algorithm.rs | 296 ++++++++ crates/exh/src/driver.rs | 146 ++++ crates/exh/src/engine.rs | 11 + crates/exh/src/engine/core.rs | 641 +++++++++++++++++ crates/exh/src/engine/decision.rs | 173 +++++ crates/exh/src/engine/lifecycle.rs | 93 +++ crates/exh/src/engine/state.rs | 337 +++++++++ crates/exh/src/engine/types.rs | 211 ++++++ crates/exh/src/error.rs | 26 + crates/exh/src/intent.rs | 103 +++ crates/exh/src/journal.rs | 425 ++++++++++++ crates/exh/src/lib.rs | 27 + crates/exh/tests/engine.rs | 550 +++++++++++++++ crates/exh/tests/support/lifecycle.rs | 558 +++++++++++++++ crates/exh/tests/support/mod.rs | 627 +++++++++++++++++ 52 files changed, 11446 insertions(+) create mode 100644 crates/exh-kit/examples/adaptive_ioc_port.rs create mode 100644 crates/exh-kit/examples/basket_parent_allocator.rs create mode 100644 crates/exh-kit/examples/deadline_catchup.rs create mode 100644 crates/exh-kit/examples/hierarchical_iceberg_oehrl.rs create mode 100644 crates/exh-kit/examples/maker_ladder.rs create mode 100644 crates/exh-kit/examples/market_limit_rl_switch.rs create mode 100644 crates/exh-kit/examples/robust_vwap.rs create mode 100644 crates/exh-kit/examples/target_aware_pov.rs create mode 100644 crates/exh-kit/src/adapters.rs create mode 100644 crates/exh-kit/src/child_order.rs create mode 100644 crates/exh-kit/src/constraints.rs create mode 100644 crates/exh-kit/src/features.rs create mode 100644 crates/exh-kit/src/lib.rs create mode 100644 crates/exh-kit/src/lifecycle.rs create mode 100644 crates/exh-kit/src/multi_asset.rs create mode 100644 crates/exh-kit/src/multi_asset/coordinator.rs create mode 100644 crates/exh-kit/src/multi_asset/helpers.rs create mode 100644 crates/exh-kit/src/multi_asset/lifecycle.rs create mode 100644 crates/exh-kit/src/multi_asset/schema.rs create mode 100644 crates/exh-kit/src/multi_asset/state.rs create mode 100644 crates/exh-kit/src/primitives.rs create mode 100644 crates/exh-kit/src/progress.rs create mode 100644 crates/exh-kit/src/schedule.rs create mode 100644 crates/exh-kit/src/schema.rs create mode 100644 crates/exh-kit/src/signals.rs create mode 100644 crates/exh-kit/src/strategy_prelude.rs create mode 100644 crates/exh-kit/src/testing.rs create mode 100644 crates/exh-kit/tests/child_order.rs create mode 100644 crates/exh-kit/tests/constraints.rs create mode 100644 crates/exh-kit/tests/features.rs create mode 100644 crates/exh-kit/tests/lifecycle.rs create mode 100644 crates/exh-kit/tests/multi_asset.rs create mode 100644 crates/exh-kit/tests/progress.rs create mode 100644 crates/exh-kit/tests/schedule.rs create mode 100644 crates/exh-kit/tests/schema.rs create mode 100644 crates/exh-kit/tests/strategy_prelude.rs create mode 100644 crates/exh-kit/tests/testing.rs create mode 100644 crates/exh/src/algorithm.rs create mode 100644 crates/exh/src/driver.rs create mode 100644 crates/exh/src/engine.rs create mode 100644 crates/exh/src/engine/core.rs create mode 100644 crates/exh/src/engine/decision.rs create mode 100644 crates/exh/src/engine/lifecycle.rs create mode 100644 crates/exh/src/engine/state.rs create mode 100644 crates/exh/src/engine/types.rs create mode 100644 crates/exh/src/error.rs create mode 100644 crates/exh/src/intent.rs create mode 100644 crates/exh/src/journal.rs create mode 100644 crates/exh/src/lib.rs create mode 100644 crates/exh/tests/engine.rs create mode 100644 crates/exh/tests/support/lifecycle.rs create mode 100644 crates/exh/tests/support/mod.rs diff --git a/crates/exh-kit/examples/adaptive_ioc_port.rs b/crates/exh-kit/examples/adaptive_ioc_port.rs new file mode 100644 index 0000000..d6e1318 --- /dev/null +++ b/crates/exh-kit/examples/adaptive_ioc_port.rs @@ -0,0 +1,651 @@ +use async_trait::async_trait; +use exh::{ + AdvanceInput, AdvanceOutcome, Driver, Engine, ExecutionId, ExecutionIntent, MemoryJournal, + OrderQuery, OrderRequest, +}; +use exh_kit::constraints::MarketConstraintService; +use exh_kit::strategy_prelude::{ + Algorithm, AlgorithmAuditSchema, AlgorithmDecision, AlgorithmDecisionSchemaExt, + AlgorithmStateSchema, ChildId, ChildOrderFactory, ChildOrderStyle, EvaluateContext, + SignalFrame, SpotChildOrderSpec, TargetProgressView, TerminalState, +}; +use mkt::prelude::{ + MarketInfo, QuantityModeSupport, Symbol, TradingConstraints, TradingPermissions, +}; +use mkt::types::{ + Decimal, ExchangeId, KnownExchange, LotSizeFilter, MarketKind, MarketQuantityMode, + MarketStatus, NotionalConstraints, Order, OrderBook, OrderBookLevel, OrderId, OrderQuantity, + OrderSide, OrderStatus, OrderType, PriceFilter, +}; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; +use time::{Duration, OffsetDateTime}; + +// This demo uses the sell/base-target half of top50-rotation's adaptive IOC loop. +// The kernel now also has quote-budget accounting for the corresponding buy path. + +#[derive(Debug, Clone)] +struct AdaptiveIocAlgo { + market: MarketInfo, + max_runtime: Duration, + slice_interval: Duration, + slippage_bps: Decimal, + max_slice_quantity: Decimal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum AdaptiveIocStopReason { + Completed, + MaxRuntime, + DustBelowMinNotional, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct AdaptiveIocRunState { + stop_reason: AdaptiveIocStopReason, + filled_base_quantity: Decimal, + remaining_base_quantity: Decimal, +} + +impl AlgorithmStateSchema for AdaptiveIocRunState { + const SCHEMA: &'static str = "adaptive_ioc.run_state"; + const VERSION: u32 = 1; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct AdaptiveIocStopAudit { + reason: AdaptiveIocStopReason, + filled_base_quantity: Decimal, + remaining_base_quantity: Decimal, +} + +impl AlgorithmAuditSchema for AdaptiveIocStopAudit { + const EVENT_TYPE: &'static str = "adaptive_ioc.stop"; + const VERSION: u32 = 1; +} + +impl AdaptiveIocAlgo { + fn stop_decision( + &self, + context: &EvaluateContext, + reason: AdaptiveIocStopReason, + terminal_state: TerminalState, + ) -> Result { + let state = AdaptiveIocRunState { + stop_reason: reason, + filled_base_quantity: context.snapshot.filled_base_quantity(), + remaining_base_quantity: context + .snapshot + .remaining_base_quantity() + .unwrap_or(Decimal::ZERO), + }; + let audit = AdaptiveIocStopAudit { + reason, + filled_base_quantity: state.filled_base_quantity, + remaining_base_quantity: state.remaining_base_quantity, + }; + AlgorithmDecision::finishing(terminal_state) + .with_typed_state(&state)? + .with_typed_audit(&audit) + } +} + +#[async_trait] +impl Algorithm for AdaptiveIocAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + let progress = TargetProgressView::from_snapshot(&context.snapshot); + if progress.is_complete() { + return self.stop_decision( + context, + AdaptiveIocStopReason::Completed, + TerminalState::Completed, + ); + } + + let started_at = context.snapshot.started_at.unwrap_or(context.observed_at); + if context.observed_at - started_at >= self.max_runtime { + return self.stop_decision( + context, + AdaptiveIocStopReason::MaxRuntime, + TerminalState::Aborted, + ); + } + + let book = match context.signals.order_book.as_ref() { + Some(book) => book, + None => { + return Ok( + AlgorithmDecision::paused().wake_at(context.observed_at + self.slice_interval) + ); + } + }; + + let Some(plan) = plan_slice( + &self.market, + book, + context.intent.side, + progress.remaining_value, + self.slippage_bps, + self.max_slice_quantity, + ) else { + return Ok( + AlgorithmDecision::paused().wake_at(context.observed_at + self.slice_interval) + ); + }; + + let constraints = MarketConstraintService::new(&self.market); + let min_quantity = constraints.min_quantity(OrderType::Limit).ok_or_else(|| { + exh::Error::PolicyViolation { + message: "adaptive IOC requires market min_quantity".to_owned(), + } + })?; + let min_notional = constraints.min_notional_or(Decimal::ZERO); + let planned_notional = plan.quantity * plan.limit_price; + if plan.quantity < min_quantity || planned_notional < min_notional { + if progress.remaining_value * plan.mid_price < min_notional { + return self.stop_decision( + context, + AdaptiveIocStopReason::DustBelowMinNotional, + TerminalState::Aborted, + ); + } + return Ok( + AlgorithmDecision::paused().wake_at(context.observed_at + self.slice_interval) + ); + } + + let id = ChildId::from_sequence(&context.intent.execution_id, "adaptive-ioc", 0)?; + let child = ChildOrderFactory::new(&self.market).spot_child_from_context( + context, + SpotChildOrderSpec::new( + id, + ChildOrderStyle::IocThroughBook, + plan.quantity, + plan.mid_price, + ) + .with_price_offset(protection_offset(plan.mid_price, plan.limit_price)), + )?; + + Ok(AlgorithmDecision::running(vec![child]) + .wake_at(context.observed_at + self.slice_interval)) + } +} + +#[derive(Debug, Clone, Copy)] +struct LiquidityLevel { + price: Decimal, +} + +#[derive(Debug, Clone, Copy)] +struct OrderBookBand { + mid_price: Decimal, + protection_price: Decimal, + executable_base_quantity: Decimal, +} + +#[derive(Debug, Clone, Copy)] +struct SlicePlan { + quantity: Decimal, + limit_price: Decimal, + mid_price: Decimal, +} + +fn plan_slice( + market: &MarketInfo, + book: &OrderBook, + side: OrderSide, + remaining_quantity: Decimal, + slippage_bps: Decimal, + max_slice_quantity: Decimal, +) -> Option { + let constraints = MarketConstraintService::new(market); + let band = scan_band(book, side, remaining_quantity, slippage_bps)?; + let step_size = constraints.step_size(OrderType::Limit)?; + let band_quantity = floor_to_step(band.executable_base_quantity, step_size); + let max_slice_quantity = floor_to_step(max_slice_quantity, step_size); + let quantity = floor_to_step( + band_quantity + .min(max_slice_quantity) + .min(remaining_quantity) + .max(Decimal::ZERO), + step_size, + ); + let limit_price = + protection_band_limit_price(side, band.protection_price, constraints.tick_size()); + + Some(SlicePlan { + quantity, + limit_price, + mid_price: band.mid_price, + }) +} + +fn scan_band( + book: &OrderBook, + side: OrderSide, + remaining_quantity: Decimal, + slippage_bps: Decimal, +) -> Option { + let mid_price = mid_price(book)?; + let protection_price = protection_price_from_mid(mid_price, side, slippage_bps)?; + let mut executable_base_quantity = Decimal::ZERO; + let mut remaining = remaining_quantity.max(Decimal::ZERO); + + for level in executable_levels(book, side) { + if remaining <= Decimal::ZERO || !level_within_band(level, side, protection_price) { + break; + } + let quantity = remaining.min(level.quantity); + executable_base_quantity += quantity; + remaining = (remaining - quantity).max(Decimal::ZERO); + } + + Some(OrderBookBand { + mid_price, + protection_price, + executable_base_quantity, + }) +} + +fn best_level(book: &OrderBook, side: OrderSide) -> Option { + let levels = match side { + OrderSide::Buy => &book.asks, + OrderSide::Sell => &book.bids, + _ => return None, + }; + levels + .iter() + .find(|level| level.price > Decimal::ZERO && level.quantity > Decimal::ZERO) + .map(|level| LiquidityLevel { price: level.price }) +} + +fn mid_price(book: &OrderBook) -> Option { + let bid = best_level(book, OrderSide::Sell)?; + let ask = best_level(book, OrderSide::Buy)?; + Some((bid.price + ask.price) / Decimal::from(2)) +} + +fn executable_levels(book: &OrderBook, side: OrderSide) -> impl Iterator { + let levels: &[OrderBookLevel] = match side { + OrderSide::Buy => book.asks.as_slice(), + OrderSide::Sell => book.bids.as_slice(), + _ => &[], + }; + levels + .iter() + .filter(|level| level.price > Decimal::ZERO && level.quantity > Decimal::ZERO) +} + +fn protection_price_from_mid( + mid_price: Decimal, + side: OrderSide, + slippage_bps: Decimal, +) -> Option { + if mid_price <= Decimal::ZERO { + return None; + } + + let slippage_fraction = slippage_bps / Decimal::from(10_000); + match side { + OrderSide::Buy => Some(mid_price * (Decimal::ONE + slippage_fraction)), + OrderSide::Sell => Some(mid_price * (Decimal::ONE - slippage_fraction)), + _ => None, + } +} + +fn level_within_band(level: &OrderBookLevel, side: OrderSide, protection_price: Decimal) -> bool { + match side { + OrderSide::Buy => level.price <= protection_price, + OrderSide::Sell => level.price >= protection_price, + _ => false, + } +} + +fn protection_band_limit_price( + side: OrderSide, + protection_price: Decimal, + tick_size: Option, +) -> Decimal { + match side { + OrderSide::Buy => floor_to_step(protection_price, tick_size.unwrap_or(Decimal::ONE)), + OrderSide::Sell => ceil_to_step(protection_price, tick_size.unwrap_or(Decimal::ONE)), + _ => protection_price, + } +} + +fn protection_offset(reference_price: Decimal, limit_price: Decimal) -> Decimal { + (limit_price - reference_price).abs() +} + +fn floor_to_step(value: Decimal, step: Decimal) -> Decimal { + if step <= Decimal::ZERO { + value + } else { + (value / step).floor() * step + } +} + +fn ceil_to_step(value: Decimal, step: Decimal) -> Decimal { + if step <= Decimal::ZERO { + value + } else { + (value / step).ceil() * step + } +} + +#[derive(Debug, Default)] +struct ScriptedIocVenueState { + fill_script: VecDeque, + placed_orders: Vec, +} + +#[derive(Debug, Clone, Default)] +struct ScriptedIocVenue { + state: Arc>, +} + +impl ScriptedIocVenue { + fn new(fill_script: Vec) -> Self { + Self { + state: Arc::new(Mutex::new(ScriptedIocVenueState { + fill_script: fill_script.into(), + placed_orders: Vec::new(), + })), + } + } + + fn placed_orders(&self) -> Vec { + self.state + .lock() + .expect("scripted venue mutex poisoned") + .placed_orders + .clone() + } +} + +#[async_trait] +impl Driver for ScriptedIocVenue { + async fn place_order(&self, request: OrderRequest) -> Result { + let OrderRequest::Spot(request) = request else { + return Err(exh::Error::DriverPlace { + message: "adaptive IOC example only supports spot requests".to_owned(), + }); + }; + let client_order_id = + request + .client_order_id + .clone() + .ok_or_else(|| exh::Error::DriverPlace { + message: "engine must assign a client order id before placing".to_owned(), + })?; + let OrderQuantity::Base(request_quantity) = request.quantity else { + return Err(exh::Error::DriverPlace { + message: "adaptive IOC example requires base-sized requests".to_owned(), + }); + }; + + let mut state = self.state.lock().expect("scripted venue mutex poisoned"); + let filled_quantity = state + .fill_script + .pop_front() + .unwrap_or(request_quantity) + .min(request_quantity) + .max(Decimal::ZERO); + let status = if filled_quantity >= request_quantity { + OrderStatus::Filled + } else { + OrderStatus::Expired + }; + let cumulative_quote_quantity = request.price.map(|price| price * filled_quantity); + let order = Order::builder() + .id(OrderId::new(format!("order-{}", client_order_id.0))) + .client_order_id(Some(client_order_id)) + .symbol(request.symbol.clone()) + .market_kind(request.symbol.kind) + .side(request.side) + .order_type(request.order_type) + .status(status) + .time_in_force(request.time_in_force) + .price(request.price) + .quantity(request_quantity) + .filled_quantity(filled_quantity) + .cumulative_quote_quantity(cumulative_quote_quantity) + .created_at(OffsetDateTime::UNIX_EPOCH) + .build() + .map_err(|message| exh::Error::DriverPlace { + message: message.to_string(), + })?; + state.placed_orders.push(order.clone()); + Ok(order) + } + + async fn query_order(&self, _query: OrderQuery) -> Result { + Err(exh::Error::DriverQuery { + message: "adaptive IOC example never queries orders".to_owned(), + }) + } + + async fn cancel_order(&self, _request: exh::CancelOrderRequest) -> Result { + Err(exh::Error::DriverCancel { + message: "adaptive IOC example never sends cancels".to_owned(), + }) + } +} + +fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("example decimal must be valid") +} + +fn adaptive_market(symbol: Symbol) -> MarketInfo { + MarketInfo::builder() + .exchange_id(ExchangeId::from(KnownExchange::Binance)) + .symbol(symbol) + .status(MarketStatus::Trading) + .base_asset("SOL") + .quote_asset("USDT") + .trading_permissions( + TradingPermissions::builder() + .spot_order_entry_allowed(Some(true)) + .supported_order_types(vec![OrderType::Limit]) + .quantity_mode_support(vec![ + QuantityModeSupport::builder() + .mode(MarketQuantityMode::Base) + .order_types(vec![OrderType::Limit]) + .sides(vec![OrderSide::Buy, OrderSide::Sell]) + .build() + .expect("quantity mode support must build"), + ]) + .build() + .expect("trading permissions must build"), + ) + .trading_constraints( + TradingConstraints::builder() + .price_filter(Some( + PriceFilter::builder() + .tick_size(Some(decimal("0.1"))) + .build() + .expect("price filter must build"), + )) + .lot_size(Some( + LotSizeFilter::builder() + .min_quantity(Some(decimal("0.1"))) + .step_size(Some(decimal("0.1"))) + .build() + .expect("lot size must build"), + )) + .notional(Some( + NotionalConstraints::builder() + .min_notional(Some(decimal("5"))) + .build() + .expect("notional constraints must build"), + )) + .build() + .expect("trading constraints must build"), + ) + .build() + .expect("market info must build") +} + +fn order_book(symbol: &Symbol, bids: &[(&str, &str)], asks: &[(&str, &str)]) -> OrderBook { + OrderBook::builder() + .symbol(symbol.clone()) + .bids( + bids.iter() + .map(|(price, quantity)| OrderBookLevel::new(decimal(price), decimal(quantity))) + .collect::>(), + ) + .asks( + asks.iter() + .map(|(price, quantity)| OrderBookLevel::new(decimal(price), decimal(quantity))) + .collect::>(), + ) + .build() + .expect("order book must build") +} + +fn frame(book: OrderBook) -> SignalFrame { + SignalFrame::builder() + .order_book(Some(book)) + .build() + .expect("signal frame must build") +} + +fn next_snapshot(outcome: AdvanceOutcome) -> exh::ExecutionSnapshot { + match outcome { + AdvanceOutcome::Progressed { snapshot, .. } + | AdvanceOutcome::Quiescent { snapshot, .. } + | AdvanceOutcome::Completed { snapshot } => snapshot, + _ => unreachable!("example handles all current advance outcomes"), + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let symbol = Symbol::spot("SOLUSDT"); + let market = adaptive_market(symbol.clone()); + let venue = ScriptedIocVenue::new(vec![decimal("0.6"), decimal("0.7"), decimal("0.7")]); + let journal = MemoryJournal::default(); + let start_at = OffsetDateTime::UNIX_EPOCH; + let engine = Engine::new( + venue.clone(), + journal, + AdaptiveIocAlgo { + market: market.clone(), + max_runtime: Duration::seconds(30), + slice_interval: Duration::seconds(1), + slippage_bps: decimal("50"), + max_slice_quantity: decimal("0.8"), + }, + ); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("adaptive-ioc-port")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Sell) + .target_quantity(decimal("2.0")) + .build()?; + let mut snapshot = engine.start(intent, start_at).await?; + + let books = vec![ + order_book( + &symbol, + &[("100.1", "0.6"), ("100.0", "0.4"), ("99.7", "2.0")], + &[("100.3", "1.0")], + ), + order_book( + &symbol, + &[("100.2", "0.7"), ("100.0", "0.6"), ("99.8", "1.5")], + &[("100.4", "1.0")], + ), + order_book( + &symbol, + &[("100.0", "1.2"), ("99.9", "1.0"), ("99.6", "1.0")], + &[("100.2", "1.0")], + ), + ]; + + for (index, book) in books.into_iter().enumerate() { + let observed_at = start_at + Duration::seconds(i64::try_from(index + 1).unwrap_or(1)); + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new(observed_at, frame(book), vec![]), + ) + .await?; + let next_wake_at = outcome.next_wake_at(); + snapshot = next_snapshot(outcome); + println!( + "tick={} filled={} remaining={} active_children={} next_wake_at={:?} terminal={:?}", + index + 1, + snapshot.filled_base_quantity().normalize(), + snapshot + .remaining_base_quantity() + .unwrap_or(Decimal::ZERO) + .normalize(), + snapshot.active_children.len(), + next_wake_at, + snapshot.terminal_state + ); + } + + if !snapshot.is_terminal() { + snapshot = next_snapshot( + engine + .advance( + &snapshot, + AdvanceInput::new( + start_at + Duration::seconds(4), + SignalFrame::builder() + .build() + .expect("empty signal frame must build"), + vec![], + ), + ) + .await?, + ); + } + + for (index, order) in venue.placed_orders().into_iter().enumerate() { + println!( + "slice={} status={} requested={} filled={} price={}", + index + 1, + order.status, + order.quantity.normalize(), + order.filled_quantity.normalize(), + order.price.unwrap_or(Decimal::ZERO).normalize() + ); + } + if let Some(run_state) = AdaptiveIocRunState::load(&snapshot.algorithm_state)? { + println!( + "typed_state stop_reason={:?} filled={} remaining={}", + run_state.stop_reason, + run_state.filled_base_quantity.normalize(), + run_state.remaining_base_quantity.normalize() + ); + } + for audit in AdaptiveIocStopAudit::collect_from(&snapshot.algorithm_audit_events)? { + println!( + "typed_audit reason={:?} filled={} remaining={}", + audit.reason, + audit.filled_base_quantity.normalize(), + audit.remaining_base_quantity.normalize() + ); + } + println!( + "terminal={:?} total_filled={} remaining={}", + snapshot.terminal_state, + snapshot.filled_base_quantity().normalize(), + snapshot + .remaining_base_quantity() + .unwrap_or(Decimal::ZERO) + .normalize() + ); + + Ok(()) +} diff --git a/crates/exh-kit/examples/basket_parent_allocator.rs b/crates/exh-kit/examples/basket_parent_allocator.rs new file mode 100644 index 0000000..51a3aad --- /dev/null +++ b/crates/exh-kit/examples/basket_parent_allocator.rs @@ -0,0 +1,123 @@ +use exh::{ExecutionId, ExecutionIntent, ExecutionProgress, ExecutionSnapshot}; +use exh_kit::strategy_prelude::{ + MultiAssetCoordinator, MultiAssetLeg, MultiAssetPlan, ParentAuditSchema, ParentExecutionRunner, + ParentRunLifecycleEffects, ParentRunLifecycleEffectsSchemaExt, ParentRunState, + ParentStateSchema, +}; +use exh_kit::testing::decimal; +use mkt::types::{Decimal, MarketKind, OrderSide, Symbol}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct BasketAllocationState { + rebalance_count: u64, + requested_parent_budget: Decimal, +} + +impl ParentStateSchema for BasketAllocationState { + const SCHEMA: &'static str = "basket_allocator.state"; + const VERSION: u32 = 1; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct BasketAllocationAudit { + parent_completion: Decimal, + first_leg_budget: Decimal, + second_leg_budget: Decimal, +} + +impl ParentAuditSchema for BasketAllocationAudit { + const EVENT_TYPE: &'static str = "basket_allocator.allocation"; + const VERSION: u32 = 1; +} + +fn intent(id: &str, symbol: &str, target: &str) -> ExecutionIntent { + ExecutionIntent::builder() + .execution_id(ExecutionId::new(id)) + .symbol(Symbol::spot(symbol)) + .market_kind(MarketKind::Spot) + .side(OrderSide::Sell) + .target_quantity(decimal(target)) + .build() + .expect("example intent must build") +} + +fn snapshot(intent: ExecutionIntent, filled: &str) -> ExecutionSnapshot { + let mut snapshot = ExecutionSnapshot::from_intent(intent); + snapshot.progress = ExecutionProgress::zero(); + snapshot.progress.filled_base_quantity = decimal(filled); + snapshot.progress.cumulative_quote_quantity = Decimal::ZERO; + snapshot +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let first = intent("basket-leg-btc", "BTCUSDT", "10"); + let second = intent("basket-leg-eth", "ETHUSDT", "20"); + let runner = ParentExecutionRunner::new(MultiAssetCoordinator::new(MultiAssetPlan::new( + "basket-shortfall-demo", + vec![ + MultiAssetLeg::new(first.clone(), decimal("60")), + MultiAssetLeg::new(second.clone(), decimal("40")), + ], + )?)); + let snapshots = vec![snapshot(first, "4"), snapshot(second, "14")]; + let recorded_at = OffsetDateTime::UNIX_EPOCH; + let run = runner + .allocate_and_advance_with_parent_lifecycle( + &snapshots, + decimal("25"), + ParentRunState::new(), + |snapshot, allocation| { + std::future::ready(Ok::<_, exh::Error>(format!( + "{}:{}", + snapshot.intent.symbol.venue_symbol, + allocation.parent_budget.normalize() + ))) + }, + |context| { + let [first_allocation, second_allocation] = context.allocations else { + return Err(exh::Error::PolicyViolation { + message: "basket example expects two leg allocations".to_owned(), + }); + }; + let state = BasketAllocationState { + rebalance_count: 1, + requested_parent_budget: context.requested_parent_budget, + }; + let audit = BasketAllocationAudit { + parent_completion: context.parent_snapshot.completion_ratio(), + first_leg_budget: first_allocation.parent_budget, + second_leg_budget: second_allocation.parent_budget, + }; + ParentRunLifecycleEffects::new(recorded_at) + .with_typed_state(&state)? + .with_typed_audit(&audit) + }, + ) + .await?; + + let parent_run_state = run + .parent_run_state + .as_ref() + .expect("parent lifecycle run must return parent state"); + let loaded_state = BasketAllocationState::load_parent_state(&parent_run_state.state)? + .expect("parent state must decode"); + let decoded_audit = + BasketAllocationAudit::collect_parent_events(&parent_run_state.audit_events)? + .into_iter() + .next() + .expect("parent audit must decode"); + + println!( + "basket parent_completion={} requested={} first_leg_budget={} second_leg_budget={} state_rebalances={}", + decoded_audit.parent_completion.normalize(), + loaded_state.requested_parent_budget.normalize(), + decoded_audit.first_leg_budget.normalize(), + decoded_audit.second_leg_budget.normalize(), + loaded_state.rebalance_count + ); + println!("leg_outputs={:?}", run.leg_outputs); + Ok(()) +} diff --git a/crates/exh-kit/examples/deadline_catchup.rs b/crates/exh-kit/examples/deadline_catchup.rs new file mode 100644 index 0000000..8a811bf --- /dev/null +++ b/crates/exh-kit/examples/deadline_catchup.rs @@ -0,0 +1,142 @@ +use async_trait::async_trait; +use exh::{AdvanceInput, AdvanceOutcome, Engine, ExecutionId, ExecutionIntent, MemoryJournal}; +use exh_kit::strategy_prelude::{ + Algorithm, AlgorithmDecision, ChildBudgetPolicy, ChildId, ChildOrderFactory, ChildOrderStyle, + EvaluateContext, ParentSchedule, SignalFrame, SpotChildOrderSpec, TargetProgressView, + TerminalState, TimeWindow, +}; +use exh_kit::testing::{SimulatedVenue, spot_market_fixture}; +use mkt::prelude::MarketInfo; +use mkt::types::{Decimal, MarketKind, OrderSide, Symbol}; +use time::{Duration, OffsetDateTime}; + +#[derive(Debug, Clone)] +struct DeadlineCatchupAlgo { + market: MarketInfo, + schedule: ParentSchedule, +} + +#[async_trait] +impl Algorithm for DeadlineCatchupAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + let progress = TargetProgressView::from_snapshot(&context.snapshot); + if progress.is_complete() { + return Ok(AlgorithmDecision::finishing(TerminalState::Completed)); + } + + let price = context + .signals + .last_price + .as_ref() + .ok_or_else(|| exh::Error::PolicyViolation { + message: "deadline strategy requires last_price".to_owned(), + })? + .price; + let schedule = self + .schedule + .evaluate(progress.clone(), context.observed_at)?; + + let (style, price_offset, target_value, tag) = + if schedule.scheduled_completion < Decimal::new(8, 1) { + let target_value = ChildBudgetPolicy::new() + .with_min_child_value(Decimal::ONE) + .target_child_value(&progress, &schedule, None)? + .min(Decimal::ONE); + ( + ChildOrderStyle::Passive, + -Decimal::ONE, + target_value, + "deadline-passive", + ) + } else { + ( + ChildOrderStyle::Aggressive, + Decimal::ONE, + progress.remaining_value, + "deadline-ioc", + ) + }; + + if target_value <= Decimal::ZERO { + return Ok( + AlgorithmDecision::paused().wake_at(context.observed_at + Duration::seconds(5)) + ); + } + + let id = ChildId::from_sequence(&context.intent.execution_id, tag, 0)?; + let child = ChildOrderFactory::new(&self.market).spot_child_from_context( + context, + SpotChildOrderSpec::new(id, style, target_value, price).with_price_offset(price_offset), + )?; + + Ok(AlgorithmDecision::running(vec![child])) + } +} + +fn deadline_market(symbol: Symbol) -> MarketInfo { + spot_market_fixture(symbol) +} + +fn frame(symbol: &Symbol, price: i64) -> SignalFrame { + SignalFrame::builder() + .last_price(Some(mkt::types::LastPrice::new( + symbol.clone(), + Decimal::new(price, 0), + ))) + .build() + .expect("signal frame builder cannot fail") +} + +fn next_snapshot(outcome: AdvanceOutcome) -> exh::ExecutionSnapshot { + match outcome { + AdvanceOutcome::Progressed { snapshot, .. } + | AdvanceOutcome::Quiescent { snapshot, .. } + | AdvanceOutcome::Completed { snapshot } => snapshot, + _ => unreachable!("example handles all current advance outcomes"), + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let symbol = Symbol::spot("SOLUSDT"); + let start_at = OffsetDateTime::now_utc(); + let venue = SimulatedVenue::default(); + let journal = MemoryJournal::default(); + let engine = Engine::new( + venue, + journal, + DeadlineCatchupAlgo { + market: deadline_market(symbol.clone()), + schedule: ParentSchedule::linear(TimeWindow::new( + start_at, + start_at + Duration::minutes(10), + )), + }, + ); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("deadline-demo")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target_quantity(Decimal::new(2, 0)) + .build()?; + let snapshot = engine.start(intent, start_at).await?; + let snapshot = next_snapshot( + engine + .advance( + &snapshot, + AdvanceInput::new(start_at + Duration::minutes(9), frame(&symbol, 150), vec![]), + ) + .await?, + ); + println!( + "deadline active_children={}, remaining={}", + snapshot.active_children.len(), + snapshot.remaining_base_quantity().unwrap_or(Decimal::ZERO) + ); + Ok(()) +} diff --git a/crates/exh-kit/examples/hierarchical_iceberg_oehrl.rs b/crates/exh-kit/examples/hierarchical_iceberg_oehrl.rs new file mode 100644 index 0000000..da14553 --- /dev/null +++ b/crates/exh-kit/examples/hierarchical_iceberg_oehrl.rs @@ -0,0 +1,505 @@ +use async_trait::async_trait; +use exh::{AdvanceInput, Engine, EngineConfig, ExecutionId, ExecutionIntent, MemoryJournal}; +use exh_kit::primitives::{micro_price, remaining}; +use exh_kit::strategy_prelude::{ + Algorithm, AlgorithmAuditSchema, AlgorithmDecision, AlgorithmLifecycleEffects, + AlgorithmLifecycleEffectsSchemaExt, AlgorithmStateSchema, ChildBudgetPolicy, ChildId, ChildKey, + ChildOrderFactory, ChildOrderStyle, EvaluateContext, LifecycleContext, LifecycleEventKind, + LifecycleEventView, ParentSchedule, ScheduleEvaluation, SignalFeatureSchema, SignalFrame, + SignalFrameFeatureExt, SpotChildOrderInput, TargetProgressView, TerminalState, TimeWindow, +}; +use exh_kit::testing::{ + SimulatedVenue, book_frame, decimal, filled_active_child, spot_market_fixture, +}; +use mkt::prelude::MarketInfo; +use mkt::types::{Decimal, MarketKind, OrderSide, OrderStatus, Symbol}; +use serde::{Deserialize, Serialize}; +use time::{Duration, OffsetDateTime}; + +#[derive(Debug, Clone)] +struct HierarchicalIcebergAlgo { + market: MarketInfo, + schedule: ParentSchedule, + macro_interval: Duration, + base_child_quantity: Decimal, + max_child_quantity: Decimal, + passive_offset: Decimal, + aggressive_offset: Decimal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum PlacementStyle { + Passive, + Aggressive, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum LifecycleAuditKind { + Placed, + PlaceRejected, + Canceled, + Observed, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct OehrlLobFeatures { + queue_imbalance: Decimal, + urgency_score: Decimal, + alpha_score: Decimal, +} + +impl Default for OehrlLobFeatures { + fn default() -> Self { + Self { + queue_imbalance: Decimal::ZERO, + urgency_score: Decimal::ZERO, + alpha_score: Decimal::ZERO, + } + } +} + +impl SignalFeatureSchema for OehrlLobFeatures { + const EXTENSION_KEY: &'static str = "features.oehrl_lob"; + const SCHEMA: &'static str = "oehrl.lob_features"; + const VERSION: u32 = 1; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct HierarchicalIcebergState { + macro_index: u32, + next_child_index: u32, + confirmed_child_count: u32, + last_event: Option, + last_child_key: Option, + last_limit_price: Option, +} + +impl HierarchicalIcebergState { + fn new(macro_index: u32) -> Self { + Self { + macro_index, + next_child_index: 0, + confirmed_child_count: 0, + last_event: None, + last_child_key: None, + last_limit_price: None, + } + } + + fn record_placed(mut self, child_key: &ChildKey, limit_price: Option) -> Self { + self.next_child_index = self.next_child_index.saturating_add(1); + self.confirmed_child_count = self.confirmed_child_count.saturating_add(1); + self.last_event = Some(LifecycleAuditKind::Placed); + self.last_child_key = Some(child_key.0.clone()); + self.last_limit_price = limit_price; + self + } + + fn record_canceled(mut self, child_key: &ChildKey) -> Self { + self.last_event = Some(LifecycleAuditKind::Canceled); + self.last_child_key = Some(child_key.0.clone()); + self + } +} + +impl AlgorithmStateSchema for HierarchicalIcebergState { + const SCHEMA: &'static str = "oehrl_iceberg.state"; + const VERSION: u32 = 1; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct HierarchicalIcebergAudit { + event: LifecycleAuditKind, + child_key: String, + client_order_id: Option, + requested_quantity: Option, + filled_quantity: Decimal, + limit_price: Option, + order_status: Option, + message: Option, +} + +impl AlgorithmAuditSchema for HierarchicalIcebergAudit { + const EVENT_TYPE: &'static str = "oehrl_iceberg.lifecycle"; + const VERSION: u32 = 1; +} + +#[async_trait] +impl Algorithm for HierarchicalIcebergAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + if !context.snapshot.active_children.is_empty() { + return Ok(AlgorithmDecision::keep_active(&context.snapshot) + .wake_at(context.observed_at + Duration::seconds(5))); + } + + let progress = TargetProgressView::from_snapshot(&context.snapshot); + if progress.is_complete() { + return Ok(AlgorithmDecision::finishing(TerminalState::Completed)); + } + + if context.observed_at >= self.schedule.window.end { + return Ok(AlgorithmDecision::finishing(TerminalState::Aborted)); + } + + let macro_index = self.macro_index(context.observed_at)?; + let state = self.load_state_for_macro(&context.algorithm_state, macro_index)?; + let features = context + .signals + .typed_feature_or_default::()?; + let schedule = self + .schedule + .evaluate(progress.clone(), context.observed_at)?; + let Some(child_value) = self.next_child_target_value(&progress, &schedule, &features) + else { + return Ok( + AlgorithmDecision::paused().wake_at(context.observed_at + Duration::seconds(5)) + ); + }; + + let child_index = state.next_child_index; + let style = placement_style(&features, schedule.catch_up_pressure); + let child_id = ChildId::from_sequence( + &context.intent.execution_id, + &format!("oehrl-m{macro_index}"), + u64::from(child_index), + )?; + let child = ChildOrderFactory::new(&self.market).spot_child( + SpotChildOrderInput::builder() + .key(child_id.key) + .symbol(context.intent.symbol.clone()) + .side(context.intent.side) + .style(child_order_style(style)) + .target_value(child_value) + .reference_price(self.reference_price(context)?) + .price_offset(price_offset( + self.passive_offset, + self.aggressive_offset, + style, + )) + .client_order_id(child_id.client_order_id) + .progress(progress) + .build() + .map_err(|message| exh::Error::PolicyViolation { + message: message.to_string(), + })?, + )?; + + Ok(AlgorithmDecision::running(vec![child]) + .wake_at(context.observed_at + Duration::seconds(5))) + } + + async fn on_lifecycle( + &self, + context: &LifecycleContext, + ) -> Result { + let Some(view) = LifecycleEventView::from_event(&context.event) else { + return Ok(AlgorithmLifecycleEffects::new()); + }; + + match view.kind { + LifecycleEventKind::Placed => { + let macro_index = self.macro_index(context.observed_at)?; + let state = self + .load_state_for_macro(&context.snapshot.algorithm_state, macro_index)? + .record_placed(view.key, view.limit_price()); + let audit = lifecycle_audit(LifecycleAuditKind::Placed, &view); + AlgorithmLifecycleEffects::new() + .with_typed_state(&state)? + .with_typed_audit(&audit) + } + LifecycleEventKind::PlaceRejected => { + let audit = lifecycle_audit(LifecycleAuditKind::PlaceRejected, &view); + AlgorithmLifecycleEffects::new().with_typed_audit(&audit) + } + LifecycleEventKind::Canceled => { + let macro_index = self.macro_index(context.observed_at)?; + let state = self + .load_state_for_macro(&context.snapshot.algorithm_state, macro_index)? + .record_canceled(view.key); + let audit = lifecycle_audit(LifecycleAuditKind::Canceled, &view); + AlgorithmLifecycleEffects::new() + .with_typed_state(&state)? + .with_typed_audit(&audit) + } + LifecycleEventKind::Observed => { + let audit = lifecycle_audit(LifecycleAuditKind::Observed, &view); + AlgorithmLifecycleEffects::new().with_typed_audit(&audit) + } + _ => Ok(AlgorithmLifecycleEffects::new()), + } + } +} + +impl HierarchicalIcebergAlgo { + fn macro_index(&self, observed_at: OffsetDateTime) -> Result { + let elapsed_ms = (observed_at - self.schedule.window.start) + .whole_milliseconds() + .max(0); + let interval_ms = self.macro_interval.whole_milliseconds(); + if interval_ms <= 0 { + return Err(exh::Error::PolicyViolation { + message: "macro_interval must be positive".to_owned(), + }); + } + u32::try_from(elapsed_ms / interval_ms).map_err(|_| exh::Error::PolicyViolation { + message: "macro index exceeds supported range".to_owned(), + }) + } + + fn load_state_for_macro( + &self, + view: &exh::AlgorithmStateView, + macro_index: u32, + ) -> Result { + Ok(HierarchicalIcebergState::load(view)? + .filter(|state| state.macro_index == macro_index) + .unwrap_or_else(|| HierarchicalIcebergState::new(macro_index))) + } + + fn next_child_target_value( + &self, + progress: &TargetProgressView, + schedule: &ScheduleEvaluation, + features: &OehrlLobFeatures, + ) -> Option { + let urgent_floor = if features.urgency_score >= Decimal::new(6, 1) + || features.alpha_score > Decimal::ZERO + { + self.base_child_quantity + } else { + Decimal::ZERO + }; + let child_value = ChildBudgetPolicy::new() + .with_min_child_value(self.base_child_quantity) + .with_urgency_floor_value(urgent_floor) + .target_child_value_no_context(progress, schedule, None) + .min(self.max_child_quantity); + if child_value > Decimal::ZERO { + Some(child_value) + } else { + None + } + } + + fn reference_price( + &self, + context: &EvaluateContext, + ) -> Result { + micro_price(&context.signals) + .or_else(|| context.signals.last_price.as_ref().map(|price| price.price)) + .ok_or_else(|| exh::Error::PolicyViolation { + message: "hierarchical iceberg requires micro_price or last_price".to_owned(), + }) + } +} + +fn placement_style(features: &OehrlLobFeatures, catch_up_pressure: Decimal) -> PlacementStyle { + if features.urgency_score >= Decimal::new(6, 1) + || features.alpha_score > Decimal::ZERO + || catch_up_pressure >= Decimal::new(5, 1) + { + PlacementStyle::Aggressive + } else { + PlacementStyle::Passive + } +} + +fn child_order_style(style: PlacementStyle) -> ChildOrderStyle { + match style { + PlacementStyle::Passive => ChildOrderStyle::Passive, + PlacementStyle::Aggressive => ChildOrderStyle::Aggressive, + } +} + +fn price_offset( + passive_offset: Decimal, + aggressive_offset: Decimal, + style: PlacementStyle, +) -> Decimal { + match style { + PlacementStyle::Passive => passive_offset, + PlacementStyle::Aggressive => aggressive_offset, + } +} + +fn lifecycle_audit( + event: LifecycleAuditKind, + view: &LifecycleEventView<'_>, +) -> HierarchicalIcebergAudit { + HierarchicalIcebergAudit { + event, + child_key: view.key.0.clone(), + client_order_id: view.client_order_id_string(), + requested_quantity: view.requested_base_quantity(), + filled_quantity: view.filled_quantity(), + limit_price: view.limit_price(), + order_status: view.order_status(), + message: view.message.map(ToOwned::to_owned), + } +} + +fn frame( + symbol: &Symbol, + bid: i64, + ask: i64, + urgency_tenths: i64, + alpha_tenths: i64, +) -> SignalFrame { + let features = OehrlLobFeatures { + queue_imbalance: Decimal::new(4, 1), + urgency_score: Decimal::new(urgency_tenths, 1), + alpha_score: Decimal::new(alpha_tenths, 1), + }; + book_frame( + symbol, + Decimal::new(bid, 0), + Decimal::new(5, 0), + Decimal::new(ask, 0), + Decimal::new(3, 0), + ) + .with_typed_feature(&features) + .expect("typed OEHRL feature must attach") +} + +fn iceberg_market(symbol: Symbol) -> MarketInfo { + spot_market_fixture(symbol) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let symbol = Symbol::spot("ETHUSDT"); + let start_at = OffsetDateTime::UNIX_EPOCH; + let venue = SimulatedVenue::default(); + let journal = MemoryJournal::default(); + let schedule = + ParentSchedule::linear(TimeWindow::new(start_at, start_at + Duration::minutes(12))) + .with_catch_up_multiplier(decimal("1.5")); + let engine = Engine::with_config( + venue, + journal, + HierarchicalIcebergAlgo { + market: iceberg_market(symbol.clone()), + schedule, + macro_interval: Duration::minutes(4), + base_child_quantity: decimal("0.4"), + max_child_quantity: decimal("1.0"), + passive_offset: decimal("-0.2"), + aggressive_offset: decimal("0.8"), + }, + EngineConfig::builder() + .max_actions_per_advance(1usize) + .build() + .expect("engine config must build"), + ); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("oehrl-iceberg-demo")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target_quantity(decimal("1.2")) + .strategy_tag(Some("hierarchical-iceberg-oehrl".to_owned())) + .build()?; + let mut snapshot = engine.start(intent, start_at).await?; + + snapshot = engine + .advance( + &snapshot, + AdvanceInput::new( + start_at + Duration::minutes(1), + frame(&symbol, 2000, 2001, 2, -1), + vec![], + ), + ) + .await? + .into_snapshot(); + println!( + "tick=1 active_children={} confirmed_state={:?} filled={} remaining={}", + snapshot.active_children.len(), + HierarchicalIcebergState::load(&snapshot.algorithm_state)?, + snapshot.filled_base_quantity().normalize(), + remaining(&snapshot).normalize() + ); + + let first_fill = filled_active_child( + &snapshot, + &ChildKey::new("oehrl-m0-0"), + decimal("0.4"), + decimal("2000.8"), + )?; + snapshot = engine + .advance( + &snapshot, + AdvanceInput::new( + start_at + Duration::minutes(2), + frame(&symbol, 2001, 2002, 7, 2), + vec![exh::OrderUpdate::new(first_fill)], + ), + ) + .await? + .into_snapshot(); + println!( + "tick=2 active_children={} filled={} remaining={}", + snapshot.active_children.len(), + snapshot.filled_base_quantity().normalize(), + remaining(&snapshot).normalize() + ); + + let second_fill = filled_active_child( + &snapshot, + &ChildKey::new("oehrl-m0-1"), + decimal("0.4"), + decimal("2002.3"), + )?; + snapshot = engine + .advance( + &snapshot, + AdvanceInput::new( + start_at + Duration::minutes(5), + frame(&symbol, 2002, 2003, 8, 1), + vec![exh::OrderUpdate::new(second_fill)], + ), + ) + .await? + .into_snapshot(); + let state = HierarchicalIcebergState::load(&snapshot.algorithm_state)? + .expect("hierarchical iceberg state must be recorded after confirmed place"); + let audits = HierarchicalIcebergAudit::collect_from(&snapshot.algorithm_audit_events)?; + println!( + "tick=3 macro={} next_child={} lifecycle_audits={} filled={} remaining={}", + state.macro_index, + state.next_child_index, + audits.len(), + snapshot.filled_base_quantity().normalize(), + remaining(&snapshot).normalize() + ); + + let third_fill = filled_active_child( + &snapshot, + &ChildKey::new("oehrl-m1-0"), + decimal("0.4"), + decimal("2003.3"), + )?; + snapshot = engine + .advance( + &snapshot, + AdvanceInput::new( + start_at + Duration::minutes(6), + frame(&symbol, 2002, 2003, 8, 1), + vec![exh::OrderUpdate::new(third_fill)], + ), + ) + .await? + .into_snapshot(); + println!( + "tick=4 terminal={:?} filled={} remaining={} lifecycle_audits={}", + snapshot.terminal_state, + snapshot.filled_base_quantity().normalize(), + remaining(&snapshot).normalize(), + HierarchicalIcebergAudit::collect_from(&snapshot.algorithm_audit_events)?.len() + ); + Ok(()) +} diff --git a/crates/exh-kit/examples/maker_ladder.rs b/crates/exh-kit/examples/maker_ladder.rs new file mode 100644 index 0000000..8b0a276 --- /dev/null +++ b/crates/exh-kit/examples/maker_ladder.rs @@ -0,0 +1,114 @@ +use async_trait::async_trait; +use exh::{AdvanceInput, Engine, ExecutionId, ExecutionIntent, MemoryJournal}; +use exh_kit::strategy_prelude::{ + Algorithm, AlgorithmDecision, ChildId, ChildOrderFactory, ChildOrderStyle, EvaluateContext, + SignalFrame, SpotChildOrderSpec, TargetProgressView, TerminalState, +}; +use exh_kit::testing::{SimulatedVenue, spot_market_fixture}; +use mkt::prelude::MarketInfo; +use mkt::types::{Decimal, MarketKind, OrderSide, Symbol}; +use time::OffsetDateTime; + +#[derive(Debug, Clone)] +struct MakerLadderAlgo { + market: MarketInfo, +} + +#[async_trait] +impl Algorithm for MakerLadderAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + let progress = TargetProgressView::from_snapshot(&context.snapshot); + if progress.is_complete() { + return Ok(AlgorithmDecision::finishing(TerminalState::Completed)); + } + + let book = + context + .signals + .book_ticker + .as_ref() + .ok_or_else(|| exh::Error::PolicyViolation { + message: "maker ladder requires book_ticker".to_owned(), + })?; + let total = progress.remaining_value.min(Decimal::new(3, 0)); + let per_level = total / Decimal::new(3, 0); + let prices = [ + book.bid_price, + book.bid_price - Decimal::new(5, 1), + book.bid_price - Decimal::new(1, 0), + ]; + + let mut children = Vec::new(); + for (index, price) in prices.into_iter().enumerate() { + let sequence = u64::try_from(index).map_err(|_| exh::Error::PolicyViolation { + message: "ladder index exceeds supported child sequence range".to_owned(), + })?; + let id = ChildId::from_sequence(&context.intent.execution_id, "ladder", sequence)?; + let child = ChildOrderFactory::new(&self.market).spot_child_from_context( + context, + SpotChildOrderSpec::new(id, ChildOrderStyle::Passive, per_level, price), + )?; + children.push(child); + } + + Ok(AlgorithmDecision::running(children)) + } +} + +fn ladder_market(symbol: Symbol) -> MarketInfo { + spot_market_fixture(symbol) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let symbol = Symbol::spot("ETHUSDT"); + let venue = SimulatedVenue::default(); + let journal = MemoryJournal::default(); + let engine = Engine::new( + venue, + journal, + MakerLadderAlgo { + market: ladder_market(symbol.clone()), + }, + ); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("ladder-demo")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target_quantity(Decimal::new(3, 0)) + .build()?; + let snapshot = engine.start(intent, OffsetDateTime::UNIX_EPOCH).await?; + let frame = SignalFrame::builder() + .book_ticker(Some( + mkt::types::BookTicker::builder() + .symbol(symbol) + .bid_price(Decimal::new(1000, 0)) + .bid_quantity(Decimal::new(5, 0)) + .ask_price(Decimal::new(1001, 0)) + .ask_quantity(Decimal::new(4, 0)) + .build() + .expect("book ticker must build"), + )) + .build() + .expect("signal frame builder cannot fail"); + + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new(OffsetDateTime::UNIX_EPOCH, frame, vec![]), + ) + .await?; + let snapshot = match outcome { + exh::AdvanceOutcome::Progressed { snapshot, .. } + | exh::AdvanceOutcome::Quiescent { snapshot, .. } + | exh::AdvanceOutcome::Completed { snapshot } => snapshot, + _ => unreachable!("example handles all current advance outcomes"), + }; + println!("ladder active_children={}", snapshot.active_children.len()); + Ok(()) +} diff --git a/crates/exh-kit/examples/market_limit_rl_switch.rs b/crates/exh-kit/examples/market_limit_rl_switch.rs new file mode 100644 index 0000000..31eca7d --- /dev/null +++ b/crates/exh-kit/examples/market_limit_rl_switch.rs @@ -0,0 +1,239 @@ +use async_trait::async_trait; +use exh::{ + AdvanceInput, AdvanceOutcome, Algorithm, AlgorithmDecision, Engine, EvaluateContext, + ExecutionId, ExecutionIntent, MemoryJournal, TerminalState, +}; +use exh_kit::child_order::{ChildId, ChildOrderFactory, ChildOrderStyle, SpotChildOrderInput}; +use exh_kit::features::{SignalFeatureSchema, SignalFrameFeatureExt}; +use exh_kit::progress::TargetProgressView; +use exh_kit::schedule::{ParentSchedule, TimeWindow}; +use exh_kit::signals::SignalFrame; +use exh_kit::testing::{ + SimulatedVenue, book_frame, decimal, filled_active_child, spot_market_fixture, +}; +use mkt::prelude::MarketInfo; +use mkt::types::{Decimal, MarketKind, OrderSide, Symbol}; +use serde::{Deserialize, Serialize}; +use time::{Duration, OffsetDateTime}; + +#[derive(Debug, Clone)] +struct MarketLimitSwitchAlgo { + market: MarketInfo, + schedule: ParentSchedule, + passive_slice_quantity: Decimal, + aggressive_slice_quantity: Decimal, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct RlExecutionFeatures { + limit_order_value: Decimal, + market_order_value: Decimal, + queue_ahead_ratio: Decimal, +} + +impl SignalFeatureSchema for RlExecutionFeatures { + const EXTENSION_KEY: &'static str = "features.market_limit_rl"; + const SCHEMA: &'static str = "rl.market_limit_switch"; + const VERSION: u32 = 1; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Placement { + Passive, + Aggressive, +} + +#[async_trait] +impl Algorithm for MarketLimitSwitchAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + if !context.snapshot.active_children.is_empty() { + return Ok(AlgorithmDecision::keep_active(&context.snapshot)); + } + + let progress = TargetProgressView::from_snapshot(&context.snapshot); + if progress.is_complete() { + return Ok(AlgorithmDecision::finishing(TerminalState::Completed)); + } + + let features = context + .signals + .require_typed_feature::()?; + let schedule = self + .schedule + .evaluate(progress.clone(), context.observed_at)?; + let placement = choose_placement(&features, schedule.catch_up_pressure); + let book = + context + .signals + .book_ticker + .as_ref() + .ok_or_else(|| exh::Error::PolicyViolation { + message: "market/limit switch requires book_ticker".to_owned(), + })?; + let (target_value, reference_price, price_offset, style, tag) = match placement { + Placement::Passive => ( + self.passive_slice_quantity, + book.bid_price, + Decimal::ZERO, + ChildOrderStyle::Passive, + "passive-limit", + ), + Placement::Aggressive => ( + self.aggressive_slice_quantity, + book.ask_price, + Decimal::new(5, 1), + ChildOrderStyle::Aggressive, + "aggressive-ioc", + ), + }; + let child_id = ChildId::from_sequence(&context.intent.execution_id, tag, 0)?; + let child = ChildOrderFactory::new(&self.market).spot_child( + SpotChildOrderInput::builder() + .key(child_id.key) + .symbol(context.intent.symbol.clone()) + .side(context.intent.side) + .style(style) + .target_value(target_value) + .reference_price(reference_price) + .price_offset(price_offset) + .client_order_id(child_id.client_order_id) + .progress(progress) + .build() + .map_err(|message| exh::Error::PolicyViolation { + message: message.to_string(), + })?, + )?; + + Ok(AlgorithmDecision::running(vec![child])) + } +} + +fn choose_placement(features: &RlExecutionFeatures, catch_up_pressure: Decimal) -> Placement { + if features.market_order_value > features.limit_order_value + || features.queue_ahead_ratio > Decimal::new(7, 1) + || catch_up_pressure > Decimal::new(6, 1) + { + Placement::Aggressive + } else { + Placement::Passive + } +} + +fn frame(symbol: &Symbol, bid: &str, ask: &str, features: RlExecutionFeatures) -> SignalFrame { + book_frame( + symbol, + decimal(bid), + decimal("5"), + decimal(ask), + decimal("4"), + ) + .with_typed_feature(&features) + .expect("RL feature must attach") +} + +fn features(limit: &str, market: &str, queue: &str) -> RlExecutionFeatures { + RlExecutionFeatures { + limit_order_value: decimal(limit), + market_order_value: decimal(market), + queue_ahead_ratio: decimal(queue), + } +} + +fn switch_market(symbol: Symbol) -> MarketInfo { + spot_market_fixture(symbol) +} + +fn next_snapshot(outcome: AdvanceOutcome) -> exh::ExecutionSnapshot { + match outcome { + AdvanceOutcome::Progressed { snapshot, .. } + | AdvanceOutcome::Quiescent { snapshot, .. } + | AdvanceOutcome::Completed { snapshot } => snapshot, + _ => unreachable!("example handles all current advance outcomes"), + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let symbol = Symbol::spot("SOLUSDT"); + let start_at = OffsetDateTime::UNIX_EPOCH; + let engine = Engine::new( + SimulatedVenue::default(), + MemoryJournal::default(), + MarketLimitSwitchAlgo { + market: switch_market(symbol.clone()), + schedule: ParentSchedule::linear(TimeWindow::new( + start_at, + start_at + Duration::minutes(5), + )), + passive_slice_quantity: decimal("0.5"), + aggressive_slice_quantity: decimal("0.7"), + }, + ); + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("market-limit-switch-demo")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target_quantity(decimal("1.2")) + .build()?; + let mut snapshot = engine.start(intent, start_at).await?; + snapshot = next_snapshot( + engine + .advance( + &snapshot, + AdvanceInput::new( + start_at + Duration::minutes(1), + frame(&symbol, "100.0", "100.2", features("0.8", "0.3", "0.2")), + vec![], + ), + ) + .await?, + ); + let passive_fill = filled_active_child( + &snapshot, + &exh::ChildKey::new("passive-limit-0"), + decimal("0.5"), + decimal("100.0"), + )?; + snapshot = next_snapshot( + engine + .advance( + &snapshot, + AdvanceInput::new( + start_at + Duration::minutes(2), + frame(&symbol, "100.1", "100.3", features("0.2", "0.9", "0.9")), + vec![exh::OrderUpdate::new(passive_fill)], + ), + ) + .await?, + ); + let aggressive_fill = filled_active_child( + &snapshot, + &exh::ChildKey::new("aggressive-ioc-0"), + decimal("0.7"), + decimal("100.9"), + )?; + snapshot = next_snapshot( + engine + .advance( + &snapshot, + AdvanceInput::new( + start_at + Duration::minutes(3), + frame(&symbol, "100.2", "100.4", features("0.1", "1.0", "0.8")), + vec![exh::OrderUpdate::new(aggressive_fill)], + ), + ) + .await?, + ); + + println!( + "market_limit_switch terminal={:?} filled={} active_children={}", + snapshot.terminal_state, + snapshot.filled_base_quantity().normalize(), + snapshot.active_children.len() + ); + Ok(()) +} diff --git a/crates/exh-kit/examples/robust_vwap.rs b/crates/exh-kit/examples/robust_vwap.rs new file mode 100644 index 0000000..2a2876f --- /dev/null +++ b/crates/exh-kit/examples/robust_vwap.rs @@ -0,0 +1,181 @@ +use async_trait::async_trait; +use exh::{AdvanceInput, AdvanceOutcome, Engine, ExecutionId, ExecutionIntent, MemoryJournal}; +use exh_kit::signals::SignalMetrics; +use exh_kit::strategy_prelude::{ + Algorithm, AlgorithmDecision, ChildBudgetPolicy, ChildId, ChildOrderFactory, ChildOrderStyle, + EvaluateContext, ParentSchedule, SignalFrame, SpotChildOrderSpec, TargetProgressView, + TerminalState, TimeWindow, +}; +use exh_kit::testing::{SimulatedVenue, filled_active_child, spot_market_fixture}; +use mkt::prelude::MarketInfo; +use mkt::types::{Decimal, MarketKind, OrderSide, Symbol}; +use time::{Duration, OffsetDateTime}; + +#[derive(Debug, Clone)] +struct RobustVwapAlgo { + market: MarketInfo, + schedule: ParentSchedule, + max_slice_quantity: Decimal, + price_band_bps: u32, +} + +#[async_trait] +impl Algorithm for RobustVwapAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + let progress = TargetProgressView::from_snapshot(&context.snapshot); + if progress.is_complete() { + return Ok(AlgorithmDecision::finishing(TerminalState::Completed)); + } + + if context.observed_at >= self.schedule.window.end { + return Ok(AlgorithmDecision::finishing(TerminalState::Aborted)); + } + + let last_price = context + .signals + .last_price + .as_ref() + .ok_or_else(|| exh::Error::PolicyViolation { + message: "VWAP requires last_price".to_owned(), + })? + .price; + let cumulative_volume = context + .signals + .metrics + .cumulative_market_volume + .ok_or_else(|| exh::Error::PolicyViolation { + message: "VWAP requires cumulative_market_volume".to_owned(), + })?; + let schedule = self + .schedule + .evaluate(progress.clone(), context.observed_at)?; + let participation_budget = self.schedule.participation_budget( + progress.clone(), + context.observed_at, + cumulative_volume, + )?; + let participation_budget = + (participation_budget - progress.filled_value).max(Decimal::ZERO); + let catch_up = ChildBudgetPolicy::new() + .target_child_value(&progress, &schedule, Some(participation_budget))? + .min(self.max_slice_quantity); + + if catch_up <= Decimal::ZERO { + return Ok(AlgorithmDecision::paused()); + } + + let id = ChildId::from_sequence(&context.intent.execution_id, "vwap-primary", 0)?; + let child = ChildOrderFactory::new(&self.market).spot_child_from_context( + context, + SpotChildOrderSpec::new(id, ChildOrderStyle::LimitGtc, catch_up, last_price) + .with_price_offset(price_band_offset(last_price, self.price_band_bps)), + )?; + + Ok(AlgorithmDecision::running(vec![child])) + } +} + +fn price_band_offset(last_price: Decimal, band_bps: u32) -> Decimal { + last_price * Decimal::from(band_bps) / Decimal::from(10_000_u32) +} + +fn vwap_market(symbol: Symbol) -> MarketInfo { + spot_market_fixture(symbol) +} + +fn frame(symbol: &Symbol, price: i64, cumulative_volume: i64) -> SignalFrame { + SignalFrame::builder() + .last_price(Some(mkt::types::LastPrice::new( + symbol.clone(), + Decimal::new(price, 0), + ))) + .metrics( + SignalMetrics::builder() + .cumulative_market_volume(Decimal::new(cumulative_volume, 0)) + .build() + .expect("signal metrics builder cannot fail"), + ) + .build() + .expect("signal frame builder cannot fail") +} + +fn next_snapshot(outcome: AdvanceOutcome) -> exh::ExecutionSnapshot { + match outcome { + AdvanceOutcome::Progressed { snapshot, .. } + | AdvanceOutcome::Quiescent { snapshot, .. } + | AdvanceOutcome::Completed { snapshot } => snapshot, + _ => unreachable!("example handles all current advance outcomes"), + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let symbol = Symbol::spot("BTCUSDT"); + let start_at = OffsetDateTime::now_utc(); + let venue = SimulatedVenue::default(); + let journal = MemoryJournal::default(); + let engine = Engine::new( + venue.clone(), + journal, + RobustVwapAlgo { + market: vwap_market(symbol.clone()), + schedule: ParentSchedule::linear(TimeWindow::new( + start_at, + start_at + Duration::minutes(15), + )) + .with_participation_limit_bps(2_000), + max_slice_quantity: Decimal::new(3, 0), + price_band_bps: 25, + }, + ); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("vwap-demo")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target_quantity(Decimal::new(5, 0)) + .build()?; + let mut snapshot = engine.start(intent, start_at).await?; + + snapshot = next_snapshot( + engine + .advance( + &snapshot, + AdvanceInput::new( + start_at + Duration::minutes(1), + frame(&symbol, 100, 10), + vec![], + ), + ) + .await?, + ); + let fill = filled_active_child( + &snapshot, + &exh::ChildKey::new("vwap-primary-0"), + Decimal::new(2, 0), + Decimal::new(100, 0), + )?; + snapshot = next_snapshot( + engine + .advance( + &snapshot, + AdvanceInput::new( + start_at + Duration::minutes(5), + frame(&symbol, 101, 30), + vec![exh::OrderUpdate::new(fill)], + ), + ) + .await?, + ); + + println!( + "vwap filled={}, active_children={}", + snapshot.filled_base_quantity(), + snapshot.active_children.len() + ); + Ok(()) +} diff --git a/crates/exh-kit/examples/target_aware_pov.rs b/crates/exh-kit/examples/target_aware_pov.rs new file mode 100644 index 0000000..b960547 --- /dev/null +++ b/crates/exh-kit/examples/target_aware_pov.rs @@ -0,0 +1,222 @@ +use async_trait::async_trait; +use exh::{ + AdvanceInput, AdvanceOutcome, Algorithm, AlgorithmDecision, Engine, EvaluateContext, + ExecutionId, ExecutionIntent, MemoryJournal, TerminalState, +}; +use exh_kit::child_order::{ChildId, ChildOrderFactory, ChildOrderStyle, SpotChildOrderInput}; +use exh_kit::progress::{TargetProgressView, TargetValueKind}; +use exh_kit::schedule::{ChildBudgetPolicy, ParentSchedule, TimeWindow}; +use exh_kit::signals::{SignalFrame, SignalMetrics}; +use exh_kit::testing::{SimulatedVenue, decimal, filled_active_child, spot_market}; +use mkt::prelude::MarketInfo; +use mkt::types::{Decimal, LastPrice, MarketKind, OrderSide, OrderType, Symbol}; +use time::{Duration, OffsetDateTime}; + +#[derive(Debug, Clone)] +struct TargetAwarePovAlgo { + market: MarketInfo, + schedule: ParentSchedule, + max_slice_base_quantity: Decimal, + price_band_bps: u32, +} + +#[async_trait] +impl Algorithm for TargetAwarePovAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + if !context.snapshot.active_children.is_empty() { + return Ok(AlgorithmDecision::keep_active(&context.snapshot)); + } + + let progress = TargetProgressView::from_snapshot(&context.snapshot); + if progress.is_complete() { + return Ok(AlgorithmDecision::finishing(TerminalState::Completed)); + } + if context.observed_at >= self.schedule.window.end { + return Ok(AlgorithmDecision::finishing(TerminalState::Aborted)); + } + + let last_price = context + .signals + .last_price + .as_ref() + .ok_or_else(|| exh::Error::PolicyViolation { + message: "target-aware POV requires last_price".to_owned(), + })? + .price; + let cumulative_volume = context + .signals + .metrics + .cumulative_market_volume + .ok_or_else(|| exh::Error::PolicyViolation { + message: "target-aware POV requires cumulative_market_volume".to_owned(), + })?; + let cumulative_volume_in_target_units = match progress.kind { + TargetValueKind::BaseQuantity => cumulative_volume, + TargetValueKind::QuoteBudget => cumulative_volume * last_price, + _ => { + return Err(exh::Error::PolicyViolation { + message: "unsupported target value kind".to_owned(), + }); + } + }; + let participation_budget = self.schedule.participation_budget( + progress.clone(), + context.observed_at, + cumulative_volume_in_target_units, + )?; + let schedule = self + .schedule + .evaluate(progress.clone(), context.observed_at)?; + let child_value = ChildBudgetPolicy::new() + .target_child_value_no_context(&progress, &schedule, Some(participation_budget)) + .min(self.max_slice_target_value(&progress, last_price)); + if child_value <= Decimal::ZERO { + return Ok( + AlgorithmDecision::paused().wake_at(context.observed_at + Duration::seconds(5)) + ); + } + + let child_id = ChildId::from_sequence(&context.intent.execution_id, "target-pov", 0)?; + let child = ChildOrderFactory::new(&self.market).spot_child( + SpotChildOrderInput::builder() + .key(child_id.key) + .symbol(context.intent.symbol.clone()) + .side(context.intent.side) + .style(ChildOrderStyle::Aggressive) + .target_value(child_value) + .reference_price(last_price) + .price_offset(price_band_offset(last_price, self.price_band_bps)) + .client_order_id(child_id.client_order_id) + .progress(progress) + .build() + .map_err(|message| exh::Error::PolicyViolation { + message: message.to_string(), + })?, + )?; + + Ok(AlgorithmDecision::running(vec![child]) + .wake_at(context.observed_at + Duration::seconds(5))) + } +} + +impl TargetAwarePovAlgo { + fn max_slice_target_value( + &self, + progress: &TargetProgressView, + last_price: Decimal, + ) -> Decimal { + match progress.kind { + TargetValueKind::BaseQuantity => self.max_slice_base_quantity, + TargetValueKind::QuoteBudget => self.max_slice_base_quantity * last_price, + _ => Decimal::ZERO, + } + } +} + +fn price_band_offset(last_price: Decimal, band_bps: u32) -> Decimal { + last_price * Decimal::from(band_bps) / Decimal::from(10_000_u32) +} + +fn frame(symbol: &Symbol, price: &str, cumulative_volume: &str) -> SignalFrame { + SignalFrame::builder() + .last_price(Some(LastPrice::new(symbol.clone(), decimal(price)))) + .metrics( + SignalMetrics::builder() + .cumulative_market_volume(decimal(cumulative_volume)) + .build() + .expect("signal metrics builder cannot fail"), + ) + .build() + .expect("signal frame builder cannot fail") +} + +fn pov_market(symbol: Symbol) -> MarketInfo { + spot_market(symbol) + .with_supported_order_types(vec![OrderType::Limit]) + .with_min_quantity(decimal("0.001")) + .with_step_size(decimal("0.001")) + .build() +} + +fn next_snapshot(outcome: AdvanceOutcome) -> exh::ExecutionSnapshot { + match outcome { + AdvanceOutcome::Progressed { snapshot, .. } + | AdvanceOutcome::Quiescent { snapshot, .. } + | AdvanceOutcome::Completed { snapshot } => snapshot, + _ => unreachable!("example handles all current advance outcomes"), + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let symbol = Symbol::spot("BTCUSDT"); + let start_at = OffsetDateTime::UNIX_EPOCH; + let schedule = + ParentSchedule::linear(TimeWindow::new(start_at, start_at + Duration::minutes(20))) + .with_catch_up_multiplier(decimal("1.2")) + .with_participation_limit_bps(1_500); + let engine = Engine::new( + SimulatedVenue::default(), + MemoryJournal::default(), + TargetAwarePovAlgo { + market: pov_market(symbol.clone()), + schedule, + max_slice_base_quantity: decimal("0.050"), + price_band_bps: 20, + }, + ); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("target-aware-pov-demo")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .quote_budget(decimal("6000")) + .build()?; + let mut snapshot = engine.start(intent, start_at).await?; + snapshot = next_snapshot( + engine + .advance( + &snapshot, + AdvanceInput::new( + start_at + Duration::minutes(4), + frame(&symbol, "30000", "1.5"), + vec![], + ), + ) + .await?, + ); + let fill = filled_active_child( + &snapshot, + &exh::ChildKey::new("target-pov-0"), + decimal("0.050"), + decimal("30060.0"), + )?; + snapshot = next_snapshot( + engine + .advance( + &snapshot, + AdvanceInput::new( + start_at + Duration::minutes(8), + frame(&symbol, "30100", "4.0"), + vec![exh::OrderUpdate::new(fill)], + ), + ) + .await?, + ); + + println!( + "target_pov filled_base={} quote_spent={} remaining_quote={} active_children={}", + snapshot.filled_base_quantity().normalize(), + snapshot.cumulative_quote_quantity().normalize(), + snapshot + .remaining_quote_budget() + .unwrap_or(Decimal::ZERO) + .normalize(), + snapshot.active_children.len() + ); + Ok(()) +} diff --git a/crates/exh-kit/src/adapters.rs b/crates/exh-kit/src/adapters.rs new file mode 100644 index 0000000..8cad057 --- /dev/null +++ b/crates/exh-kit/src/adapters.rs @@ -0,0 +1,30 @@ +use mkt::types::{BookTicker, Extensions, Kline, LastPrice, OrderBook, Trade}; + +use crate::signals::{SignalFrame, SignalMetrics}; + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct SignalFrameBuilderInput { + pub last_price: Option, + pub book_ticker: Option, + pub order_book: Option, + pub recent_trades: Vec, + pub klines: Vec, + pub metrics: SignalMetrics, + pub extensions: Extensions, +} + +impl SignalFrameBuilderInput { + pub fn into_signal_frame(self) -> SignalFrame { + SignalFrame::builder() + .last_price(self.last_price) + .book_ticker(self.book_ticker) + .order_book(self.order_book) + .recent_trades(self.recent_trades) + .klines(self.klines) + .metrics(self.metrics) + .extensions(self.extensions) + .build() + .expect("signal frame builder cannot fail") + } +} diff --git a/crates/exh-kit/src/child_order.rs b/crates/exh-kit/src/child_order.rs new file mode 100644 index 0000000..f8f2177 --- /dev/null +++ b/crates/exh-kit/src/child_order.rs @@ -0,0 +1,271 @@ +use derive_builder::Builder; +use exh::{ChildKey, ChildTarget, Error, EvaluateContext, ExecutionId, OrderRequest}; +use mkt::types::{ + ClientOrderId, Decimal, MarketInfo, OrderQuantity, OrderSide, OrderType, SpotOrderRequest, + Symbol, TimeInForce, +}; + +use crate::constraints::{MarketConstraintService, RoundingMode}; +use crate::progress::{TargetProgressView, TargetValueKind}; + +/// Factory-level placement style for child spot orders. +/// +/// These styles do not imply native venue peg order support. The factory emits +/// ordinary `Limit` or `PostOnly` requests from `reference_price`, +/// `price_offset`, optional tick improvement, and style-specific rounding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ChildOrderStyle { + /// Post-only limit placement with passive-side rounding. + Passive, + /// Resting limit placement with passive-side rounding. + LimitGtc, + /// IOC limit placement with marketable-side rounding. + Aggressive, + /// IOC limit placement around a caller-supplied midpoint/reference price. + MidIoc, + /// Post-only limit placement around a caller-supplied best-bid/ask reference. + BestBidAskPostOnly, + /// Post-only limit placement intended to join the caller-supplied queue price. + JoinQueue, + /// Post-only limit placement that improves the input price by market ticks. + ImproveByTicks(u32), + /// IOC limit placement with marketable-side rounding. + IocThroughBook, +} + +impl ChildOrderStyle { + pub fn order_type(self) -> OrderType { + match self { + Self::Passive + | Self::BestBidAskPostOnly + | Self::JoinQueue + | Self::ImproveByTicks(_) => OrderType::PostOnly, + Self::LimitGtc | Self::Aggressive | Self::MidIoc | Self::IocThroughBook => { + OrderType::Limit + } + } + } + + pub fn time_in_force(self) -> TimeInForce { + match self { + Self::Passive + | Self::BestBidAskPostOnly + | Self::JoinQueue + | Self::ImproveByTicks(_) => TimeInForce::Gtx, + Self::LimitGtc => TimeInForce::Gtc, + Self::Aggressive | Self::MidIoc | Self::IocThroughBook => TimeInForce::Ioc, + } + } + + pub fn price_rounding(self, side: OrderSide) -> RoundingMode { + match (side, self) { + (OrderSide::Buy, Self::Aggressive | Self::MidIoc | Self::IocThroughBook) + | ( + OrderSide::Sell, + Self::Passive + | Self::LimitGtc + | Self::BestBidAskPostOnly + | Self::JoinQueue + | Self::ImproveByTicks(_), + ) => RoundingMode::Ceil, + _ => RoundingMode::Floor, + } + } + + fn effective_price_offset(self, price_offset: Decimal, tick_size: Option) -> Decimal { + let Self::ImproveByTicks(ticks) = self else { + return price_offset; + }; + tick_size + .filter(|tick_size| *tick_size > Decimal::ZERO) + .map(|tick_size| price_offset + tick_size * Decimal::from(ticks)) + .unwrap_or(price_offset) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ChildId { + pub key: ChildKey, + pub client_order_id: ClientOrderId, +} + +impl ChildId { + pub fn new(key: ChildKey, client_order_id: ClientOrderId) -> Self { + Self { + key, + client_order_id, + } + } + + pub fn from_sequence( + execution_id: &ExecutionId, + strategy_tag: &str, + sequence: u64, + ) -> Result { + if strategy_tag.trim().is_empty() { + return Err(Error::PolicyViolation { + message: "child id strategy tag must not be empty".to_owned(), + }); + } + let child_key = format!("{strategy_tag}-{sequence}"); + Ok(Self { + key: ChildKey::new(child_key.clone()), + client_order_id: ClientOrderId::new(format!("{}-{child_key}", execution_id.0)), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Builder)] +#[non_exhaustive] +#[builder(pattern = "owned", setter(into))] +pub struct SpotChildOrderInput { + pub key: ChildKey, + pub symbol: Symbol, + pub side: OrderSide, + pub style: ChildOrderStyle, + pub target_value: Decimal, + pub reference_price: Decimal, + pub price_offset: Decimal, + pub client_order_id: ClientOrderId, + pub progress: TargetProgressView, +} + +impl SpotChildOrderInput { + pub fn builder() -> SpotChildOrderInputBuilder { + SpotChildOrderInputBuilder::default() + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct SpotChildOrderSpec { + pub id: ChildId, + pub style: ChildOrderStyle, + pub target_value: Decimal, + pub reference_price: Decimal, + pub price_offset: Decimal, +} + +impl SpotChildOrderSpec { + pub fn new( + id: ChildId, + style: ChildOrderStyle, + target_value: Decimal, + reference_price: Decimal, + ) -> Self { + Self { + id, + style, + target_value, + reference_price, + price_offset: Decimal::ZERO, + } + } + + pub fn with_price_offset(mut self, price_offset: Decimal) -> Self { + self.price_offset = price_offset; + self + } +} + +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub struct ChildOrderFactory<'a> { + constraints: MarketConstraintService<'a>, +} + +impl<'a> ChildOrderFactory<'a> { + pub fn new(market: &'a MarketInfo) -> Self { + Self { + constraints: MarketConstraintService::new(market), + } + } + + pub fn spot_child(&self, input: SpotChildOrderInput) -> Result { + let order_type = input.style.order_type(); + let limit_price = self.limit_price(&input); + let requested_base_quantity = self.requested_base_quantity(&input, limit_price)?; + let sizing = self.constraints.size_base_child( + order_type, + input.progress, + requested_base_quantity, + Some(limit_price), + )?; + + self.constraints.validate_base_child_size( + order_type, + sizing.quantity, + Some(limit_price), + )?; + + let request = OrderRequest::Spot( + SpotOrderRequest::builder() + .symbol(input.symbol) + .side(input.side) + .order_type(order_type) + .quantity(OrderQuantity::Base(sizing.quantity)) + .price(limit_price) + .time_in_force(input.style.time_in_force()) + .client_order_id(input.client_order_id) + .build() + .map_err(|source| Error::PolicyViolation { + message: format!("spot child order request is invalid: {source}"), + })?, + ); + self.constraints.validate_request(&request)?; + + Ok(ChildTarget::new(input.key, request)) + } + + pub fn spot_child_from_context( + &self, + context: &EvaluateContext, + spec: SpotChildOrderSpec, + ) -> Result { + self.spot_child(SpotChildOrderInput { + key: spec.id.key, + symbol: context.intent.symbol.clone(), + side: context.intent.side, + style: spec.style, + target_value: spec.target_value, + reference_price: spec.reference_price, + price_offset: spec.price_offset, + client_order_id: spec.id.client_order_id, + progress: TargetProgressView::from_snapshot(&context.snapshot), + }) + } + + fn limit_price(&self, input: &SpotChildOrderInput) -> Decimal { + let price_offset = input + .style + .effective_price_offset(input.price_offset, self.constraints.tick_size()); + let raw_price = match input.side { + OrderSide::Buy => input.reference_price + price_offset, + OrderSide::Sell => input.reference_price - price_offset, + _ => input.reference_price, + }; + self.constraints + .round_price(raw_price, input.style.price_rounding(input.side)) + } + + fn requested_base_quantity( + &self, + input: &SpotChildOrderInput, + limit_price: Decimal, + ) -> Result { + match input.progress.kind { + TargetValueKind::BaseQuantity => Ok(input.target_value), + TargetValueKind::QuoteBudget => { + if limit_price <= Decimal::ZERO { + return Err(Error::PolicyViolation { + message: "quote-target spot child order requires positive limit price" + .to_owned(), + }); + } + Ok(input.target_value / limit_price) + } + } + } +} diff --git a/crates/exh-kit/src/constraints.rs b/crates/exh-kit/src/constraints.rs new file mode 100644 index 0000000..70dbfae --- /dev/null +++ b/crates/exh-kit/src/constraints.rs @@ -0,0 +1,426 @@ +use exh::{Error, OrderRequest}; +use mkt::types::{ + Decimal, LotSizeFilter, MarketInfo, MarketKind, MarketQuantityMode, OrderQuantity, OrderSide, + OrderType, +}; + +use crate::progress::{TargetProgressView, TargetValueKind}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RoundingMode { + Floor, + Ceil, +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct BaseChildSizing { + pub quantity: Decimal, + pub target_value: Decimal, + pub notional_value: Option, + pub min_quantity: Option, + pub min_notional: Option, +} + +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub struct MarketConstraintService<'a> { + pub market: &'a MarketInfo, +} + +impl<'a> MarketConstraintService<'a> { + pub fn new(market: &'a MarketInfo) -> Self { + Self { market } + } + + pub fn round_price(&self, price: Decimal, mode: RoundingMode) -> Decimal { + match self.tick_size() { + Some(tick_size) => Self::round_to_step(price, tick_size, mode), + None => price, + } + } + + pub fn round_quantity( + &self, + quantity: Decimal, + order_type: OrderType, + mode: RoundingMode, + ) -> Decimal { + match self.step_size(order_type) { + Some(step_size) => Self::round_to_step(quantity, step_size, mode), + None => quantity, + } + } + + pub fn floor_quantity(&self, quantity: Decimal, order_type: OrderType) -> Decimal { + self.round_quantity(quantity, order_type, RoundingMode::Floor) + } + + pub fn min_quantity(&self, order_type: OrderType) -> Option { + self.lot_size_for(order_type) + .and_then(|filter| filter.min_quantity) + .filter(|value| *value > Decimal::ZERO) + } + + pub fn max_quantity(&self, order_type: OrderType) -> Option { + self.lot_size_for(order_type) + .and_then(|filter| filter.max_quantity) + .filter(|value| *value > Decimal::ZERO) + } + + pub fn step_size(&self, order_type: OrderType) -> Option { + self.lot_size_for(order_type) + .and_then(|filter| filter.step_size) + .filter(|value| *value > Decimal::ZERO) + } + + pub fn tick_size(&self) -> Option { + self.market + .trading_constraints + .price_filter + .as_ref() + .and_then(|filter| filter.tick_size) + .filter(|value| *value > Decimal::ZERO) + } + + pub fn min_notional(&self) -> Option { + self.market + .trading_constraints + .notional + .as_ref() + .and_then(|constraints| constraints.min_notional) + .filter(|value| *value > Decimal::ZERO) + } + + pub fn min_notional_or(&self, fallback: Decimal) -> Decimal { + self.min_notional().unwrap_or(fallback).max(fallback) + } + + pub fn is_trading(&self) -> bool { + matches!(self.market.status, mkt::types::MarketStatus::Trading) + } + + pub fn allows_spot_order_entry(&self) -> bool { + self.market + .trading_permissions + .spot_order_entry_allowed + .unwrap_or(true) + } + + pub fn supports_order_type(&self, order_type: OrderType) -> bool { + self.market + .trading_permissions + .supported_order_types + .contains(&order_type) + } + + pub fn supports_quantity_mode( + &self, + mode: MarketQuantityMode, + order_type: OrderType, + side: OrderSide, + ) -> bool { + self.market + .trading_permissions + .quantity_mode_support + .iter() + .filter(|support| support.mode == mode) + .any(|support| { + let order_type_supported = + support.order_types.is_empty() || support.order_types.contains(&order_type); + let side_supported = support.sides.is_empty() || support.sides.contains(&side); + order_type_supported && side_supported + }) + } + + pub fn lot_size_for(&self, order_type: OrderType) -> Option<&LotSizeFilter> { + let trading_constraints = &self.market.trading_constraints; + match order_type { + OrderType::Market | OrderType::StopMarket => trading_constraints + .market_lot_size + .as_ref() + .or(trading_constraints.lot_size.as_ref()), + _ => trading_constraints + .lot_size + .as_ref() + .or(trading_constraints.market_lot_size.as_ref()), + } + } + + pub fn validate_request(&self, request: &OrderRequest) -> Result<(), Error> { + self.validate_market_request(request)?; + let detail = RequestDetail::from_request(request)?; + self.validate_permissions(&detail)?; + if let Some(price) = detail.price { + self.validate_price(price)?; + } + self.validate_quantity(detail.order_type, detail.quantity)?; + if let Some(notional) = detail.notional_value { + self.validate_notional(notional)?; + } + Ok(()) + } + + pub fn validate_base_child_size( + &self, + order_type: OrderType, + quantity: Decimal, + price: Option, + ) -> Result<(), Error> { + self.validate_quantity(order_type, quantity)?; + if let Some(price) = price { + self.validate_notional(quantity * price)?; + } + Ok(()) + } + + pub fn size_base_child( + &self, + order_type: OrderType, + progress: TargetProgressView, + requested_base_quantity: Decimal, + limit_price: Option, + ) -> Result { + let remaining_base = match progress.kind { + TargetValueKind::BaseQuantity => progress.remaining_value, + TargetValueKind::QuoteBudget => { + let price = limit_price.ok_or_else(|| Error::PolicyViolation { + message: "quote-target base child sizing requires a limit price".to_owned(), + })?; + if price <= Decimal::ZERO { + return Err(Error::PolicyViolation { + message: "quote-target base child sizing requires positive price" + .to_owned(), + }); + } + progress.remaining_value / price + } + }; + let max_quantity = self.max_quantity(order_type); + let mut quantity = requested_base_quantity + .max(Decimal::ZERO) + .min(remaining_base.max(Decimal::ZERO)); + if let Some(max_quantity) = max_quantity { + quantity = quantity.min(max_quantity); + } + quantity = self.floor_quantity(quantity, order_type); + + let notional_value = limit_price.map(|price| quantity * price); + let target_value = match progress.kind { + TargetValueKind::BaseQuantity => quantity, + TargetValueKind::QuoteBudget => notional_value.unwrap_or(Decimal::ZERO), + }; + + Ok(BaseChildSizing { + quantity, + target_value, + notional_value, + min_quantity: self.min_quantity(order_type), + min_notional: self.min_notional(), + }) + } + + fn round_to_step(value: Decimal, step: Decimal, mode: RoundingMode) -> Decimal { + if step <= Decimal::ZERO { + return value; + } + match mode { + RoundingMode::Floor => (value / step).floor() * step, + RoundingMode::Ceil => (value / step).ceil() * step, + } + } + + fn validate_market_request(&self, request: &OrderRequest) -> Result<(), Error> { + if request.symbol() != &self.market.symbol { + return Err(Error::PolicyViolation { + message: format!( + "order symbol {} does not match market {}", + request.symbol().venue_symbol, + self.market.symbol.venue_symbol + ), + }); + } + if !self.is_trading() { + return Err(Error::PolicyViolation { + message: format!("market {} is not trading", self.market.symbol.venue_symbol), + }); + } + if matches!(request.market_kind(), MarketKind::Spot) && !self.allows_spot_order_entry() { + return Err(Error::PolicyViolation { + message: format!( + "market {} does not allow spot order entry", + self.market.symbol.venue_symbol + ), + }); + } + Ok(()) + } + + fn validate_permissions(&self, detail: &RequestDetail) -> Result<(), Error> { + let permissions = &self.market.trading_permissions; + if !permissions.supported_order_types.is_empty() + && !self.supports_order_type(detail.order_type) + { + return Err(Error::PolicyViolation { + message: format!( + "order type {} is not supported for {}", + detail.order_type, self.market.symbol.venue_symbol + ), + }); + } + if !permissions.quantity_mode_support.is_empty() + && !self.supports_quantity_mode( + detail.quantity_mode.clone(), + detail.order_type, + detail.side, + ) + { + return Err(Error::PolicyViolation { + message: format!( + "quantity mode {:?} is not supported for {} {}", + detail.quantity_mode, detail.side, detail.order_type + ), + }); + } + Ok(()) + } + + fn validate_price(&self, price: Decimal) -> Result<(), Error> { + if price <= Decimal::ZERO { + return Err(Error::PolicyViolation { + message: "order price must be greater than zero".to_owned(), + }); + } + if let Some(filter) = self.market.trading_constraints.price_filter.as_ref() { + if filter.min_price.is_some_and(|min_price| price < min_price) { + return Err(Error::PolicyViolation { + message: "order price is below market minimum price".to_owned(), + }); + } + if filter.max_price.is_some_and(|max_price| price > max_price) { + return Err(Error::PolicyViolation { + message: "order price is above market maximum price".to_owned(), + }); + } + } + if self.tick_size().is_some_and(|tick_size| { + Self::round_to_step(price, tick_size, RoundingMode::Floor) != price + }) { + return Err(Error::PolicyViolation { + message: "order price is not aligned to market tick size".to_owned(), + }); + } + Ok(()) + } + + fn validate_quantity(&self, order_type: OrderType, quantity: Decimal) -> Result<(), Error> { + if quantity <= Decimal::ZERO { + return Err(Error::PolicyViolation { + message: "order quantity must be greater than zero".to_owned(), + }); + } + if self + .min_quantity(order_type) + .is_some_and(|min_quantity| quantity < min_quantity) + { + return Err(Error::PolicyViolation { + message: "order quantity is below market minimum quantity".to_owned(), + }); + } + if self + .max_quantity(order_type) + .is_some_and(|max_quantity| quantity > max_quantity) + { + return Err(Error::PolicyViolation { + message: "order quantity is above market maximum quantity".to_owned(), + }); + } + if self.step_size(order_type).is_some_and(|step_size| { + Self::round_to_step(quantity, step_size, RoundingMode::Floor) != quantity + }) { + return Err(Error::PolicyViolation { + message: "order quantity is not aligned to market step size".to_owned(), + }); + } + Ok(()) + } + + fn validate_notional(&self, notional: Decimal) -> Result<(), Error> { + if self + .min_notional() + .is_some_and(|min_notional| notional < min_notional) + { + return Err(Error::PolicyViolation { + message: "order notional is below market minimum notional".to_owned(), + }); + } + if self + .market + .trading_constraints + .notional + .as_ref() + .and_then(|constraints| constraints.max_notional) + .filter(|value| *value > Decimal::ZERO) + .is_some_and(|max_notional| notional > max_notional) + { + return Err(Error::PolicyViolation { + message: "order notional is above market maximum notional".to_owned(), + }); + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +struct RequestDetail { + side: OrderSide, + order_type: OrderType, + quantity: Decimal, + quantity_mode: MarketQuantityMode, + price: Option, + notional_value: Option, +} + +impl RequestDetail { + fn from_request(request: &OrderRequest) -> Result { + match request { + OrderRequest::Spot(request) => { + let (quantity, quantity_mode, notional_value) = match request.quantity { + OrderQuantity::Base(quantity) => ( + quantity, + MarketQuantityMode::Base, + request.price.map(|price| quantity * price), + ), + OrderQuantity::Quote(quantity) => { + (quantity, MarketQuantityMode::Quote, Some(quantity)) + } + _ => { + return Err(Error::PolicyViolation { + message: "unsupported spot quantity mode".to_owned(), + }); + } + }; + Ok(Self { + side: request.side, + order_type: request.order_type, + quantity, + quantity_mode, + price: request.price, + notional_value, + }) + } + OrderRequest::Futures(request) => Ok(Self { + side: request.side, + order_type: request.order_type, + quantity: request.quantity, + quantity_mode: MarketQuantityMode::Base, + price: request.price, + notional_value: request.price.map(|price| request.quantity * price), + }), + _ => Err(Error::PolicyViolation { + message: "unsupported order request variant".to_owned(), + }), + } + } +} diff --git a/crates/exh-kit/src/features.rs b/crates/exh-kit/src/features.rs new file mode 100644 index 0000000..341b10e --- /dev/null +++ b/crates/exh-kit/src/features.rs @@ -0,0 +1,190 @@ +//! Typed codecs for strategy feature payloads stored in `SignalFrame::extensions`. + +use exh::Error; +use mkt::types::Extensions; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::schema::validate_identifier; +use crate::signals::SignalFrame; + +#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)] +#[non_exhaustive] +pub struct SignalFeatureEnvelope { + pub schema: String, + pub version: u32, + pub payload: Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct SignalFeatureRequirement { + pub extension_key: &'static str, + pub schema: &'static str, + pub version: u32, +} + +impl SignalFeatureEnvelope { + pub fn new(schema: impl Into, version: u32, payload: Value) -> Self { + Self { + schema: schema.into(), + version, + payload, + } + } +} + +pub trait SignalFeatureSchema: Sized + Serialize + DeserializeOwned { + const EXTENSION_KEY: &'static str; + const SCHEMA: &'static str; + const VERSION: u32; + + fn requirement() -> SignalFeatureRequirement { + SignalFeatureRequirement { + extension_key: Self::EXTENSION_KEY, + schema: Self::SCHEMA, + version: Self::VERSION, + } + } + + fn to_envelope(&self) -> Result { + validate_identifier("signal feature extension key", Self::EXTENSION_KEY)?; + validate_identifier("signal feature schema", Self::SCHEMA)?; + let payload = serde_json::to_value(self).map_err(|error| Error::PolicyViolation { + message: format!("serialize signal feature {}: {error}", Self::SCHEMA), + })?; + Ok(SignalFeatureEnvelope::new( + Self::SCHEMA, + Self::VERSION, + payload, + )) + } + + fn from_envelope(envelope: &SignalFeatureEnvelope) -> Result { + if envelope.schema != Self::SCHEMA { + return Err(Error::InvalidRecovery { + message: format!( + "signal feature schema {} does not match expected {}", + envelope.schema, + Self::SCHEMA + ), + }); + } + if envelope.version != Self::VERSION { + return Err(Error::InvalidRecovery { + message: format!( + "signal feature {} version {} does not match expected {}", + Self::SCHEMA, + envelope.version, + Self::VERSION + ), + }); + } + serde_json::from_value(envelope.payload.clone()).map_err(|error| Error::InvalidRecovery { + message: format!("deserialize signal feature {}: {error}", Self::SCHEMA), + }) + } + + fn insert_into(&self, extensions: &mut Extensions) -> Result<(), Error> { + let envelope = self.to_envelope()?; + let value = serde_json::to_value(envelope).map_err(|error| Error::PolicyViolation { + message: format!( + "serialize signal feature envelope {}: {error}", + Self::SCHEMA + ), + })?; + extensions + .insert(Self::EXTENSION_KEY, value) + .map_err(|error| Error::PolicyViolation { + message: format!( + "insert signal feature {} at {}: {error}", + Self::SCHEMA, + Self::EXTENSION_KEY + ), + }) + } + + fn load_from(extensions: &Extensions) -> Result, Error> { + let Some(value) = extensions.get(Self::EXTENSION_KEY) else { + return Ok(None); + }; + let envelope: SignalFeatureEnvelope = + serde_json::from_value(value.clone()).map_err(|error| Error::InvalidRecovery { + message: format!( + "deserialize signal feature envelope {}: {error}", + Self::EXTENSION_KEY + ), + })?; + Self::from_envelope(&envelope).map(Some) + } + + fn load_from_frame(frame: &SignalFrame) -> Result, Error> { + Self::load_from(&frame.extensions) + } +} + +pub trait SignalFeatureRequirements { + fn required_signal_features(&self) -> Vec { + Vec::new() + } +} + +pub trait SignalFrameFeatureExt: Sized { + fn with_typed_feature(self, feature: &T) -> Result + where + T: SignalFeatureSchema; + + fn typed_feature(&self) -> Result, Error> + where + T: SignalFeatureSchema; + + fn require_typed_feature(&self) -> Result + where + T: SignalFeatureSchema; + + fn typed_feature_or_default(&self) -> Result + where + T: SignalFeatureSchema + Default; +} + +impl SignalFrameFeatureExt for SignalFrame { + fn with_typed_feature(mut self, feature: &T) -> Result + where + T: SignalFeatureSchema, + { + feature.insert_into(&mut self.extensions)?; + Ok(self) + } + + fn typed_feature(&self) -> Result, Error> + where + T: SignalFeatureSchema, + { + T::load_from_frame(self) + } + + fn require_typed_feature(&self) -> Result + where + T: SignalFeatureSchema, + { + if let Some(feature) = self.typed_feature::()? { + return Ok(feature); + } + + let requirement = T::requirement(); + Err(Error::PolicyViolation { + message: format!( + "missing required signal feature {} schema {} version {}", + requirement.extension_key, requirement.schema, requirement.version + ), + }) + } + + fn typed_feature_or_default(&self) -> Result + where + T: SignalFeatureSchema + Default, + { + Ok(self.typed_feature::()?.unwrap_or_default()) + } +} diff --git a/crates/exh-kit/src/lib.rs b/crates/exh-kit/src/lib.rs new file mode 100644 index 0000000..55c332f --- /dev/null +++ b/crates/exh-kit/src/lib.rs @@ -0,0 +1,13 @@ +pub mod adapters; +pub mod child_order; +pub mod constraints; +pub mod features; +pub mod lifecycle; +pub mod multi_asset; +pub mod primitives; +pub mod progress; +pub mod schedule; +pub mod schema; +pub mod signals; +pub mod strategy_prelude; +pub mod testing; diff --git a/crates/exh-kit/src/lifecycle.rs b/crates/exh-kit/src/lifecycle.rs new file mode 100644 index 0000000..55d849a --- /dev/null +++ b/crates/exh-kit/src/lifecycle.rs @@ -0,0 +1,102 @@ +use exh::{AlgorithmLifecycleEvent, ChildKey, OrderRequest}; +use mkt::types::{ClientOrderId, Decimal, Order, OrderStatus}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum LifecycleEventKind { + Placed, + PlaceRejected, + Canceled, + Observed, +} + +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub struct LifecycleEventView<'a> { + pub kind: LifecycleEventKind, + pub key: &'a ChildKey, + pub client_order_id: Option<&'a ClientOrderId>, + pub request: Option<&'a OrderRequest>, + pub order: Option<&'a Order>, + pub message: Option<&'a str>, +} + +impl<'a> LifecycleEventView<'a> { + pub fn from_event(event: &'a AlgorithmLifecycleEvent) -> Option { + match event { + AlgorithmLifecycleEvent::ChildPlaced { + key, + request, + order, + } => Some(Self { + kind: LifecycleEventKind::Placed, + key, + client_order_id: request.client_order_id().or(order.client_order_id.as_ref()), + request: Some(request), + order: Some(order), + message: None, + }), + AlgorithmLifecycleEvent::ChildPlaceRejected { + key, + request, + client_order_id, + message, + } => Some(Self { + kind: LifecycleEventKind::PlaceRejected, + key, + client_order_id: Some(client_order_id), + request: Some(request), + order: None, + message: Some(message.as_str()), + }), + AlgorithmLifecycleEvent::ChildCanceled { key, order } => Some(Self { + kind: LifecycleEventKind::Canceled, + key, + client_order_id: order.client_order_id.as_ref(), + request: None, + order: Some(order), + message: None, + }), + AlgorithmLifecycleEvent::ChildObserved { key, order } => Some(Self { + kind: LifecycleEventKind::Observed, + key, + client_order_id: order.client_order_id.as_ref(), + request: None, + order: Some(order), + message: None, + }), + _ => None, + } + } + + pub fn requested_base_quantity(&self) -> Option { + self.request + .and_then(OrderRequest::execution_base_quantity) + .or_else(|| self.order.map(|order| order.quantity)) + } + + pub fn limit_price(&self) -> Option { + self.request + .and_then(|request| match request { + OrderRequest::Spot(request) => request.price, + OrderRequest::Futures(request) => request.price, + _ => None, + }) + .or_else(|| self.order.and_then(|order| order.price)) + } + + pub fn filled_quantity(&self) -> Decimal { + self.order + .map(|order| order.filled_quantity) + .unwrap_or(Decimal::ZERO) + } + + pub fn order_status(&self) -> Option { + self.order.map(|order| order.status) + } + + pub fn client_order_id_string(&self) -> Option { + self.client_order_id + .map(|client_order_id| client_order_id.0.clone()) + } +} diff --git a/crates/exh-kit/src/multi_asset.rs b/crates/exh-kit/src/multi_asset.rs new file mode 100644 index 0000000..cc5fff6 --- /dev/null +++ b/crates/exh-kit/src/multi_asset.rs @@ -0,0 +1,18 @@ +mod coordinator; +mod helpers; +mod lifecycle; +mod schema; +mod state; + +pub use coordinator::{ + LegBudgetAllocation, MultiAssetCoordinator, MultiAssetLeg, MultiAssetLegProgress, + MultiAssetParentSnapshot, MultiAssetPlan, +}; +pub use lifecycle::{ + ParentExecutionRun, ParentExecutionRunner, ParentRunLifecycleContext, ParentRunLifecycleEffects, +}; +pub use schema::{ + ParentAuditSchema, ParentRunLifecycleEffectsSchemaExt, ParentRunStateSchemaExt, + ParentStateSchema, +}; +pub use state::{ParentAuditEvent, ParentRunState, ParentStatePatch, ParentStateView}; diff --git a/crates/exh-kit/src/multi_asset/coordinator.rs b/crates/exh-kit/src/multi_asset/coordinator.rs new file mode 100644 index 0000000..c673b76 --- /dev/null +++ b/crates/exh-kit/src/multi_asset/coordinator.rs @@ -0,0 +1,208 @@ +use exh::{Error, ExecutionIntent, ExecutionSnapshot}; +use mkt::types::Decimal; +use std::collections::BTreeSet; + +use crate::progress::TargetProgressView; + +use super::helpers::{snapshots_by_execution_id, validate_leg_snapshot}; + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct MultiAssetLeg { + pub intent: ExecutionIntent, + pub parent_budget: Decimal, +} + +impl MultiAssetLeg { + pub fn new(intent: ExecutionIntent, parent_budget: Decimal) -> Self { + Self { + intent, + parent_budget, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct MultiAssetPlan { + pub parent_id: String, + pub legs: Vec, +} + +impl MultiAssetPlan { + pub fn new(parent_id: impl Into, legs: Vec) -> Result { + let plan = Self { + parent_id: parent_id.into(), + legs, + }; + plan.validate()?; + Ok(plan) + } + + pub fn parent_budget(&self) -> Decimal { + self.legs + .iter() + .map(|leg| leg.parent_budget) + .sum::() + } + + fn validate(&self) -> Result<(), Error> { + if self.parent_id.trim().is_empty() { + return Err(Error::PolicyViolation { + message: "multi-asset parent id must not be empty".to_owned(), + }); + } + if self.legs.is_empty() { + return Err(Error::PolicyViolation { + message: "multi-asset parent requires at least one leg".to_owned(), + }); + } + let mut seen = BTreeSet::new(); + for leg in &self.legs { + if leg.parent_budget <= Decimal::ZERO { + return Err(Error::PolicyViolation { + message: "multi-asset leg parent budget must be greater than zero".to_owned(), + }); + } + if !seen.insert(leg.intent.execution_id.0.clone()) { + return Err(Error::PolicyViolation { + message: format!( + "duplicate multi-asset leg execution id {}", + leg.intent.execution_id.0 + ), + }); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct MultiAssetCoordinator { + pub plan: MultiAssetPlan, +} + +impl MultiAssetCoordinator { + pub fn new(plan: MultiAssetPlan) -> Self { + Self { plan } + } + + pub fn evaluate( + &self, + snapshots: &[ExecutionSnapshot], + ) -> Result { + let snapshots_by_id = snapshots_by_execution_id(snapshots)?; + let mut legs = Vec::with_capacity(self.plan.legs.len()); + let mut filled_parent_budget = Decimal::ZERO; + let parent_budget = self.plan.parent_budget(); + + for leg in &self.plan.legs { + let snapshot = snapshots_by_id + .get(&leg.intent.execution_id.0) + .ok_or_else(|| Error::PolicyViolation { + message: format!( + "missing snapshot for multi-asset leg {}", + leg.intent.execution_id.0 + ), + })?; + validate_leg_snapshot(&leg.intent, snapshot)?; + let progress = TargetProgressView::from_snapshot(snapshot); + let filled_budget = leg.parent_budget * progress.completion_ratio(); + let remaining_budget = (leg.parent_budget - filled_budget).max(Decimal::ZERO); + filled_parent_budget += filled_budget; + legs.push(MultiAssetLegProgress { + execution_id: leg.intent.execution_id.0.clone(), + symbol: leg.intent.symbol.venue_symbol.clone(), + parent_budget: leg.parent_budget, + filled_parent_budget: filled_budget, + remaining_parent_budget: remaining_budget, + target_progress: progress, + }); + } + + Ok(MultiAssetParentSnapshot { + parent_id: self.plan.parent_id.clone(), + parent_budget, + filled_parent_budget, + remaining_parent_budget: (parent_budget - filled_parent_budget).max(Decimal::ZERO), + legs, + }) + } + + pub fn allocate_child_parent_budget( + &self, + snapshots: &[ExecutionSnapshot], + requested_parent_budget: Decimal, + ) -> Result, Error> { + let parent_snapshot = self.evaluate(snapshots)?; + let allocatable = requested_parent_budget + .max(Decimal::ZERO) + .min(parent_snapshot.remaining_parent_budget); + let total_remaining = parent_snapshot + .legs + .iter() + .map(|leg| leg.remaining_parent_budget) + .sum::(); + if total_remaining <= Decimal::ZERO { + return Ok(parent_snapshot + .legs + .iter() + .map(|leg| LegBudgetAllocation { + execution_id: leg.execution_id.clone(), + parent_budget: Decimal::ZERO, + }) + .collect()); + } + + Ok(parent_snapshot + .legs + .iter() + .map(|leg| LegBudgetAllocation { + execution_id: leg.execution_id.clone(), + parent_budget: allocatable * leg.remaining_parent_budget / total_remaining, + }) + .collect()) + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct MultiAssetParentSnapshot { + pub parent_id: String, + pub parent_budget: Decimal, + pub filled_parent_budget: Decimal, + pub remaining_parent_budget: Decimal, + pub legs: Vec, +} + +impl MultiAssetParentSnapshot { + pub fn completion_ratio(&self) -> Decimal { + if self.parent_budget <= Decimal::ZERO { + return Decimal::ONE; + } + (self.filled_parent_budget / self.parent_budget).clamp(Decimal::ZERO, Decimal::ONE) + } + + pub fn is_complete(&self) -> bool { + self.remaining_parent_budget <= Decimal::ZERO + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct MultiAssetLegProgress { + pub execution_id: String, + pub symbol: String, + pub parent_budget: Decimal, + pub filled_parent_budget: Decimal, + pub remaining_parent_budget: Decimal, + pub target_progress: TargetProgressView, +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct LegBudgetAllocation { + pub execution_id: String, + pub parent_budget: Decimal, +} diff --git a/crates/exh-kit/src/multi_asset/helpers.rs b/crates/exh-kit/src/multi_asset/helpers.rs new file mode 100644 index 0000000..08ce23e --- /dev/null +++ b/crates/exh-kit/src/multi_asset/helpers.rs @@ -0,0 +1,60 @@ +use exh::{Error, ExecutionIntent, ExecutionSnapshot}; +use std::collections::BTreeMap; + +pub(super) fn snapshots_by_execution_id( + snapshots: &[ExecutionSnapshot], +) -> Result, Error> { + let mut snapshots_by_id = BTreeMap::new(); + for snapshot in snapshots { + let execution_id = snapshot.intent.execution_id.0.clone(); + if snapshots_by_id + .insert(execution_id.clone(), snapshot) + .is_some() + { + return Err(Error::PolicyViolation { + message: format!("duplicate snapshot for execution id {execution_id}"), + }); + } + } + Ok(snapshots_by_id) +} + +pub(super) fn validate_leg_snapshot( + planned: &ExecutionIntent, + snapshot: &ExecutionSnapshot, +) -> Result<(), Error> { + let actual = &snapshot.intent; + if actual.symbol != planned.symbol { + return Err(Error::PolicyViolation { + message: format!( + "snapshot symbol {} does not match planned leg {} for execution {}", + actual.symbol.venue_symbol, planned.symbol.venue_symbol, planned.execution_id.0 + ), + }); + } + if actual.market_kind != planned.market_kind { + return Err(Error::PolicyViolation { + message: format!( + "snapshot market kind {} does not match planned leg {} for execution {}", + actual.market_kind, planned.market_kind, planned.execution_id.0 + ), + }); + } + if actual.side != planned.side { + return Err(Error::PolicyViolation { + message: format!( + "snapshot side {} does not match planned leg {} for execution {}", + actual.side, planned.side, planned.execution_id.0 + ), + }); + } + if actual.target != planned.target { + return Err(Error::PolicyViolation { + message: format!( + "snapshot target does not match planned leg target for execution {}", + planned.execution_id.0 + ), + }); + } + Ok(()) +} diff --git a/crates/exh-kit/src/multi_asset/lifecycle.rs b/crates/exh-kit/src/multi_asset/lifecycle.rs new file mode 100644 index 0000000..c6f0760 --- /dev/null +++ b/crates/exh-kit/src/multi_asset/lifecycle.rs @@ -0,0 +1,184 @@ +use exh::{Error, ExecutionSnapshot}; +use mkt::types::Decimal; +use std::future::Future; +use time::OffsetDateTime; + +use super::coordinator::{LegBudgetAllocation, MultiAssetCoordinator, MultiAssetParentSnapshot}; +use super::helpers::snapshots_by_execution_id; +use super::state::{ParentAuditEvent, ParentRunState, ParentStatePatch}; + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct ParentExecutionRunner { + pub coordinator: MultiAssetCoordinator, +} + +impl ParentExecutionRunner { + pub fn new(coordinator: MultiAssetCoordinator) -> Self { + Self { coordinator } + } + + pub async fn allocate_and_advance( + &self, + snapshots: &[ExecutionSnapshot], + requested_parent_budget: Decimal, + advance_leg: F, + ) -> Result, Error> + where + F: FnMut(&ExecutionSnapshot, &LegBudgetAllocation) -> Fut, + Fut: Future>, + { + let (parent_snapshot, allocations, leg_outputs) = self + .allocate_and_advance_core(snapshots, requested_parent_budget, advance_leg) + .await?; + Ok(ParentExecutionRun { + parent_snapshot, + allocations, + leg_outputs, + parent_run_state: None, + }) + } + + pub async fn allocate_and_advance_with_parent_run_state( + &self, + snapshots: &[ExecutionSnapshot], + requested_parent_budget: Decimal, + parent_run_state: ParentRunState, + advance_leg: F, + ) -> Result, Error> + where + F: FnMut(&ExecutionSnapshot, &LegBudgetAllocation) -> Fut, + Fut: Future>, + { + let (parent_snapshot, allocations, leg_outputs) = self + .allocate_and_advance_core(snapshots, requested_parent_budget, advance_leg) + .await?; + Ok(ParentExecutionRun { + parent_snapshot, + allocations, + leg_outputs, + parent_run_state: Some(parent_run_state), + }) + } + + pub async fn allocate_and_advance_with_parent_lifecycle( + &self, + snapshots: &[ExecutionSnapshot], + requested_parent_budget: Decimal, + mut parent_run_state: ParentRunState, + advance_leg: F, + parent_lifecycle: L, + ) -> Result, Error> + where + F: FnMut(&ExecutionSnapshot, &LegBudgetAllocation) -> Fut, + Fut: Future>, + L: for<'a> FnOnce( + ParentRunLifecycleContext<'a, O>, + ) -> Result, + { + let (parent_snapshot, allocations, leg_outputs) = self + .allocate_and_advance_core(snapshots, requested_parent_budget, advance_leg) + .await?; + match parent_lifecycle(ParentRunLifecycleContext { + parent_snapshot: &parent_snapshot, + requested_parent_budget, + allocations: &allocations, + leg_outputs: &leg_outputs, + parent_run_state: &parent_run_state, + }) { + Ok(effects) => parent_run_state.apply_lifecycle_effects(effects), + Err(error) => { + tracing::warn!(error = %error, "parent lifecycle failed after leg advancement") + } + } + Ok(ParentExecutionRun { + parent_snapshot, + allocations, + leg_outputs, + parent_run_state: Some(parent_run_state), + }) + } + + async fn allocate_and_advance_core( + &self, + snapshots: &[ExecutionSnapshot], + requested_parent_budget: Decimal, + mut advance_leg: F, + ) -> Result<(MultiAssetParentSnapshot, Vec, Vec), Error> + where + F: FnMut(&ExecutionSnapshot, &LegBudgetAllocation) -> Fut, + Fut: Future>, + { + let parent_snapshot = self.coordinator.evaluate(snapshots)?; + let allocations = self + .coordinator + .allocate_child_parent_budget(snapshots, requested_parent_budget)?; + let snapshots_by_id = snapshots_by_execution_id(snapshots)?; + let mut leg_outputs = Vec::with_capacity(allocations.len()); + for allocation in &allocations { + let snapshot = snapshots_by_id + .get(&allocation.execution_id) + .ok_or_else(|| Error::PolicyViolation { + message: format!( + "missing snapshot for allocated leg {}", + allocation.execution_id + ), + })?; + leg_outputs.push(advance_leg(snapshot, allocation).await?); + } + + Ok((parent_snapshot, allocations, leg_outputs)) + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct ParentExecutionRun { + pub parent_snapshot: MultiAssetParentSnapshot, + pub allocations: Vec, + pub leg_outputs: Vec, + pub parent_run_state: Option, +} + +#[derive(Debug)] +#[non_exhaustive] +pub struct ParentRunLifecycleContext<'a, O> { + pub parent_snapshot: &'a MultiAssetParentSnapshot, + pub requested_parent_budget: Decimal, + pub allocations: &'a [LegBudgetAllocation], + pub leg_outputs: &'a [O], + pub parent_run_state: &'a ParentRunState, +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct ParentRunLifecycleEffects { + #[allow(clippy::struct_field_names)] + pub state_patch: Option, + pub audit_events: Vec, + pub recorded_at: OffsetDateTime, +} + +impl ParentRunLifecycleEffects { + pub fn new(recorded_at: OffsetDateTime) -> Self { + Self { + state_patch: None, + audit_events: Vec::new(), + recorded_at, + } + } + + pub fn state_patch(mut self, state_patch: ParentStatePatch) -> Self { + self.state_patch = Some(state_patch); + self + } + + pub fn audit_event(mut self, audit_event: ParentAuditEvent) -> Self { + self.audit_events.push(audit_event); + self + } + + pub fn is_empty(&self) -> bool { + self.state_patch.is_none() && self.audit_events.is_empty() + } +} diff --git a/crates/exh-kit/src/multi_asset/schema.rs b/crates/exh-kit/src/multi_asset/schema.rs new file mode 100644 index 0000000..d9e02a4 --- /dev/null +++ b/crates/exh-kit/src/multi_asset/schema.rs @@ -0,0 +1,159 @@ +use exh::Error; +use serde::Serialize; +use serde::de::DeserializeOwned; +use time::OffsetDateTime; + +use crate::schema::validate_identifier; + +use super::lifecycle::ParentRunLifecycleEffects; +use super::state::{ParentAuditEvent, ParentRunState, ParentStatePatch, ParentStateView}; + +pub trait ParentStateSchema: Sized + Serialize + DeserializeOwned { + const SCHEMA: &'static str; + const VERSION: u32; + + fn to_parent_patch(&self) -> Result { + validate_identifier("parent state schema", Self::SCHEMA)?; + let payload = serde_json::to_value(self).map_err(|error| Error::PolicyViolation { + message: format!("serialize parent state {}: {error}", Self::SCHEMA), + })?; + Ok(ParentStatePatch::new(Self::SCHEMA, Self::VERSION, payload)) + } + + fn from_parent_patch(patch: &ParentStatePatch) -> Result { + if patch.schema != Self::SCHEMA { + return Err(Error::InvalidRecovery { + message: format!( + "parent state patch schema {} does not match expected {}", + patch.schema, + Self::SCHEMA + ), + }); + } + if patch.version != Self::VERSION { + return Err(Error::InvalidRecovery { + message: format!( + "parent state patch {} version {} does not match expected {}", + Self::SCHEMA, + patch.version, + Self::VERSION + ), + }); + } + serde_json::from_value(patch.payload.clone()).map_err(|error| Error::InvalidRecovery { + message: format!("deserialize parent state {}: {error}", Self::SCHEMA), + }) + } + + fn load_parent_state(view: &ParentStateView) -> Result, Error> { + view.get(Self::SCHEMA) + .map(Self::from_parent_patch) + .transpose() + } +} + +pub trait ParentAuditSchema: Sized + Serialize + DeserializeOwned { + const EVENT_TYPE: &'static str; + const VERSION: u32; + + fn to_parent_event(&self, recorded_at: OffsetDateTime) -> Result { + validate_identifier("parent audit event type", Self::EVENT_TYPE)?; + let payload = serde_json::to_value(self).map_err(|error| Error::PolicyViolation { + message: format!("serialize parent audit {}: {error}", Self::EVENT_TYPE), + })?; + Ok(ParentAuditEvent::new( + Self::EVENT_TYPE, + Self::VERSION, + payload, + recorded_at, + )) + } + + fn from_parent_event(event: &ParentAuditEvent) -> Result { + if event.event_type != Self::EVENT_TYPE { + return Err(Error::InvalidRecovery { + message: format!( + "parent audit event type {} does not match expected {}", + event.event_type, + Self::EVENT_TYPE + ), + }); + } + if event.version != Self::VERSION { + return Err(Error::InvalidRecovery { + message: format!( + "parent audit event {} version {} does not match expected {}", + Self::EVENT_TYPE, + event.version, + Self::VERSION + ), + }); + } + serde_json::from_value(event.payload.clone()).map_err(|error| Error::InvalidRecovery { + message: format!("deserialize parent audit {}: {error}", Self::EVENT_TYPE), + }) + } + + fn collect_parent_events(events: &[ParentAuditEvent]) -> Result, Error> { + events + .iter() + .filter(|event| event.event_type == Self::EVENT_TYPE) + .map(Self::from_parent_event) + .collect() + } +} + +pub trait ParentRunStateSchemaExt: Sized { + fn with_typed_state(self, state: &T, recorded_at: OffsetDateTime) -> Result + where + T: ParentStateSchema; + + fn with_typed_audit(self, event: &T, recorded_at: OffsetDateTime) -> Result + where + T: ParentAuditSchema; +} + +impl ParentRunStateSchemaExt for ParentRunState { + fn with_typed_state(mut self, state: &T, recorded_at: OffsetDateTime) -> Result + where + T: ParentStateSchema, + { + self.apply_state_patch(state.to_parent_patch()?, recorded_at); + Ok(self) + } + + fn with_typed_audit(mut self, event: &T, recorded_at: OffsetDateTime) -> Result + where + T: ParentAuditSchema, + { + self.record_audit_event(event.to_parent_event(recorded_at)?); + Ok(self) + } +} + +pub trait ParentRunLifecycleEffectsSchemaExt: Sized { + fn with_typed_state(self, state: &T) -> Result + where + T: ParentStateSchema; + + fn with_typed_audit(self, event: &T) -> Result + where + T: ParentAuditSchema; +} + +impl ParentRunLifecycleEffectsSchemaExt for ParentRunLifecycleEffects { + fn with_typed_state(self, state: &T) -> Result + where + T: ParentStateSchema, + { + Ok(self.state_patch(state.to_parent_patch()?)) + } + + fn with_typed_audit(self, event: &T) -> Result + where + T: ParentAuditSchema, + { + let recorded_at = self.recorded_at; + Ok(self.audit_event(event.to_parent_event(recorded_at)?)) + } +} diff --git a/crates/exh-kit/src/multi_asset/state.rs b/crates/exh-kit/src/multi_asset/state.rs new file mode 100644 index 0000000..fe0deb0 --- /dev/null +++ b/crates/exh-kit/src/multi_asset/state.rs @@ -0,0 +1,110 @@ +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeMap; +use time::OffsetDateTime; + +use super::lifecycle::ParentRunLifecycleEffects; + +#[derive(Debug, Clone, Default, PartialEq, Serialize, serde::Deserialize)] +#[non_exhaustive] +pub struct ParentRunState { + pub state: ParentStateView, + pub audit_events: Vec, + #[serde(with = "time::serde::timestamp::milliseconds::option")] + pub last_updated_at: Option, +} + +impl ParentRunState { + pub fn new() -> Self { + Self::default() + } + + pub fn apply_state_patch(&mut self, patch: ParentStatePatch, recorded_at: OffsetDateTime) { + self.state.apply_patch(patch); + self.touch(recorded_at); + } + + pub fn record_audit_event(&mut self, event: ParentAuditEvent) { + self.touch(event.recorded_at); + self.audit_events.push(event); + } + + pub fn apply_lifecycle_effects(&mut self, effects: ParentRunLifecycleEffects) { + if let Some(patch) = effects.state_patch { + self.apply_state_patch(patch, effects.recorded_at); + } + for event in effects.audit_events { + self.record_audit_event(event); + } + } + + fn touch(&mut self, recorded_at: OffsetDateTime) { + self.last_updated_at = Some(self.last_updated_at.map_or(recorded_at, |last_updated_at| { + last_updated_at.max(recorded_at) + })); + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)] +#[non_exhaustive] +pub struct ParentStatePatch { + pub schema: String, + pub version: u32, + pub payload: Value, +} + +impl ParentStatePatch { + pub fn new(schema: impl Into, version: u32, payload: Value) -> Self { + Self { + schema: schema.into(), + version, + payload, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, serde::Deserialize)] +#[non_exhaustive] +pub struct ParentStateView { + pub patches_by_schema: BTreeMap, +} + +impl ParentStateView { + pub fn new() -> Self { + Self::default() + } + + pub fn apply_patch(&mut self, patch: ParentStatePatch) { + self.patches_by_schema.insert(patch.schema.clone(), patch); + } + + pub fn get(&self, schema: &str) -> Option<&ParentStatePatch> { + self.patches_by_schema.get(schema) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)] +#[non_exhaustive] +pub struct ParentAuditEvent { + pub event_type: String, + pub version: u32, + pub payload: Value, + #[serde(with = "time::serde::timestamp::milliseconds")] + pub recorded_at: OffsetDateTime, +} + +impl ParentAuditEvent { + pub fn new( + event_type: impl Into, + version: u32, + payload: Value, + recorded_at: OffsetDateTime, + ) -> Self { + Self { + event_type: event_type.into(), + version, + payload, + recorded_at, + } + } +} diff --git a/crates/exh-kit/src/primitives.rs b/crates/exh-kit/src/primitives.rs new file mode 100644 index 0000000..cca71b4 --- /dev/null +++ b/crates/exh-kit/src/primitives.rs @@ -0,0 +1,75 @@ +use exh::{ + AlgorithmMode, ChildKey, ChildTarget, DesiredState, Error, ExecutionSnapshot, OrderRequest, +}; +use mkt::types::{Decimal, OrderSide}; +use time::OffsetDateTime; + +use crate::signals::SignalFrame; + +pub fn child_target(key: impl Into, request: OrderRequest) -> ChildTarget { + ChildTarget::new(ChildKey::new(key), request) +} + +pub fn running(children: Vec) -> DesiredState { + DesiredState::new(AlgorithmMode::Running, children) +} + +pub fn paused() -> DesiredState { + DesiredState::paused() +} + +pub fn finishing_completed() -> DesiredState { + DesiredState::finishing(exh::TerminalState::Completed) +} + +pub fn remaining(snapshot: &ExecutionSnapshot) -> Decimal { + snapshot.remaining_base_quantity().unwrap_or(Decimal::ZERO) +} + +pub fn schedule_progress( + window_start: OffsetDateTime, + window_end: OffsetDateTime, + observed_at: OffsetDateTime, +) -> Result { + let total_window_ms = (window_end - window_start).whole_milliseconds(); + if total_window_ms <= 0 { + return Err(Error::PolicyViolation { + message: "window must be positive".to_owned(), + }); + } + let elapsed_ms = (observed_at - window_start) + .whole_milliseconds() + .clamp(0, total_window_ms); + Ok(Decimal::from(elapsed_ms) / Decimal::from(total_window_ms)) +} + +pub fn participation_quantity( + cumulative_volume: Decimal, + schedule_ratio: Decimal, + max_participation_bps: u32, +) -> Decimal { + let participation = Decimal::from(max_participation_bps) / Decimal::from(10_000_u32); + cumulative_volume * schedule_ratio * participation +} + +pub fn price_band_limit(last_price: Decimal, side: OrderSide, band_bps: u32) -> Decimal { + let band = Decimal::from(band_bps) / Decimal::from(10_000_u32); + match side { + OrderSide::Buy => last_price * (Decimal::ONE + band), + OrderSide::Sell => last_price * (Decimal::ONE - band), + _ => last_price, + } +} + +pub fn micro_price(frame: &SignalFrame) -> Option { + frame.metrics.micro_price.or_else(|| { + let book = frame.book_ticker.as_ref()?; + let numerator = (book.ask_price * book.bid_quantity) + (book.bid_price * book.ask_quantity); + let denominator = book.bid_quantity + book.ask_quantity; + if denominator <= Decimal::ZERO { + None + } else { + Some(numerator / denominator) + } + }) +} diff --git a/crates/exh-kit/src/progress.rs b/crates/exh-kit/src/progress.rs new file mode 100644 index 0000000..34f2742 --- /dev/null +++ b/crates/exh-kit/src/progress.rs @@ -0,0 +1,89 @@ +use exh::{Error, ExecutionSnapshot, ExecutionTarget, OrderRequest}; +use mkt::types::Decimal; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TargetValueKind { + BaseQuantity, + QuoteBudget, +} + +impl From for TargetValueKind { + fn from(target: ExecutionTarget) -> Self { + if target.is_base_quantity() { + Self::BaseQuantity + } else { + Self::QuoteBudget + } + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct TargetProgressView { + pub target: ExecutionTarget, + pub kind: TargetValueKind, + pub target_value: Decimal, + pub filled_value: Decimal, + pub remaining_value: Decimal, +} + +impl TargetProgressView { + pub fn from_snapshot(snapshot: &ExecutionSnapshot) -> Self { + Self::from_target_and_filled(snapshot.intent.target, snapshot.target_progress_value()) + } + + pub fn from_target_and_filled(target: ExecutionTarget, filled_value: Decimal) -> Self { + let target_value = target.value(); + let filled_value = filled_value.max(Decimal::ZERO); + Self { + target, + kind: target.into(), + target_value, + filled_value, + remaining_value: (target_value - filled_value).max(Decimal::ZERO), + } + } + + pub fn completion_ratio(&self) -> Decimal { + if self.target_value <= Decimal::ZERO { + return Decimal::ONE; + } + (self.filled_value / self.target_value).clamp(Decimal::ZERO, Decimal::ONE) + } + + pub fn is_complete(&self) -> bool { + self.remaining_value <= Decimal::ZERO + } + + pub fn child_request_value(&self, request: &OrderRequest) -> Result { + match self.kind { + TargetValueKind::BaseQuantity => { + request + .execution_base_quantity() + .ok_or_else(|| Error::PolicyViolation { + message: "base target requires a child request with known base quantity" + .to_owned(), + }) + } + TargetValueKind::QuoteBudget => { + request + .execution_quote_amount() + .ok_or_else(|| Error::PolicyViolation { + message: "quote target requires a child request with known quote value" + .to_owned(), + }) + } + } + } + + pub fn cap_child_value(&self, requested_value: Decimal) -> Decimal { + requested_value.max(Decimal::ZERO).min(self.remaining_value) + } + + pub fn deficit_to_completion(&self, scheduled_completion: Decimal) -> Decimal { + let scheduled_value = + self.target_value * scheduled_completion.clamp(Decimal::ZERO, Decimal::ONE); + (scheduled_value - self.filled_value).max(Decimal::ZERO) + } +} diff --git a/crates/exh-kit/src/schedule.rs b/crates/exh-kit/src/schedule.rs new file mode 100644 index 0000000..dd64809 --- /dev/null +++ b/crates/exh-kit/src/schedule.rs @@ -0,0 +1,325 @@ +use exh::Error; +use mkt::types::Decimal; +use time::OffsetDateTime; + +use crate::progress::TargetProgressView; + +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub struct TimeWindow { + pub start: OffsetDateTime, + pub end: OffsetDateTime, +} + +impl TimeWindow { + pub fn new(start: OffsetDateTime, end: OffsetDateTime) -> Self { + Self { start, end } + } + + pub fn progress_at(self, observed_at: OffsetDateTime) -> Result { + let total_window_ms = (self.end - self.start).whole_milliseconds(); + if total_window_ms <= 0 { + return Err(Error::PolicyViolation { + message: "schedule window must be positive".to_owned(), + }); + } + let elapsed_ms = (observed_at - self.start) + .whole_milliseconds() + .clamp(0, total_window_ms); + Ok(Decimal::from(elapsed_ms) / Decimal::from(total_window_ms)) + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct ParentSchedule { + pub window: TimeWindow, + pub catch_up_multiplier: Decimal, + pub participation_limit_bps: Option, +} + +impl ParentSchedule { + pub fn linear(window: TimeWindow) -> Self { + Self { + window, + catch_up_multiplier: Decimal::ONE, + participation_limit_bps: None, + } + } + + pub fn with_catch_up_multiplier(mut self, multiplier: Decimal) -> Self { + self.catch_up_multiplier = multiplier.max(Decimal::ZERO); + self + } + + pub fn with_participation_limit_bps(mut self, limit_bps: u32) -> Self { + self.participation_limit_bps = Some(limit_bps); + self + } + + pub fn evaluate( + &self, + progress: TargetProgressView, + observed_at: OffsetDateTime, + ) -> Result { + let scheduled_completion = self.window.progress_at(observed_at)?; + let target_value_due = progress.target_value * scheduled_completion; + let deficit_value = (target_value_due - progress.filled_value).max(Decimal::ZERO); + let catch_up_child_value = (deficit_value * self.catch_up_multiplier) + .max(Decimal::ZERO) + .min(progress.remaining_value); + let catch_up_pressure = if progress.remaining_value <= Decimal::ZERO { + Decimal::ZERO + } else { + (deficit_value / progress.remaining_value).clamp(Decimal::ZERO, Decimal::ONE) + }; + + Ok(ScheduleEvaluation { + scheduled_completion, + actual_completion: progress.completion_ratio(), + target_value_due, + deficit_value, + catch_up_child_value, + catch_up_pressure, + }) + } + + pub fn participation_budget( + &self, + progress: TargetProgressView, + observed_at: OffsetDateTime, + cumulative_market_volume_in_target_units: Decimal, + ) -> Result { + let schedule_progress = self.window.progress_at(observed_at)?; + let Some(limit_bps) = self.participation_limit_bps else { + return Ok(progress.remaining_value); + }; + let participation = Decimal::from(limit_bps) / Decimal::from(10_000_u32); + Ok( + (cumulative_market_volume_in_target_units * schedule_progress * participation) + .max(Decimal::ZERO) + .min(progress.remaining_value), + ) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +#[non_exhaustive] +pub struct ChildBudgetContext { + pub interval_market_volume_in_target_units: Option, + pub volatility_throttle: Option, + pub alpha_urgency: Option, +} + +impl ChildBudgetContext { + pub fn new() -> Self { + Self::default() + } + + pub fn with_interval_market_volume_in_target_units( + mut self, + interval_market_volume_in_target_units: Decimal, + ) -> Self { + self.interval_market_volume_in_target_units = + Some(interval_market_volume_in_target_units.max(Decimal::ZERO)); + self + } + + pub fn with_volatility_throttle(mut self, volatility_throttle: Decimal) -> Self { + self.volatility_throttle = Some(volatility_throttle.clamp(Decimal::ZERO, Decimal::ONE)); + self + } + + pub fn with_alpha_urgency(mut self, alpha_urgency: Decimal) -> Self { + self.alpha_urgency = Some(alpha_urgency.max(Decimal::ZERO)); + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub struct ChildBudgetPolicy { + pub min_child_value: Decimal, + pub urgency_floor_value: Decimal, + pub interval_participation_limit_bps: Option, + pub volatility_throttle: Option, + pub alpha_urgency_decay: Option, +} + +impl ChildBudgetPolicy { + pub fn new() -> Self { + Self { + min_child_value: Decimal::ZERO, + urgency_floor_value: Decimal::ZERO, + interval_participation_limit_bps: None, + volatility_throttle: None, + alpha_urgency_decay: None, + } + } + + pub fn with_min_child_value(mut self, min_child_value: Decimal) -> Self { + self.min_child_value = min_child_value.max(Decimal::ZERO); + self + } + + pub fn with_urgency_floor_value(mut self, urgency_floor_value: Decimal) -> Self { + self.urgency_floor_value = urgency_floor_value.max(Decimal::ZERO); + self + } + + /// Configures a strict interval participation cap. + /// + /// Use [`Self::target_child_value_with_context`] with interval market volume when this is set. + /// Calling [`Self::target_child_value`] without context will fail rather than silently bypassing + /// the cap. + pub fn with_interval_participation_limit_bps(mut self, limit_bps: u32) -> Self { + self.interval_participation_limit_bps = Some(limit_bps); + self + } + + pub fn with_volatility_throttle(mut self, throttle: Decimal) -> Self { + self.volatility_throttle = Some(throttle.clamp(Decimal::ZERO, Decimal::ONE)); + self + } + + pub fn with_alpha_urgency_decay(mut self, decay: Decimal) -> Self { + self.alpha_urgency_decay = Some(decay.max(Decimal::ZERO)); + self + } + + /// Computes a child budget without optional context inputs. + /// + /// Returns [`Error::PolicyViolation`] when interval participation is configured because + /// interval market volume is required to enforce that cap. Use + /// [`Self::target_child_value_no_context`] only when deliberately choosing the basic + /// no-context path. + pub fn target_child_value( + &self, + progress: &TargetProgressView, + schedule: &ScheduleEvaluation, + participation_budget: Option, + ) -> Result { + if self.interval_participation_limit_bps.is_some() { + return Err(Self::missing_interval_volume_error()); + } + + Ok(self.target_child_value_no_context(progress, schedule, participation_budget)) + } + + /// Computes the basic no-context child budget. + /// + /// This deliberately ignores context-only controls that require runtime context, including + /// interval participation volume and alpha urgency. + pub fn target_child_value_no_context( + &self, + progress: &TargetProgressView, + schedule: &ScheduleEvaluation, + participation_budget: Option, + ) -> Decimal { + self.target_child_value_from_context( + progress, + schedule, + participation_budget, + ChildBudgetContext::default(), + ) + } + + /// Computes a context-aware child budget. + /// + /// Returns [`Error::PolicyViolation`] when interval participation is configured without + /// interval market volume, because otherwise the cap would be silently bypassed. + pub fn target_child_value_with_context( + &self, + progress: &TargetProgressView, + schedule: &ScheduleEvaluation, + participation_budget: Option, + context: ChildBudgetContext, + ) -> Result { + if self.interval_participation_limit_bps.is_some() + && context.interval_market_volume_in_target_units.is_none() + { + return Err(Self::missing_interval_volume_error()); + } + + Ok(self.target_child_value_from_context(progress, schedule, participation_budget, context)) + } + + fn missing_interval_volume_error() -> Error { + Error::PolicyViolation { + message: "interval participation limit requires interval market volume".to_owned(), + } + } + + fn target_child_value_from_context( + &self, + progress: &TargetProgressView, + schedule: &ScheduleEvaluation, + participation_budget: Option, + context: ChildBudgetContext, + ) -> Decimal { + let participation_budget = participation_budget.unwrap_or(Decimal::ZERO); + let floor = if schedule.deficit_value > Decimal::ZERO + || participation_budget > Decimal::ZERO + || self.urgency_floor_value > Decimal::ZERO + { + self.min_child_value + } else { + Decimal::ZERO + }; + let mut child_value = schedule + .catch_up_child_value + .max(participation_budget) + .max(floor) + .max(self.urgency_floor_value); + + if let (Some(limit_bps), Some(interval_volume)) = ( + self.interval_participation_limit_bps, + context.interval_market_volume_in_target_units, + ) { + let interval_participation = Decimal::from(limit_bps) / Decimal::from(10_000_u32); + let interval_cap = interval_volume.max(Decimal::ZERO) * interval_participation; + child_value = child_value.min(interval_cap.max(Decimal::ZERO)); + } + + let volatility_throttle = match (self.volatility_throttle, context.volatility_throttle) { + (Some(policy_throttle), Some(context_throttle)) => { + Some(policy_throttle.min(context_throttle)) + } + (Some(policy_throttle), None) => Some(policy_throttle), + (None, Some(context_throttle)) => Some(context_throttle), + (None, None) => None, + }; + if let Some(throttle) = volatility_throttle { + child_value *= throttle.clamp(Decimal::ZERO, Decimal::ONE); + } + + if let (Some(alpha_urgency), Some(decay)) = + (context.alpha_urgency, self.alpha_urgency_decay) + { + let decay = decay.max(Decimal::ZERO); + let contribution = self.urgency_floor_value * alpha_urgency.max(Decimal::ZERO) + / (Decimal::ONE + decay); + let remaining_after_child = (progress.remaining_value - child_value).max(Decimal::ZERO); + child_value += contribution.max(Decimal::ZERO).min(remaining_after_child); + } + + progress.cap_child_value(child_value) + } +} + +impl Default for ChildBudgetPolicy { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct ScheduleEvaluation { + pub scheduled_completion: Decimal, + pub actual_completion: Decimal, + pub target_value_due: Decimal, + pub deficit_value: Decimal, + pub catch_up_child_value: Decimal, + pub catch_up_pressure: Decimal, +} diff --git a/crates/exh-kit/src/schema.rs b/crates/exh-kit/src/schema.rs new file mode 100644 index 0000000..b940b5a --- /dev/null +++ b/crates/exh-kit/src/schema.rs @@ -0,0 +1,169 @@ +//! Typed adapters for algorithm-owned state and audit journal envelopes. +//! +//! The `exh` kernel persists versioned JSON envelopes so replay stays independent +//! from any one strategy type. This module lets strategy code define typed Rust +//! schemas at the edge and keep raw JSON construction out of algorithm logic. + +use exh::{ + AlgorithmAuditEvent, AlgorithmDecision, AlgorithmLifecycleEffects, AlgorithmStatePatch, + AlgorithmStateView, Error, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +pub trait AlgorithmStateSchema: Sized + Serialize + DeserializeOwned { + const SCHEMA: &'static str; + const VERSION: u32; + + fn to_patch(&self) -> Result { + validate_identifier("algorithm state schema", Self::SCHEMA)?; + let payload = serde_json::to_value(self).map_err(|error| Error::PolicyViolation { + message: format!("serialize algorithm state {}: {error}", Self::SCHEMA), + })?; + Ok(AlgorithmStatePatch::new( + Self::SCHEMA, + Self::VERSION, + payload, + )) + } + + fn from_patch(patch: &AlgorithmStatePatch) -> Result { + if patch.schema != Self::SCHEMA { + return Err(Error::InvalidRecovery { + message: format!( + "algorithm state patch schema {} does not match expected {}", + patch.schema, + Self::SCHEMA + ), + }); + } + if patch.version != Self::VERSION { + return Err(Error::InvalidRecovery { + message: format!( + "algorithm state patch {} version {} does not match expected {}", + Self::SCHEMA, + patch.version, + Self::VERSION + ), + }); + } + serde_json::from_value(patch.payload.clone()).map_err(|error| Error::InvalidRecovery { + message: format!("deserialize algorithm state {}: {error}", Self::SCHEMA), + }) + } + + fn load(view: &AlgorithmStateView) -> Result, Error> { + view.get(Self::SCHEMA).map(Self::from_patch).transpose() + } +} + +pub trait AlgorithmAuditSchema: Sized + Serialize + DeserializeOwned { + const EVENT_TYPE: &'static str; + const VERSION: u32; + + fn to_event(&self) -> Result { + validate_identifier("algorithm audit event type", Self::EVENT_TYPE)?; + let payload = serde_json::to_value(self).map_err(|error| Error::PolicyViolation { + message: format!("serialize algorithm audit {}: {error}", Self::EVENT_TYPE), + })?; + Ok(AlgorithmAuditEvent::new( + Self::EVENT_TYPE, + Self::VERSION, + payload, + )) + } + + fn from_event(event: &AlgorithmAuditEvent) -> Result { + if event.event_type != Self::EVENT_TYPE { + return Err(Error::InvalidRecovery { + message: format!( + "algorithm audit event type {} does not match expected {}", + event.event_type, + Self::EVENT_TYPE + ), + }); + } + if event.version != Self::VERSION { + return Err(Error::InvalidRecovery { + message: format!( + "algorithm audit event {} version {} does not match expected {}", + Self::EVENT_TYPE, + event.version, + Self::VERSION + ), + }); + } + serde_json::from_value(event.payload.clone()).map_err(|error| Error::InvalidRecovery { + message: format!("deserialize algorithm audit {}: {error}", Self::EVENT_TYPE), + }) + } + + fn collect_from(events: &[AlgorithmAuditEvent]) -> Result, Error> { + events + .iter() + .filter(|event| event.event_type == Self::EVENT_TYPE) + .map(Self::from_event) + .collect() + } +} + +pub trait AlgorithmDecisionSchemaExt: Sized { + fn with_typed_state(self, state: &T) -> Result + where + T: AlgorithmStateSchema; + + fn with_typed_audit(self, event: &T) -> Result + where + T: AlgorithmAuditSchema; +} + +impl AlgorithmDecisionSchemaExt for AlgorithmDecision { + fn with_typed_state(self, state: &T) -> Result + where + T: AlgorithmStateSchema, + { + Ok(self.state_patch(state.to_patch()?)) + } + + fn with_typed_audit(self, event: &T) -> Result + where + T: AlgorithmAuditSchema, + { + Ok(self.audit_event(event.to_event()?)) + } +} + +pub trait AlgorithmLifecycleEffectsSchemaExt: Sized { + fn with_typed_state(self, state: &T) -> Result + where + T: AlgorithmStateSchema; + + fn with_typed_audit(self, event: &T) -> Result + where + T: AlgorithmAuditSchema; +} + +impl AlgorithmLifecycleEffectsSchemaExt for AlgorithmLifecycleEffects { + fn with_typed_state(self, state: &T) -> Result + where + T: AlgorithmStateSchema, + { + Ok(self.state_patch(state.to_patch()?)) + } + + fn with_typed_audit(self, event: &T) -> Result + where + T: AlgorithmAuditSchema, + { + Ok(self.audit_event(event.to_event()?)) + } +} + +pub(crate) fn validate_identifier(kind: &str, value: &str) -> Result<(), Error> { + if value.trim().is_empty() { + return Err(Error::PolicyViolation { + message: format!("{kind} must not be empty"), + }); + } + Ok(()) +} diff --git a/crates/exh-kit/src/signals.rs b/crates/exh-kit/src/signals.rs new file mode 100644 index 0000000..623cfef --- /dev/null +++ b/crates/exh-kit/src/signals.rs @@ -0,0 +1,57 @@ +use derive_builder::Builder; +use mkt::types::{BookTicker, Extensions, Kline, LastPrice, OrderBook, Trade}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Builder, Serialize, Deserialize)] +#[non_exhaustive] +#[builder(pattern = "owned", setter(into))] +pub struct SignalMetrics { + #[builder(default)] + pub cumulative_market_volume: Option, + #[builder(default)] + pub interval_market_volume: Option, + #[builder(default)] + pub arrival_price: Option, + #[builder(default)] + pub micro_price: Option, + #[builder(default)] + pub realized_volatility_bps: Option, + #[builder(default)] + pub alpha_score: Option, + #[builder(default)] + pub urgency_score: Option, +} + +impl SignalMetrics { + pub fn builder() -> SignalMetricsBuilder { + SignalMetricsBuilder::default() + } +} + +#[derive(Debug, Clone, PartialEq, Builder, Serialize, Deserialize)] +#[non_exhaustive] +#[builder(pattern = "owned", setter(into))] +pub struct SignalFrame { + #[builder(default)] + pub last_price: Option, + #[builder(default)] + pub book_ticker: Option, + #[builder(default)] + pub order_book: Option, + #[builder(default)] + pub recent_trades: Vec, + #[builder(default)] + pub klines: Vec, + #[builder( + default = "SignalMetrics::builder().build().expect(\"signal metrics builder cannot fail\")" + )] + pub metrics: SignalMetrics, + #[builder(default)] + pub extensions: Extensions, +} + +impl SignalFrame { + pub fn builder() -> SignalFrameBuilder { + SignalFrameBuilder::default() + } +} diff --git a/crates/exh-kit/src/strategy_prelude.rs b/crates/exh-kit/src/strategy_prelude.rs new file mode 100644 index 0000000..92426d1 --- /dev/null +++ b/crates/exh-kit/src/strategy_prelude.rs @@ -0,0 +1,32 @@ +//! Common imports for execution strategy authors. +//! +//! This module is intentionally explicit so examples can import the strategy +//! authoring surface without depending on broad wildcard re-exports. + +pub use exh::{ + Algorithm, AlgorithmDecision, AlgorithmLifecycleEffects, AlgorithmLifecycleEvent, ChildKey, + ChildTarget, DesiredState, EvaluateContext, LifecycleContext, OrderRequest, TerminalState, +}; + +pub use crate::child_order::{ + ChildId, ChildOrderFactory, ChildOrderStyle, SpotChildOrderInput, SpotChildOrderSpec, +}; +pub use crate::features::{ + SignalFeatureRequirement, SignalFeatureRequirements, SignalFeatureSchema, SignalFrameFeatureExt, +}; +pub use crate::lifecycle::{LifecycleEventKind, LifecycleEventView}; +pub use crate::multi_asset::{ + LegBudgetAllocation, MultiAssetCoordinator, MultiAssetLeg, MultiAssetPlan, ParentAuditEvent, + ParentAuditSchema, ParentExecutionRun, ParentExecutionRunner, ParentRunLifecycleContext, + ParentRunLifecycleEffects, ParentRunLifecycleEffectsSchemaExt, ParentRunState, + ParentRunStateSchemaExt, ParentStatePatch, ParentStateSchema, ParentStateView, +}; +pub use crate::progress::{TargetProgressView, TargetValueKind}; +pub use crate::schedule::{ + ChildBudgetContext, ChildBudgetPolicy, ParentSchedule, ScheduleEvaluation, TimeWindow, +}; +pub use crate::schema::{ + AlgorithmAuditSchema, AlgorithmDecisionSchemaExt, AlgorithmLifecycleEffectsSchemaExt, + AlgorithmStateSchema, +}; +pub use crate::signals::SignalFrame; diff --git a/crates/exh-kit/src/testing.rs b/crates/exh-kit/src/testing.rs new file mode 100644 index 0000000..4007f4e --- /dev/null +++ b/crates/exh-kit/src/testing.rs @@ -0,0 +1,393 @@ +use async_trait::async_trait; +use exh::{ + CancelOrderRequest, ChildKey, Driver, Error, ExecutionSnapshot, OrderQuery, OrderRequest, +}; +use mkt::types::{ + BookTicker, ClientOrderId, Decimal, ExchangeId, KnownExchange, LotSizeFilter, MarketInfo, + MarketKind, MarketQuantityMode, MarketStatus, NotionalConstraints, Order, OrderId, OrderKey, + OrderQuantity, OrderSide, OrderStatus, OrderType, PriceFilter, QuantityModeSupport, Symbol, + TradingConstraints, TradingPermissions, +}; +use std::collections::BTreeMap; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; + +use crate::signals::SignalFrame; + +pub fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("invalid fixture decimal literal; tests cannot proceed") +} + +#[non_exhaustive] +pub struct SpotMarketFixture { + symbol: Symbol, + tick_size: Decimal, + min_quantity: Decimal, + step_size: Decimal, + min_notional: Decimal, + supported_order_types: Vec, +} + +pub fn spot_market(symbol: Symbol) -> SpotMarketFixture { + SpotMarketFixture { + symbol, + tick_size: decimal("0.1"), + min_quantity: decimal("0.1"), + step_size: decimal("0.1"), + min_notional: decimal("10"), + supported_order_types: vec![OrderType::Limit, OrderType::PostOnly, OrderType::Market], + } +} + +impl SpotMarketFixture { + pub fn with_tick_size(mut self, tick_size: Decimal) -> Self { + self.tick_size = tick_size; + self + } + + pub fn with_min_quantity(mut self, min_quantity: Decimal) -> Self { + self.min_quantity = min_quantity; + self + } + + pub fn with_step_size(mut self, step_size: Decimal) -> Self { + self.step_size = step_size; + self + } + + pub fn with_min_notional(mut self, min_notional: Decimal) -> Self { + self.min_notional = min_notional; + self + } + + pub fn with_supported_order_types(mut self, supported_order_types: Vec) -> Self { + self.supported_order_types = supported_order_types; + self + } + + pub fn build(self) -> MarketInfo { + let quantity_mode_order_types = self.supported_order_types.clone(); + + MarketInfo::builder() + .exchange_id(ExchangeId::from(KnownExchange::Binance)) + .symbol(self.symbol) + .status(MarketStatus::Trading) + .base_asset("BASE") + .quote_asset("QUOTE") + .trading_permissions( + TradingPermissions::builder() + .spot_order_entry_allowed(Some(true)) + .supported_order_types(self.supported_order_types) + .quantity_mode_support(vec![ + QuantityModeSupport::builder() + .mode(MarketQuantityMode::Base) + .order_types(quantity_mode_order_types) + .sides(vec![OrderSide::Buy, OrderSide::Sell]) + .build() + .expect( + "quantity mode support fixture must build; tests cannot proceed", + ), + ]) + .build() + .expect("trading permissions fixture must build; tests cannot proceed"), + ) + .trading_constraints( + TradingConstraints::builder() + .price_filter(Some( + PriceFilter::builder() + .tick_size(Some(self.tick_size)) + .build() + .expect("price filter fixture must build; tests cannot proceed"), + )) + .lot_size(Some( + LotSizeFilter::builder() + .min_quantity(Some(self.min_quantity)) + .step_size(Some(self.step_size)) + .build() + .expect("lot size fixture must build; tests cannot proceed"), + )) + .notional(Some( + NotionalConstraints::builder() + .min_notional(Some(self.min_notional)) + .build() + .expect( + "notional constraints fixture must build; tests cannot proceed", + ), + )) + .build() + .expect("trading constraints fixture must build; tests cannot proceed"), + ) + .build() + .expect("spot market fixture must build; tests cannot proceed") + } +} + +pub fn spot_market_fixture(symbol: Symbol) -> MarketInfo { + spot_market(symbol).build() +} + +pub fn book_frame( + symbol: &Symbol, + bid_price: Decimal, + bid_quantity: Decimal, + ask_price: Decimal, + ask_quantity: Decimal, +) -> SignalFrame { + SignalFrame::builder() + .book_ticker(Some( + BookTicker::builder() + .symbol(symbol.clone()) + .bid_price(bid_price) + .bid_quantity(bid_quantity) + .ask_price(ask_price) + .ask_quantity(ask_quantity) + .build() + .expect("book ticker fixture must build; tests cannot proceed"), + )) + .build() + .expect("signal frame fixture must build; tests cannot proceed") +} + +pub fn filled_spot_order( + symbol: &Symbol, + client_order_id: impl Into, + quantity: Decimal, + price: Decimal, +) -> Order { + let client_order_id = client_order_id.into(); + Order::builder() + .id(OrderId::new(format!("order-{client_order_id}"))) + .client_order_id(Some(ClientOrderId::new(client_order_id))) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .order_type(OrderType::Limit) + .status(OrderStatus::Filled) + .price(Some(price)) + .quantity(quantity) + .filled_quantity(quantity) + .cumulative_quote_quantity(Some(quantity * price)) + .created_at(time::OffsetDateTime::UNIX_EPOCH) + .build() + .expect("filled spot order fixture must build; tests cannot proceed") +} + +pub fn filled_active_child( + snapshot: &ExecutionSnapshot, + key: &ChildKey, + filled_quantity: Decimal, + price: Decimal, +) -> Result { + let active = snapshot + .active_children + .get(key) + .ok_or_else(|| Error::PolicyViolation { + message: format!("active child {} is not present in snapshot", key.0), + })?; + let client_order_id = active.client_order_id().cloned(); + Order::builder() + .id(active.order.id.clone()) + .client_order_id(client_order_id) + .symbol(active.order.symbol.clone()) + .market_kind(active.order.market_kind) + .side(active.order.side) + .order_type(active.order.order_type) + .status(OrderStatus::Filled) + .time_in_force(active.order.time_in_force) + .price(Some(price)) + .quantity(active.order.quantity) + .filled_quantity(filled_quantity) + .cumulative_quote_quantity(Some(filled_quantity * price)) + .created_at(active.order.created_at) + .build() + .map_err(|message| Error::PolicyViolation { + message: format!("filled active child fixture is invalid: {message}"), + }) +} + +#[derive(Debug, Default)] +struct SimulatedVenueState { + orders_by_client_order_id: BTreeMap, + queued_updates_by_client_order_id: BTreeMap, +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct SimulatedVenue { + state: Arc>, +} + +impl SimulatedVenue { + pub fn seed_query_result(&self, order: Order) { + if let Some(client_order_id) = &order.client_order_id { + self.state + .lock() + .expect("simulated venue mutex poisoned; tests cannot proceed") + .queued_updates_by_client_order_id + .insert(client_order_id.0.clone(), order); + } + } +} + +#[async_trait] +impl Driver for SimulatedVenue { + async fn place_order(&self, request: OrderRequest) -> Result { + let client_order_id = + request + .client_order_id() + .cloned() + .ok_or_else(|| Error::DriverPlace { + message: "request missing client order id".to_owned(), + })?; + + let order = match request { + OrderRequest::Spot(request) => { + let market_kind = request.symbol.kind; + Order::builder() + .id(OrderId::new(format!("order-{}", client_order_id.0))) + .client_order_id(Some(client_order_id.clone())) + .symbol(request.symbol) + .market_kind(market_kind) + .side(request.side) + .order_type(request.order_type) + .status(OrderStatus::New) + .time_in_force(request.time_in_force) + .price(request.price) + .quantity(match request.quantity { + OrderQuantity::Base(quantity) => quantity, + OrderQuantity::Quote(_) => Decimal::ONE, + _ => Decimal::ONE, + }) + .filled_quantity(Decimal::ZERO) + .created_at(time::OffsetDateTime::UNIX_EPOCH) + .build() + .map_err(|message| Error::DriverPlace { + message: message.to_string(), + })? + } + OrderRequest::Futures(request) => { + let market_kind = request.symbol.kind; + Order::builder() + .id(OrderId::new(format!("order-{}", client_order_id.0))) + .client_order_id(Some(client_order_id.clone())) + .symbol(request.symbol) + .market_kind(market_kind) + .side(request.side) + .order_type(request.order_type) + .status(OrderStatus::New) + .time_in_force(request.time_in_force) + .price(request.price) + .quantity(request.quantity) + .filled_quantity(Decimal::ZERO) + .created_at(time::OffsetDateTime::UNIX_EPOCH) + .build() + .map_err(|message| Error::DriverPlace { + message: message.to_string(), + })? + } + _ => { + return Err(Error::DriverPlace { + message: "unsupported order request variant in simulated venue".to_owned(), + }); + } + }; + + self.state + .lock() + .expect("simulated venue mutex poisoned; tests cannot proceed") + .orders_by_client_order_id + .insert(client_order_id.0, order.clone()); + Ok(order) + } + + async fn query_order(&self, query: OrderQuery) -> Result { + let client_order_id = match query { + OrderQuery::Spot(query) => match query.key { + OrderKey::Client(client_order_id) => client_order_id, + OrderKey::Exchange(order_id) => ClientOrderId::new(order_id.0), + _ => { + return Err(Error::DriverQuery { + message: "unsupported spot order key variant".to_owned(), + }); + } + }, + OrderQuery::Futures(query) => match query.key { + OrderKey::Client(client_order_id) => client_order_id, + OrderKey::Exchange(order_id) => ClientOrderId::new(order_id.0), + _ => { + return Err(Error::DriverQuery { + message: "unsupported futures order key variant".to_owned(), + }); + } + }, + _ => { + return Err(Error::DriverQuery { + message: "unsupported order query variant in simulated venue".to_owned(), + }); + } + }; + + let mut state = self + .state + .lock() + .expect("simulated venue mutex poisoned; tests cannot proceed"); + if let Some(order) = state + .queued_updates_by_client_order_id + .remove(&client_order_id.0) + { + state + .orders_by_client_order_id + .insert(client_order_id.0.clone(), order.clone()); + return Ok(order); + } + state + .orders_by_client_order_id + .get(&client_order_id.0) + .cloned() + .ok_or_else(|| Error::DriverQuery { + message: format!("missing order {}", client_order_id.0), + }) + } + + async fn cancel_order(&self, request: CancelOrderRequest) -> Result { + let client_order_id = match request { + CancelOrderRequest::Spot(request) => match request.key { + OrderKey::Client(client_order_id) => client_order_id, + OrderKey::Exchange(order_id) => ClientOrderId::new(order_id.0), + _ => { + return Err(Error::DriverCancel { + message: "unsupported spot cancel order key variant".to_owned(), + }); + } + }, + CancelOrderRequest::Futures(request) => match request.key { + OrderKey::Client(client_order_id) => client_order_id, + OrderKey::Exchange(order_id) => ClientOrderId::new(order_id.0), + _ => { + return Err(Error::DriverCancel { + message: "unsupported futures cancel order key variant".to_owned(), + }); + } + }, + _ => { + return Err(Error::DriverCancel { + message: "unsupported cancel order request variant in simulated venue" + .to_owned(), + }); + } + }; + + let mut state = self + .state + .lock() + .expect("simulated venue mutex poisoned; tests cannot proceed"); + let order = state + .orders_by_client_order_id + .get_mut(&client_order_id.0) + .ok_or_else(|| Error::DriverCancel { + message: format!("missing order {}", client_order_id.0), + })?; + order.status = OrderStatus::Canceled; + Ok(order.clone()) + } +} diff --git a/crates/exh-kit/tests/child_order.rs b/crates/exh-kit/tests/child_order.rs new file mode 100644 index 0000000..500caa2 --- /dev/null +++ b/crates/exh-kit/tests/child_order.rs @@ -0,0 +1,383 @@ +use exh::{ChildKey, ExecutionId, ExecutionTarget, OrderRequest}; +use exh_kit::child_order::{ChildId, ChildOrderFactory, ChildOrderStyle, SpotChildOrderInput}; +use exh_kit::constraints::RoundingMode; +use exh_kit::progress::TargetProgressView; +use mkt::types::{ + ClientOrderId, Decimal, ExchangeId, KnownExchange, LotSizeFilter, MarketInfo, + MarketQuantityMode, MarketStatus, NotionalConstraints, OrderQuantity, OrderSide, OrderType, + PriceFilter, QuantityModeSupport, SpotOrderRequest, Symbol, TimeInForce, TradingConstraints, + TradingPermissions, +}; +use std::str::FromStr; + +fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("test decimal must be valid") +} + +fn market() -> MarketInfo { + MarketInfo::builder() + .exchange_id(ExchangeId::from(KnownExchange::Binance)) + .symbol(Symbol::spot("SOLUSDT")) + .status(MarketStatus::Trading) + .base_asset("SOL") + .quote_asset("USDT") + .trading_permissions( + TradingPermissions::builder() + .spot_order_entry_allowed(Some(true)) + .supported_order_types(vec![OrderType::Limit, OrderType::PostOnly]) + .quantity_mode_support(vec![ + QuantityModeSupport::builder() + .mode(MarketQuantityMode::Base) + .order_types(vec![OrderType::Limit, OrderType::PostOnly]) + .sides(vec![OrderSide::Buy, OrderSide::Sell]) + .build() + .expect("quantity mode support must build"), + ]) + .build() + .expect("trading permissions must build"), + ) + .trading_constraints( + TradingConstraints::builder() + .price_filter(Some( + PriceFilter::builder() + .tick_size(Some(decimal("0.1"))) + .build() + .expect("price filter must build"), + )) + .lot_size(Some( + LotSizeFilter::builder() + .min_quantity(Some(decimal("0.01"))) + .step_size(Some(decimal("0.01"))) + .build() + .expect("lot size must build"), + )) + .notional(Some( + NotionalConstraints::builder() + .min_notional(Some(decimal("10"))) + .build() + .expect("notional constraints must build"), + )) + .build() + .expect("trading constraints must build"), + ) + .build() + .expect("market must build") +} + +fn base_progress(target: &str) -> TargetProgressView { + TargetProgressView::from_target_and_filled( + ExecutionTarget::base_quantity(decimal(target)), + Decimal::ZERO, + ) +} + +fn quote_progress(target: &str) -> TargetProgressView { + TargetProgressView::from_target_and_filled( + ExecutionTarget::quote_budget(decimal(target)), + Decimal::ZERO, + ) +} + +struct StyleBehaviorCase { + style: ChildOrderStyle, + order_type: OrderType, + time_in_force: TimeInForce, + buy_rounding: RoundingMode, + sell_rounding: RoundingMode, + buy_price: &'static str, + sell_price: &'static str, + quantity: &'static str, +} + +fn style_child_request( + factory: &ChildOrderFactory<'_>, + style: ChildOrderStyle, + side: OrderSide, +) -> SpotOrderRequest { + let child = factory + .spot_child( + SpotChildOrderInput::builder() + .key(ChildKey::new("style-child")) + .symbol(Symbol::spot("SOLUSDT")) + .side(side) + .style(style) + .target_value(decimal("2")) + .reference_price(decimal("10.03")) + .price_offset(decimal("0.04")) + .client_order_id(ClientOrderId::new("style-child-1")) + .progress(base_progress("5")) + .build() + .expect("spot child input must build"), + ) + .expect("child order style request must pass market constraints"); + + let OrderRequest::Spot(request) = child.request else { + panic!("child factory must produce a spot order request"); + }; + request +} + +fn assert_style_request( + request: SpotOrderRequest, + side: OrderSide, + case: &StyleBehaviorCase, + price: &str, +) { + assert_eq!(request.side, side, "{:?}", case.style); + assert_eq!(request.order_type, case.order_type, "{:?}", case.style); + assert_eq!( + request.time_in_force, + Some(case.time_in_force), + "{:?}", + case.style + ); + assert_eq!(request.price, Some(decimal(price)), "{:?}", case.style); + assert_eq!( + request.quantity, + OrderQuantity::Base(decimal(case.quantity)), + "{:?}", + case.style + ); +} + +#[test] +fn child_id_generates_stable_child_and_client_identifiers() { + let id = + ChildId::from_sequence(&ExecutionId::new("exec-1"), "pov", 7).expect("child id must build"); + + assert_eq!(id.key, ChildKey::new("pov-7")); + assert_eq!(id.client_order_id, ClientOrderId::new("exec-1-pov-7")); + assert!( + ChildId::from_sequence(&ExecutionId::new("exec-1"), " ", 1) + .expect_err("empty strategy tag must fail") + .to_string() + .contains("strategy tag") + ); +} + +#[test] +fn base_target_passive_buy_child_rounds_price_and_preserves_identifiers() { + let market = market(); + let factory = ChildOrderFactory::new(&market); + let client_order_id = ClientOrderId::new("passive-buy-1"); + + let child = factory + .spot_child( + SpotChildOrderInput::builder() + .key(ChildKey::new("child-1")) + .symbol(Symbol::spot("SOLUSDT")) + .side(OrderSide::Buy) + .style(ChildOrderStyle::Passive) + .target_value(decimal("1.23")) + .reference_price(decimal("10.03")) + .price_offset(decimal("0.04")) + .client_order_id(client_order_id.clone()) + .progress(base_progress("5")) + .build() + .expect("spot child input must build"), + ) + .expect("passive buy child must pass market constraints"); + + assert_eq!(child.key, ChildKey::new("child-1")); + let OrderRequest::Spot(request) = child.request else { + panic!("child factory must produce a spot order request"); + }; + assert_eq!(request.order_type, OrderType::PostOnly); + assert_eq!(request.time_in_force, Some(TimeInForce::Gtx)); + assert_eq!(request.price, Some(decimal("10.0"))); + assert_eq!(request.quantity, OrderQuantity::Base(decimal("1.23"))); + assert_eq!(request.client_order_id, Some(client_order_id)); +} + +#[test] +fn quote_target_converts_quote_target_value_to_base_quantity_by_limit_price() { + let market = market(); + let factory = ChildOrderFactory::new(&market); + + let child = factory + .spot_child( + SpotChildOrderInput::builder() + .key(ChildKey::new("child-quote")) + .symbol(Symbol::spot("SOLUSDT")) + .side(OrderSide::Buy) + .style(ChildOrderStyle::Aggressive) + .target_value(decimal("25")) + .reference_price(decimal("10")) + .price_offset(Decimal::ZERO) + .client_order_id(ClientOrderId::new("quote-buy-1")) + .progress(quote_progress("100")) + .build() + .expect("spot child input must build"), + ) + .expect("quote child must pass market constraints"); + + let OrderRequest::Spot(request) = child.request else { + panic!("child factory must produce a spot order request"); + }; + assert_eq!(request.order_type, OrderType::Limit); + assert_eq!(request.time_in_force, Some(TimeInForce::Ioc)); + assert_eq!(request.price, Some(decimal("10"))); + assert_eq!(request.quantity, OrderQuantity::Base(decimal("2.5"))); +} + +#[test] +fn child_order_styles_build_expected_limit_requests() { + let market = market(); + let factory = ChildOrderFactory::new(&market); + let cases = [ + StyleBehaviorCase { + style: ChildOrderStyle::Passive, + order_type: OrderType::PostOnly, + time_in_force: TimeInForce::Gtx, + buy_rounding: RoundingMode::Floor, + sell_rounding: RoundingMode::Ceil, + buy_price: "10.0", + sell_price: "10.0", + quantity: "2", + }, + StyleBehaviorCase { + style: ChildOrderStyle::Aggressive, + order_type: OrderType::Limit, + time_in_force: TimeInForce::Ioc, + buy_rounding: RoundingMode::Ceil, + sell_rounding: RoundingMode::Floor, + buy_price: "10.1", + sell_price: "9.9", + quantity: "2", + }, + StyleBehaviorCase { + style: ChildOrderStyle::LimitGtc, + order_type: OrderType::Limit, + time_in_force: TimeInForce::Gtc, + buy_rounding: RoundingMode::Floor, + sell_rounding: RoundingMode::Ceil, + buy_price: "10.0", + sell_price: "10.0", + quantity: "2", + }, + StyleBehaviorCase { + style: ChildOrderStyle::MidIoc, + order_type: OrderType::Limit, + time_in_force: TimeInForce::Ioc, + buy_rounding: RoundingMode::Ceil, + sell_rounding: RoundingMode::Floor, + buy_price: "10.1", + sell_price: "9.9", + quantity: "2", + }, + StyleBehaviorCase { + style: ChildOrderStyle::BestBidAskPostOnly, + order_type: OrderType::PostOnly, + time_in_force: TimeInForce::Gtx, + buy_rounding: RoundingMode::Floor, + sell_rounding: RoundingMode::Ceil, + buy_price: "10.0", + sell_price: "10.0", + quantity: "2", + }, + StyleBehaviorCase { + style: ChildOrderStyle::JoinQueue, + order_type: OrderType::PostOnly, + time_in_force: TimeInForce::Gtx, + buy_rounding: RoundingMode::Floor, + sell_rounding: RoundingMode::Ceil, + buy_price: "10.0", + sell_price: "10.0", + quantity: "2", + }, + StyleBehaviorCase { + style: ChildOrderStyle::ImproveByTicks(2), + order_type: OrderType::PostOnly, + time_in_force: TimeInForce::Gtx, + buy_rounding: RoundingMode::Floor, + sell_rounding: RoundingMode::Ceil, + buy_price: "10.2", + sell_price: "9.8", + quantity: "2", + }, + StyleBehaviorCase { + style: ChildOrderStyle::IocThroughBook, + order_type: OrderType::Limit, + time_in_force: TimeInForce::Ioc, + buy_rounding: RoundingMode::Ceil, + sell_rounding: RoundingMode::Floor, + buy_price: "10.1", + sell_price: "9.9", + quantity: "2", + }, + ]; + + for case in cases { + assert_eq!(case.style.order_type(), case.order_type, "{:?}", case.style); + assert_eq!( + case.style.time_in_force(), + case.time_in_force, + "{:?}", + case.style + ); + assert_eq!( + case.style.price_rounding(OrderSide::Buy), + case.buy_rounding, + "{:?}", + case.style + ); + assert_eq!( + case.style.price_rounding(OrderSide::Sell), + case.sell_rounding, + "{:?}", + case.style + ); + + let buy_request = style_child_request(&factory, case.style, OrderSide::Buy); + assert_style_request(buy_request, OrderSide::Buy, &case, case.buy_price); + + let sell_request = style_child_request(&factory, case.style, OrderSide::Sell); + assert_style_request(sell_request, OrderSide::Sell, &case, case.sell_price); + } +} + +#[test] +fn min_notional_or_invalid_quote_price_returns_error() { + let market = market(); + let factory = ChildOrderFactory::new(&market); + + let min_notional_error = factory + .spot_child( + SpotChildOrderInput::builder() + .key(ChildKey::new("too-small")) + .symbol(Symbol::spot("SOLUSDT")) + .side(OrderSide::Buy) + .style(ChildOrderStyle::Aggressive) + .target_value(decimal("0.5")) + .reference_price(decimal("10")) + .price_offset(Decimal::ZERO) + .client_order_id(ClientOrderId::new("too-small-1")) + .progress(base_progress("5")) + .build() + .expect("spot child input must build"), + ) + .expect_err("below-min-notional child must be rejected"); + assert!(min_notional_error.to_string().contains("minimum notional")); + + let invalid_quote_price_error = factory + .spot_child( + SpotChildOrderInput::builder() + .key(ChildKey::new("bad-price")) + .symbol(Symbol::spot("SOLUSDT")) + .side(OrderSide::Sell) + .style(ChildOrderStyle::Aggressive) + .target_value(decimal("25")) + .reference_price(decimal("1")) + .price_offset(decimal("1")) + .client_order_id(ClientOrderId::new("bad-price-1")) + .progress(quote_progress("100")) + .build() + .expect("spot child input must build"), + ) + .expect_err("quote child with non-positive price must be rejected"); + assert!( + invalid_quote_price_error + .to_string() + .contains("positive limit price") + ); +} diff --git a/crates/exh-kit/tests/constraints.rs b/crates/exh-kit/tests/constraints.rs new file mode 100644 index 0000000..2b7050c --- /dev/null +++ b/crates/exh-kit/tests/constraints.rs @@ -0,0 +1,179 @@ +use exh::{ExecutionTarget, OrderRequest}; +use exh_kit::constraints::{MarketConstraintService, RoundingMode}; +use exh_kit::progress::TargetProgressView; +use mkt::types::{ + Decimal, ExchangeId, KnownExchange, LotSizeFilter, MarketInfo, MarketQuantityMode, + MarketStatus, NotionalConstraints, OrderQuantity, OrderSide, OrderType, PriceFilter, + QuantityModeSupport, SpotOrderRequest, Symbol, TradingConstraints, TradingPermissions, +}; +use std::str::FromStr; + +fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("test decimal must be valid") +} + +fn market() -> MarketInfo { + MarketInfo::builder() + .exchange_id(ExchangeId::from(KnownExchange::Binance)) + .symbol(Symbol::spot("SOLUSDT")) + .status(MarketStatus::Trading) + .base_asset("SOL") + .quote_asset("USDT") + .trading_permissions( + TradingPermissions::builder() + .supported_order_types(vec![OrderType::Limit]) + .quantity_mode_support(vec![ + QuantityModeSupport::builder() + .mode(MarketQuantityMode::Base) + .order_types(vec![OrderType::Limit]) + .sides(vec![OrderSide::Buy, OrderSide::Sell]) + .build() + .expect("base mode support must build"), + ]) + .build() + .expect("trading permissions must build"), + ) + .trading_constraints( + TradingConstraints::builder() + .price_filter(Some( + PriceFilter::builder() + .tick_size(Some(decimal("0.1"))) + .build() + .expect("price filter must build"), + )) + .lot_size(Some( + LotSizeFilter::builder() + .min_quantity(Some(decimal("0.2"))) + .max_quantity(Some(decimal("5"))) + .step_size(Some(decimal("0.1"))) + .build() + .expect("lot size must build"), + )) + .notional(Some( + NotionalConstraints::builder() + .min_notional(Some(decimal("10"))) + .build() + .expect("notional constraints must build"), + )) + .build() + .expect("trading constraints must build"), + ) + .build() + .expect("market must build") +} + +fn limit_request(quantity: &str, price: &str) -> OrderRequest { + SpotOrderRequest::builder() + .symbol(Symbol::spot("SOLUSDT")) + .side(OrderSide::Sell) + .order_type(OrderType::Limit) + .quantity(OrderQuantity::Base(decimal(quantity))) + .price(decimal(price)) + .build() + .map(OrderRequest::Spot) + .expect("test request must build") +} + +fn spot_request( + order_type: OrderType, + quantity: OrderQuantity, + price: Option, +) -> OrderRequest { + let mut builder = SpotOrderRequest::builder() + .symbol(Symbol::spot("SOLUSDT")) + .side(OrderSide::Sell) + .order_type(order_type) + .quantity(quantity); + if let Some(price) = price { + builder = builder.price(price); + } + builder + .build() + .map(OrderRequest::Spot) + .expect("test request must build") +} + +#[test] +fn market_constraints_round_price_and_quantity_to_filters() { + let market = market(); + let service = MarketConstraintService::new(&market); + + assert_eq!( + service.round_price(decimal("100.19"), RoundingMode::Floor), + decimal("100.1") + ); + assert_eq!( + service.round_price(decimal("100.11"), RoundingMode::Ceil), + decimal("100.2") + ); + assert_eq!( + service.floor_quantity(decimal("1.29"), OrderType::Limit), + decimal("1.2") + ); +} + +#[test] +fn market_constraints_validate_child_request_against_exchange_filters() { + let market = market(); + let service = MarketConstraintService::new(&market); + service + .validate_request(&limit_request("0.2", "50.0")) + .expect("request satisfies min quantity, min notional, and increments"); + + let error = service + .validate_request(&limit_request("0.25", "50.0")) + .expect_err("step mismatch must fail"); + assert!(error.to_string().contains("step size")); + + let error = service + .validate_request(&limit_request("0.2", "49.0")) + .expect_err("min notional mismatch must fail"); + assert!(error.to_string().contains("minimum notional")); +} + +#[test] +fn market_constraints_reject_unsupported_order_type_and_quantity_mode() { + let market = market(); + let service = MarketConstraintService::new(&market); + + let unsupported_order_type = + spot_request(OrderType::Market, OrderQuantity::Base(decimal("0.2")), None); + let error = service + .validate_request(&unsupported_order_type) + .expect_err("unsupported order type must fail before venue reject"); + assert!(error.to_string().contains("order type")); + + let unsupported_quantity_mode = spot_request( + OrderType::Limit, + OrderQuantity::Quote(decimal("20")), + Some(decimal("50")), + ); + let error = service + .validate_request(&unsupported_quantity_mode) + .expect_err("unsupported quantity mode must fail before venue reject"); + assert!(error.to_string().contains("quantity mode")); +} + +#[test] +fn market_constraints_size_quote_target_base_children_by_price() { + let market = market(); + let service = MarketConstraintService::new(&market); + let progress = TargetProgressView::from_target_and_filled( + ExecutionTarget::quote_budget(decimal("100")), + decimal("25"), + ); + + let sizing = service + .size_base_child( + OrderType::Limit, + progress, + decimal("10"), + Some(decimal("20")), + ) + .expect("quote target can be sized when price is known"); + + assert_eq!(sizing.quantity, decimal("3.7")); + assert_eq!(sizing.target_value, decimal("74.0")); + assert_eq!(sizing.min_quantity, Some(decimal("0.2"))); + assert_eq!(sizing.min_notional, Some(decimal("10"))); +} diff --git a/crates/exh-kit/tests/features.rs b/crates/exh-kit/tests/features.rs new file mode 100644 index 0000000..51c18e9 --- /dev/null +++ b/crates/exh-kit/tests/features.rs @@ -0,0 +1,138 @@ +use exh::Error; +use exh_kit::features::{ + SignalFeatureEnvelope, SignalFeatureRequirement, SignalFeatureRequirements, + SignalFeatureSchema, SignalFrameFeatureExt, +}; +use exh_kit::signals::SignalFrame; +use mkt::types::{Decimal, Extensions}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +struct LobFeatures { + queue_imbalance: Decimal, + micro_price_distance_bps: Decimal, +} + +impl SignalFeatureSchema for LobFeatures { + const EXTENSION_KEY: &'static str = "features.lob"; + const SCHEMA: &'static str = "test.lob_features"; + const VERSION: u32 = 1; +} + +struct FeatureAwareStrategy; + +impl SignalFeatureRequirements for FeatureAwareStrategy { + fn required_signal_features(&self) -> Vec { + vec![LobFeatures::requirement()] + } +} + +fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("test decimal must be valid") +} + +#[test] +fn signal_feature_requirement_matches_schema_constants() { + let requirement = LobFeatures::requirement(); + + assert_eq!(requirement.extension_key, "features.lob"); + assert_eq!(requirement.schema, "test.lob_features"); + assert_eq!(requirement.version, 1); +} + +#[test] +fn strategy_declares_required_signal_features() { + let requirements = FeatureAwareStrategy.required_signal_features(); + + assert_eq!(requirements, vec![LobFeatures::requirement()]); + assert_eq!(requirements[0].schema, LobFeatures::SCHEMA); +} + +#[test] +fn signal_features_round_trip_through_frame_extensions() { + let features = LobFeatures { + queue_imbalance: decimal("0.42"), + micro_price_distance_bps: decimal("3.5"), + }; + let frame = SignalFrame::builder() + .build() + .expect("signal frame must build") + .with_typed_feature(&features) + .expect("typed feature must attach"); + + assert_eq!( + frame + .typed_feature::() + .expect("typed feature must decode"), + Some(features) + ); +} + +#[test] +fn signal_features_reject_mismatched_versions() { + let envelope = SignalFeatureEnvelope::new( + LobFeatures::SCHEMA, + LobFeatures::VERSION + 1, + serde_json::json!({ + "queue_imbalance": "0.42", + "micro_price_distance_bps": "3.5" + }), + ); + let mut extensions = Extensions::new(); + extensions + .insert( + LobFeatures::EXTENSION_KEY, + serde_json::to_value(envelope).expect("feature envelope must serialize"), + ) + .expect("feature key is valid"); + + let error = LobFeatures::load_from(&extensions).expect_err("version mismatch must fail"); + assert!(matches!(error, Error::InvalidRecovery { .. })); + assert!(error.to_string().contains("version")); +} + +#[test] +fn require_typed_feature_reports_missing_feature_and_decodes_present_feature() { + let empty_frame = SignalFrame::builder() + .build() + .expect("signal frame must build"); + + let error = empty_frame + .require_typed_feature::() + .expect_err("required feature must fail when absent"); + let message = error.to_string(); + assert!(matches!(error, Error::PolicyViolation { .. })); + assert!(message.contains(LobFeatures::EXTENSION_KEY)); + assert!(message.contains(LobFeatures::SCHEMA)); + assert!(message.contains(&LobFeatures::VERSION.to_string())); + + let features = LobFeatures { + queue_imbalance: decimal("0.42"), + micro_price_distance_bps: decimal("3.5"), + }; + let frame = empty_frame + .with_typed_feature(&features) + .expect("typed feature must attach"); + + assert_eq!( + frame + .require_typed_feature::() + .expect("required feature must decode"), + features + ); +} + +#[test] +fn typed_feature_or_default_uses_default_when_feature_is_absent() { + let empty_frame = SignalFrame::builder() + .build() + .expect("signal frame must build"); + + assert_eq!( + empty_frame + .typed_feature_or_default::() + .expect("default feature must be available"), + LobFeatures::default() + ); +} diff --git a/crates/exh-kit/tests/lifecycle.rs b/crates/exh-kit/tests/lifecycle.rs new file mode 100644 index 0000000..9479f7f --- /dev/null +++ b/crates/exh-kit/tests/lifecycle.rs @@ -0,0 +1,161 @@ +use exh::{AlgorithmLifecycleEvent, ChildKey, OrderRequest}; +use exh_kit::lifecycle::{LifecycleEventKind, LifecycleEventView}; +use mkt::types::{ + ClientOrderId, Decimal, MarketKind, Order, OrderId, OrderQuantity, OrderSide, OrderStatus, + OrderType, SpotOrderRequest, Symbol, +}; +use std::str::FromStr; + +fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("test decimal must be valid") +} + +fn symbol() -> Symbol { + Symbol::spot("ETHUSDT") +} + +fn limit_request(client_order_id: &str, quantity: &str, price: &str) -> OrderRequest { + SpotOrderRequest::builder() + .symbol(symbol()) + .side(OrderSide::Buy) + .order_type(OrderType::Limit) + .quantity(OrderQuantity::Base(decimal(quantity))) + .price(decimal(price)) + .client_order_id(ClientOrderId::new(client_order_id)) + .build() + .map(OrderRequest::Spot) + .expect("test spot order request must build") +} + +fn order( + client_order_id: &str, + quantity: &str, + price: &str, + filled_quantity: &str, + status: OrderStatus, +) -> Order { + Order::builder() + .id(OrderId::new(format!("order-{client_order_id}"))) + .client_order_id(Some(ClientOrderId::new(client_order_id))) + .symbol(symbol()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .order_type(OrderType::Limit) + .status(status) + .price(Some(decimal(price))) + .quantity(decimal(quantity)) + .filled_quantity(decimal(filled_quantity)) + .created_at(time::OffsetDateTime::UNIX_EPOCH) + .build() + .expect("test order must build") +} + +#[test] +fn child_placed_view_extracts_request_and_order_fields() { + let event = AlgorithmLifecycleEvent::ChildPlaced { + key: ChildKey::new("child-placed"), + request: limit_request("client-placed", "2.5", "100.5"), + order: order( + "client-placed", + "2.5", + "100.5", + "0.7", + OrderStatus::PartiallyFilled, + ), + }; + + let view = LifecycleEventView::from_event(&event).expect("placed event must be supported"); + + assert_eq!(view.kind, LifecycleEventKind::Placed); + assert_eq!(view.key.0.as_str(), "child-placed"); + assert_eq!( + view.client_order_id_string().as_deref(), + Some("client-placed") + ); + assert_eq!(view.requested_base_quantity(), Some(decimal("2.5"))); + assert_eq!(view.limit_price(), Some(decimal("100.5"))); + assert_eq!(view.filled_quantity(), decimal("0.7")); + assert_eq!(view.order_status(), Some(OrderStatus::PartiallyFilled)); + assert_eq!(view.message, None); +} + +#[test] +fn child_place_rejected_view_extracts_request_fields_and_message() { + let event = AlgorithmLifecycleEvent::ChildPlaceRejected { + key: ChildKey::new("child-rejected"), + request: limit_request("request-client", "1.25", "99.1"), + client_order_id: ClientOrderId::new("rejected-client"), + message: "insufficient balance".to_owned(), + }; + + let view = + LifecycleEventView::from_event(&event).expect("place rejected event must be supported"); + + assert_eq!(view.kind, LifecycleEventKind::PlaceRejected); + assert_eq!(view.key.0.as_str(), "child-rejected"); + assert_eq!( + view.client_order_id_string().as_deref(), + Some("rejected-client") + ); + assert_eq!(view.requested_base_quantity(), Some(decimal("1.25"))); + assert_eq!(view.limit_price(), Some(decimal("99.1"))); + assert_eq!(view.filled_quantity(), Decimal::ZERO); + assert_eq!(view.order_status(), None); + assert_eq!(view.message, Some("insufficient balance")); +} + +#[test] +fn child_canceled_view_extracts_order_fields() { + let event = AlgorithmLifecycleEvent::ChildCanceled { + key: ChildKey::new("child-canceled"), + order: order( + "client-canceled", + "3.0", + "101.2", + "1.0", + OrderStatus::Canceled, + ), + }; + + let view = LifecycleEventView::from_event(&event).expect("canceled event must be supported"); + + assert_eq!(view.kind, LifecycleEventKind::Canceled); + assert_eq!(view.key.0.as_str(), "child-canceled"); + assert_eq!( + view.client_order_id_string().as_deref(), + Some("client-canceled") + ); + assert_eq!(view.requested_base_quantity(), Some(decimal("3.0"))); + assert_eq!(view.limit_price(), Some(decimal("101.2"))); + assert_eq!(view.filled_quantity(), decimal("1.0")); + assert_eq!(view.order_status(), Some(OrderStatus::Canceled)); + assert_eq!(view.message, None); +} + +#[test] +fn child_observed_view_extracts_order_fields() { + let event = AlgorithmLifecycleEvent::ChildObserved { + key: ChildKey::new("child-observed"), + order: order( + "client-observed", + "4.0", + "102.3", + "4.0", + OrderStatus::Filled, + ), + }; + + let view = LifecycleEventView::from_event(&event).expect("observed event must be supported"); + + assert_eq!(view.kind, LifecycleEventKind::Observed); + assert_eq!(view.key.0.as_str(), "child-observed"); + assert_eq!( + view.client_order_id_string().as_deref(), + Some("client-observed") + ); + assert_eq!(view.requested_base_quantity(), Some(decimal("4.0"))); + assert_eq!(view.limit_price(), Some(decimal("102.3"))); + assert_eq!(view.filled_quantity(), decimal("4.0")); + assert_eq!(view.order_status(), Some(OrderStatus::Filled)); + assert_eq!(view.message, None); +} diff --git a/crates/exh-kit/tests/multi_asset.rs b/crates/exh-kit/tests/multi_asset.rs new file mode 100644 index 0000000..da4be0d --- /dev/null +++ b/crates/exh-kit/tests/multi_asset.rs @@ -0,0 +1,375 @@ +use exh::{Error, ExecutionId, ExecutionIntent, ExecutionProgress, ExecutionSnapshot}; +use exh_kit::multi_asset::{ + LegBudgetAllocation, MultiAssetCoordinator, MultiAssetLeg, MultiAssetPlan, ParentAuditEvent, + ParentAuditSchema, ParentExecutionRunner, ParentRunLifecycleEffects, + ParentRunLifecycleEffectsSchemaExt, ParentRunState, ParentRunStateSchemaExt, ParentStateSchema, + ParentStateView, +}; +use mkt::types::{Decimal, MarketKind, OrderSide, Symbol}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use time::{Duration, OffsetDateTime}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct BasketState { + rebalance_count: u64, + risk_budget: Decimal, +} + +impl ParentStateSchema for BasketState { + const SCHEMA: &'static str = "basket.state"; + const VERSION: u32 = 1; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct BasketAudit { + leg_count: u64, +} + +impl ParentAuditSchema for BasketAudit { + const EVENT_TYPE: &'static str = "basket.audit"; + const VERSION: u32 = 1; +} + +fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("test decimal must be valid") +} + +fn intent(id: &str, symbol: &str, target: &str) -> ExecutionIntent { + ExecutionIntent::builder() + .execution_id(ExecutionId::new(id)) + .symbol(Symbol::spot(symbol)) + .market_kind(MarketKind::Spot) + .side(OrderSide::Sell) + .target_quantity(decimal(target)) + .build() + .expect("test intent must build") +} + +fn snapshot(intent: ExecutionIntent, filled: &str) -> ExecutionSnapshot { + let mut snapshot = ExecutionSnapshot::from_intent(intent); + snapshot.progress = ExecutionProgress::zero(); + snapshot.progress.filled_base_quantity = decimal(filled); + snapshot.progress.cumulative_quote_quantity = Decimal::ZERO; + snapshot +} + +fn coordinator_for(first: ExecutionIntent, second: ExecutionIntent) -> MultiAssetCoordinator { + MultiAssetCoordinator::new( + MultiAssetPlan::new( + "basket-1", + vec![ + MultiAssetLeg::new(first, decimal("60")), + MultiAssetLeg::new(second, decimal("40")), + ], + ) + .expect("multi-asset plan must build"), + ) +} + +#[test] +fn multi_asset_coordinator_normalizes_leg_progress_to_parent_budget() { + let first = intent("leg-a", "AAAUSDT", "10"); + let second = intent("leg-b", "BBBUSDT", "10"); + let coordinator = coordinator_for(first.clone(), second.clone()); + + let parent = coordinator + .evaluate(&[snapshot(first, "5"), snapshot(second, "10")]) + .expect("all leg snapshots are present"); + + assert_eq!(parent.parent_budget, decimal("100")); + assert_eq!(parent.filled_parent_budget, decimal("70.0")); + assert_eq!(parent.remaining_parent_budget, decimal("30.0")); + assert_eq!(parent.completion_ratio(), decimal("0.7")); + assert_eq!(parent.legs[0].remaining_parent_budget, decimal("30.0")); + assert_eq!(parent.legs[1].remaining_parent_budget, decimal("0")); +} + +#[test] +fn multi_asset_coordinator_allocates_requested_budget_to_unfinished_legs() { + let first = intent("leg-a", "AAAUSDT", "10"); + let second = intent("leg-b", "BBBUSDT", "10"); + let coordinator = coordinator_for(first.clone(), second.clone()); + + let allocations = coordinator + .allocate_child_parent_budget( + &[snapshot(first, "5"), snapshot(second, "10")], + decimal("15"), + ) + .expect("budget allocation must evaluate"); + + assert_eq!(allocations[0].parent_budget, decimal("15.0")); + assert_eq!(allocations[1].parent_budget, decimal("0.0")); +} + +#[tokio::test] +async fn parent_execution_runner_allocates_and_advances_legs_in_allocation_order() { + let first = intent("leg-a", "AAAUSDT", "10"); + let second = intent("leg-b", "BBBUSDT", "10"); + let coordinator = coordinator_for(first.clone(), second.clone()); + let snapshots = vec![snapshot(first, "5"), snapshot(second, "2")]; + let expected_parent_snapshot = coordinator + .evaluate(&snapshots) + .expect("pre-advance parent snapshot must evaluate"); + let runner = ParentExecutionRunner::new(coordinator); + + let run = runner + .allocate_and_advance( + &snapshots, + decimal("31"), + |leg_snapshot: &ExecutionSnapshot, allocation: &LegBudgetAllocation| { + assert_eq!(leg_snapshot.intent.execution_id.0, allocation.execution_id); + let output = ( + leg_snapshot.intent.execution_id.0.clone(), + allocation.parent_budget, + ); + async move { Ok::<_, Error>(output) } + }, + ) + .await + .expect("parent execution run must advance all allocated legs"); + + assert_eq!(run.parent_snapshot, expected_parent_snapshot); + assert_eq!( + run.allocations + .iter() + .map(|allocation| (allocation.execution_id.as_str(), allocation.parent_budget)) + .collect::>(), + vec![("leg-a", decimal("15")), ("leg-b", decimal("16"))] + ); + assert_eq!( + run.leg_outputs, + vec![ + ("leg-a".to_owned(), decimal("15")), + ("leg-b".to_owned(), decimal("16")), + ] + ); + assert_eq!(run.parent_run_state, None); +} + +#[tokio::test] +async fn parent_execution_runner_records_parent_lifecycle_effects_for_allocation_run() { + let first = intent("leg-a", "AAAUSDT", "10"); + let second = intent("leg-b", "BBBUSDT", "10"); + let coordinator = coordinator_for(first.clone(), second.clone()); + let snapshots = vec![snapshot(first, "5"), snapshot(second, "2")]; + let runner = ParentExecutionRunner::new(coordinator); + let recorded_at = OffsetDateTime::UNIX_EPOCH + Duration::seconds(3); + + let run = runner + .allocate_and_advance_with_parent_lifecycle( + &snapshots, + decimal("31"), + ParentRunState::new(), + |_leg_snapshot: &ExecutionSnapshot, allocation: &LegBudgetAllocation| { + let output = allocation.parent_budget; + async move { Ok::<_, Error>(output) } + }, + |context| { + assert_eq!(context.requested_parent_budget, decimal("31")); + assert_eq!(context.leg_outputs, [decimal("15"), decimal("16")]); + let state = BasketState { + rebalance_count: 1, + risk_budget: context.requested_parent_budget, + }; + let audit = BasketAudit { + leg_count: u64::try_from(context.allocations.len()) + .expect("test allocation count must fit u64"), + }; + ParentRunLifecycleEffects::new(recorded_at) + .with_typed_state(&state)? + .with_typed_audit(&audit) + }, + ) + .await + .expect("parent execution run must record parent lifecycle effects"); + + let parent_run_state = run + .parent_run_state + .expect("parent lifecycle run must return parent state"); + assert_eq!( + BasketState::load_parent_state(&parent_run_state.state).expect("state must decode"), + Some(BasketState { + rebalance_count: 1, + risk_budget: decimal("31"), + }) + ); + assert_eq!( + BasketAudit::collect_parent_events(&parent_run_state.audit_events) + .expect("audit must decode"), + vec![BasketAudit { leg_count: 2 }] + ); + assert_eq!(parent_run_state.last_updated_at, Some(recorded_at)); +} + +#[tokio::test] +async fn parent_lifecycle_failure_does_not_mask_completed_leg_advancement() { + let first = intent("leg-a", "AAAUSDT", "10"); + let second = intent("leg-b", "BBBUSDT", "10"); + let coordinator = coordinator_for(first.clone(), second.clone()); + let snapshots = vec![snapshot(first, "5"), snapshot(second, "2")]; + let runner = ParentExecutionRunner::new(coordinator); + let initial_run_state = ParentRunState::new() + .with_typed_state( + &BasketState { + rebalance_count: 1, + risk_budget: decimal("100"), + }, + OffsetDateTime::UNIX_EPOCH, + ) + .expect("initial parent state must record"); + + let run = runner + .allocate_and_advance_with_parent_lifecycle( + &snapshots, + decimal("31"), + initial_run_state.clone(), + |_leg_snapshot: &ExecutionSnapshot, allocation: &LegBudgetAllocation| { + let output = allocation.parent_budget; + async move { Ok::<_, Error>(output) } + }, + |context| { + assert_eq!(context.leg_outputs, [decimal("15"), decimal("16")]); + Err(Error::PolicyViolation { + message: "parent lifecycle failed after side effects".to_owned(), + }) + }, + ) + .await + .expect("parent lifecycle failure must not mask completed leg advancement"); + + assert_eq!(run.leg_outputs, vec![decimal("15"), decimal("16")]); + assert_eq!(run.parent_run_state, Some(initial_run_state)); +} + +#[test] +fn multi_asset_coordinator_rejects_duplicate_snapshot_execution_ids() { + let first = intent("leg-a", "AAAUSDT", "10"); + let second = intent("leg-b", "BBBUSDT", "10"); + let coordinator = coordinator_for(first.clone(), second); + + let error = coordinator + .evaluate(&[snapshot(first.clone(), "1"), snapshot(first, "2")]) + .expect_err("duplicate execution id snapshots must fail"); + + assert!(error.to_string().contains("duplicate snapshot")); +} + +#[test] +fn multi_asset_coordinator_rejects_snapshot_identity_mismatch() { + let first = intent("leg-a", "AAAUSDT", "10"); + let second = intent("leg-b", "BBBUSDT", "10"); + let coordinator = coordinator_for(first.clone(), second.clone()); + + for mismatched in [ + intent("leg-a", "CCCUSDT", "10"), + ExecutionIntent::builder() + .execution_id(ExecutionId::new("leg-a")) + .symbol(Symbol::spot("AAAUSDT")) + .market_kind(MarketKind::linear_perpetual()) + .side(OrderSide::Sell) + .target_quantity(decimal("10")) + .build() + .expect("test intent must build"), + ExecutionIntent::builder() + .execution_id(ExecutionId::new("leg-a")) + .symbol(Symbol::spot("AAAUSDT")) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target_quantity(decimal("10")) + .build() + .expect("test intent must build"), + intent("leg-a", "AAAUSDT", "11"), + ] { + let error = coordinator + .evaluate(&[snapshot(mismatched, "1"), snapshot(second.clone(), "1")]) + .expect_err("mismatched leg identity must fail"); + + assert!( + error.to_string().contains("does not match"), + "unexpected error: {error}" + ); + } +} + +#[test] +fn parent_state_and_audit_schemas_round_trip_typed_payloads() { + let state = BasketState { + rebalance_count: 2, + risk_budget: decimal("100"), + }; + let patch = state + .to_parent_patch() + .expect("parent state patch must serialize"); + let mut view = ParentStateView::new(); + view.apply_patch(patch); + + assert_eq!( + BasketState::load_parent_state(&view).expect("state view must decode"), + Some(state) + ); + + let audit = BasketAudit { leg_count: 2 }; + let event = audit + .to_parent_event(OffsetDateTime::UNIX_EPOCH) + .expect("parent audit event must serialize"); + + assert_eq!( + BasketAudit::from_parent_event(&event).expect("audit event must decode"), + audit + ); + assert_eq!(event.recorded_at, OffsetDateTime::UNIX_EPOCH); +} + +#[test] +fn parent_run_state_records_and_decodes_typed_state_and_audit() { + assert!(ParentRunLifecycleEffects::new(OffsetDateTime::UNIX_EPOCH).is_empty()); + + let state = BasketState { + rebalance_count: 2, + risk_budget: decimal("100"), + }; + let audit = BasketAudit { leg_count: 2 }; + let state_recorded_at = OffsetDateTime::UNIX_EPOCH + Duration::seconds(1); + let audit_recorded_at = OffsetDateTime::UNIX_EPOCH + Duration::seconds(2); + + let run_state = ParentRunState::new() + .with_typed_state(&state, state_recorded_at) + .expect("typed parent state must record") + .with_typed_audit(&audit, audit_recorded_at) + .expect("typed parent audit must record"); + + assert_eq!( + BasketState::load_parent_state(&run_state.state).expect("run state must decode"), + Some(state) + ); + assert_eq!( + BasketAudit::collect_parent_events(&run_state.audit_events) + .expect("run audit events must decode"), + vec![audit] + ); + assert_eq!(run_state.last_updated_at, Some(audit_recorded_at)); + assert_eq!(run_state.audit_events[0].recorded_at, audit_recorded_at); +} + +#[test] +fn parent_audit_schema_collects_matching_parent_events() { + let audit = BasketAudit { leg_count: 2 }; + let event = audit + .to_parent_event(OffsetDateTime::UNIX_EPOCH) + .expect("parent audit event must serialize"); + let unrelated = ParentAuditEvent::new( + "basket.unrelated", + 1, + serde_json::json!({ + "ignored": true + }), + OffsetDateTime::UNIX_EPOCH, + ); + + assert_eq!( + BasketAudit::collect_parent_events(&[unrelated, event]) + .expect("matching parent audit events must decode"), + vec![audit] + ); +} diff --git a/crates/exh-kit/tests/progress.rs b/crates/exh-kit/tests/progress.rs new file mode 100644 index 0000000..36b75a6 --- /dev/null +++ b/crates/exh-kit/tests/progress.rs @@ -0,0 +1,66 @@ +use exh::{ExecutionId, ExecutionIntent, ExecutionProgress, ExecutionSnapshot, OrderRequest}; +use exh_kit::progress::{TargetProgressView, TargetValueKind}; +use mkt::types::{ + Decimal, MarketKind, OrderQuantity, OrderSide, OrderType, SpotOrderRequest, Symbol, +}; +use std::str::FromStr; + +fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("test decimal must be valid") +} + +fn intent(target: exh::ExecutionTarget) -> ExecutionIntent { + ExecutionIntent::builder() + .execution_id(ExecutionId::new("progress-test")) + .symbol(Symbol::spot("ETHUSDT")) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target(target) + .build() + .expect("test intent must build") +} + +#[test] +fn target_progress_uses_base_target_dimension() { + let mut snapshot = + ExecutionSnapshot::from_intent(intent(exh::ExecutionTarget::base_quantity(decimal("10")))); + snapshot.progress = ExecutionProgress::zero(); + snapshot.progress.filled_base_quantity = decimal("3.5"); + snapshot.progress.cumulative_quote_quantity = decimal("700"); + + let progress = TargetProgressView::from_snapshot(&snapshot); + + assert_eq!(progress.kind, TargetValueKind::BaseQuantity); + assert_eq!(progress.filled_value, decimal("3.5")); + assert_eq!(progress.remaining_value, decimal("6.5")); + assert_eq!(progress.completion_ratio(), decimal("0.35")); +} + +#[test] +fn target_progress_values_quote_child_requests_by_quote_budget() { + let mut snapshot = + ExecutionSnapshot::from_intent(intent(exh::ExecutionTarget::quote_budget(decimal("1000")))); + snapshot.progress = ExecutionProgress::zero(); + snapshot.progress.filled_base_quantity = decimal("2"); + snapshot.progress.cumulative_quote_quantity = decimal("250"); + let progress = TargetProgressView::from_snapshot(&snapshot); + let request = SpotOrderRequest::builder() + .symbol(Symbol::spot("ETHUSDT")) + .side(OrderSide::Buy) + .order_type(OrderType::Limit) + .quantity(OrderQuantity::Base(decimal("1.5"))) + .price(decimal("100")) + .build() + .map(OrderRequest::Spot) + .expect("test request must build"); + + assert_eq!(progress.kind, TargetValueKind::QuoteBudget); + assert_eq!(progress.remaining_value, decimal("750")); + assert_eq!( + progress + .child_request_value(&request) + .expect("priced base child has quote value"), + decimal("150.0") + ); + assert_eq!(progress.cap_child_value(decimal("900")), decimal("750")); +} diff --git a/crates/exh-kit/tests/schedule.rs b/crates/exh-kit/tests/schedule.rs new file mode 100644 index 0000000..0346de0 --- /dev/null +++ b/crates/exh-kit/tests/schedule.rs @@ -0,0 +1,211 @@ +use exh::ExecutionTarget; +use exh_kit::progress::TargetProgressView; +use exh_kit::schedule::{ChildBudgetContext, ChildBudgetPolicy, ParentSchedule, TimeWindow}; +use mkt::types::Decimal; +use std::str::FromStr; +use time::{Duration, OffsetDateTime}; + +fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("test decimal must be valid") +} + +#[test] +fn parent_schedule_computes_time_deficit_and_catch_up_value() { + let start = OffsetDateTime::UNIX_EPOCH; + let schedule = ParentSchedule::linear(TimeWindow::new(start, start + Duration::seconds(100))) + .with_catch_up_multiplier(decimal("2")); + let progress = TargetProgressView::from_target_and_filled( + ExecutionTarget::base_quantity(decimal("100")), + decimal("20"), + ); + + let evaluation = schedule + .evaluate(progress, start + Duration::seconds(50)) + .expect("schedule window is valid"); + + assert_eq!(evaluation.scheduled_completion, decimal("0.5")); + assert_eq!(evaluation.actual_completion, decimal("0.2")); + assert_eq!(evaluation.target_value_due, decimal("50.0")); + assert_eq!(evaluation.deficit_value, decimal("30.0")); + assert_eq!(evaluation.catch_up_child_value, decimal("60.0")); + assert_eq!(evaluation.catch_up_pressure, decimal("0.375")); +} + +#[test] +fn parent_schedule_caps_participation_budget_by_remaining_target() { + let start = OffsetDateTime::UNIX_EPOCH; + let schedule = ParentSchedule::linear(TimeWindow::new(start, start + Duration::seconds(100))) + .with_participation_limit_bps(1_000); + let progress = TargetProgressView::from_target_and_filled( + ExecutionTarget::base_quantity(decimal("100")), + decimal("95"), + ); + + let budget = schedule + .participation_budget(progress, start + Duration::seconds(80), decimal("1000")) + .expect("schedule window is valid"); + + assert_eq!(budget, decimal("5")); +} + +#[test] +fn parent_schedule_can_be_reused_for_evaluation_and_participation_budget() { + let start = OffsetDateTime::UNIX_EPOCH; + let schedule = ParentSchedule::linear(TimeWindow::new(start, start + Duration::seconds(100))) + .with_catch_up_multiplier(decimal("1.5")) + .with_participation_limit_bps(500); + let progress = TargetProgressView::from_target_and_filled( + ExecutionTarget::base_quantity(decimal("100")), + decimal("30"), + ); + + let evaluation = schedule + .evaluate(progress.clone(), start + Duration::seconds(60)) + .expect("schedule evaluation must not consume schedule"); + let participation_budget = schedule + .participation_budget(progress, start + Duration::seconds(60), decimal("200")) + .expect("participation budget must reuse schedule"); + + assert_eq!(evaluation.deficit_value, decimal("30.0")); + assert_eq!(participation_budget, decimal("6.00")); +} + +#[test] +fn child_budget_policy_combines_schedule_participation_and_urgency_floor() { + let start = OffsetDateTime::UNIX_EPOCH; + let schedule = ParentSchedule::linear(TimeWindow::new(start, start + Duration::seconds(100))); + let progress = TargetProgressView::from_target_and_filled( + ExecutionTarget::base_quantity(decimal("10")), + decimal("8"), + ); + let evaluation = schedule + .evaluate(progress.clone(), start) + .expect("schedule evaluation must succeed"); + + let child_value = ChildBudgetPolicy::new() + .with_min_child_value(decimal("0.5")) + .with_urgency_floor_value(decimal("3")) + .target_child_value_no_context(&progress, &evaluation, Some(decimal("1"))); + + assert_eq!(child_value, decimal("2")); +} + +#[test] +fn child_budget_policy_caps_child_value_by_interval_participation() { + let start = OffsetDateTime::UNIX_EPOCH; + let schedule = ParentSchedule::linear(TimeWindow::new(start, start + Duration::seconds(100))); + let progress = TargetProgressView::from_target_and_filled( + ExecutionTarget::base_quantity(decimal("100")), + decimal("0"), + ); + let evaluation = schedule + .evaluate(progress.clone(), start + Duration::seconds(50)) + .expect("schedule evaluation must succeed"); + let context = + ChildBudgetContext::new().with_interval_market_volume_in_target_units(decimal("200")); + + let child_value = ChildBudgetPolicy::new() + .with_interval_participation_limit_bps(1_000) + .target_child_value_with_context(&progress, &evaluation, None, context) + .expect("interval volume is present for capped child budget"); + + assert_eq!(child_value, decimal("20")); +} + +#[test] +fn child_budget_policy_rejects_target_child_value_when_interval_cap_needs_volume() { + let start = OffsetDateTime::UNIX_EPOCH; + let schedule = ParentSchedule::linear(TimeWindow::new(start, start + Duration::seconds(100))); + let progress = TargetProgressView::from_target_and_filled( + ExecutionTarget::base_quantity(decimal("100")), + decimal("0"), + ); + let evaluation = schedule + .evaluate(progress.clone(), start + Duration::seconds(50)) + .expect("schedule evaluation must succeed"); + + let error = ChildBudgetPolicy::new() + .with_interval_participation_limit_bps(1_000) + .target_child_value(&progress, &evaluation, None) + .expect_err("target_child_value must not silently ignore interval caps"); + + assert!(matches!(error, exh::Error::PolicyViolation { .. })); + assert!(error.to_string().contains("interval market volume")); +} + +#[test] +fn child_budget_policy_no_context_method_explicitly_uses_basic_budget() { + let start = OffsetDateTime::UNIX_EPOCH; + let schedule = ParentSchedule::linear(TimeWindow::new(start, start + Duration::seconds(100))); + let progress = TargetProgressView::from_target_and_filled( + ExecutionTarget::base_quantity(decimal("100")), + decimal("0"), + ); + let evaluation = schedule + .evaluate(progress.clone(), start + Duration::seconds(50)) + .expect("schedule evaluation must succeed"); + + let child_value = ChildBudgetPolicy::new() + .with_interval_participation_limit_bps(1_000) + .target_child_value_no_context(&progress, &evaluation, None); + + assert_eq!(child_value, decimal("50.0")); +} + +#[test] +fn child_budget_policy_rejects_interval_participation_without_interval_volume() { + let start = OffsetDateTime::UNIX_EPOCH; + let schedule = ParentSchedule::linear(TimeWindow::new(start, start + Duration::seconds(100))); + let progress = TargetProgressView::from_target_and_filled( + ExecutionTarget::base_quantity(decimal("100")), + decimal("0"), + ); + let evaluation = schedule + .evaluate(progress.clone(), start + Duration::seconds(50)) + .expect("schedule evaluation must succeed"); + + let error = ChildBudgetPolicy::new() + .with_interval_participation_limit_bps(1_000) + .target_child_value_with_context( + &progress, + &evaluation, + None, + ChildBudgetContext::default(), + ) + .expect_err("strict interval participation requires interval volume"); + + assert!(error.to_string().contains("interval market volume")); +} + +#[test] +fn child_budget_policy_applies_volatility_throttle_and_alpha_decay() { + let start = OffsetDateTime::UNIX_EPOCH; + let schedule = ParentSchedule::linear(TimeWindow::new(start, start + Duration::seconds(100))); + let progress = TargetProgressView::from_target_and_filled( + ExecutionTarget::base_quantity(decimal("100")), + decimal("0"), + ); + let evaluation = schedule + .evaluate(progress.clone(), start + Duration::seconds(40)) + .expect("schedule evaluation must succeed"); + let context = ChildBudgetContext::new().with_alpha_urgency(decimal("2")); + + let child_value = ChildBudgetPolicy::new() + .with_urgency_floor_value(decimal("10")) + .with_volatility_throttle(decimal("0.5")) + .with_alpha_urgency_decay(decimal("1")) + .target_child_value_with_context(&progress, &evaluation, None, context) + .expect("context has all inputs required by configured knobs"); + + assert_eq!(child_value, decimal("30.00")); +} + +#[test] +fn time_window_rejects_non_positive_windows() { + let start = OffsetDateTime::UNIX_EPOCH; + let error = TimeWindow::new(start, start) + .progress_at(start) + .expect_err("zero-length window must fail"); + + assert!(error.to_string().contains("positive")); +} diff --git a/crates/exh-kit/tests/schema.rs b/crates/exh-kit/tests/schema.rs new file mode 100644 index 0000000..eb8d656 --- /dev/null +++ b/crates/exh-kit/tests/schema.rs @@ -0,0 +1,173 @@ +use exh::{ + AlgorithmAuditEvent, AlgorithmLifecycleEffects, AlgorithmStatePatch, AlgorithmStateView, +}; +use exh::{AlgorithmDecision, DesiredState}; +use exh_kit::schema::{ + AlgorithmAuditSchema, AlgorithmDecisionSchemaExt, AlgorithmLifecycleEffectsSchemaExt, + AlgorithmStateSchema, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct SliceState { + slice_index: u64, + request_count: u64, +} + +impl AlgorithmStateSchema for SliceState { + const SCHEMA: &'static str = "test.slice_state"; + const VERSION: u32 = 1; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct SliceAudit { + slice_index: u64, + fill_ratio: String, +} + +impl AlgorithmAuditSchema for SliceAudit { + const EVENT_TYPE: &'static str = "test.slice_audit"; + const VERSION: u32 = 1; +} + +#[test] +fn state_schema_round_trips_through_patch_and_view() { + let state = SliceState { + slice_index: 3, + request_count: 7, + }; + let patch = state.to_patch().expect("state patch must serialize"); + assert_eq!(patch.schema, SliceState::SCHEMA); + assert_eq!(patch.version, SliceState::VERSION); + assert_eq!( + SliceState::from_patch(&patch).expect("state patch must deserialize"), + state + ); + + let mut view = AlgorithmStateView::new(); + view.patches_by_schema.insert(patch.schema.clone(), patch); + assert_eq!( + SliceState::load(&view).expect("state view must decode"), + Some(state) + ); +} + +#[test] +fn state_schema_rejects_version_mismatch() { + let patch = AlgorithmStatePatch::new( + SliceState::SCHEMA, + SliceState::VERSION + 1, + serde_json::to_value(SliceState { + slice_index: 3, + request_count: 7, + }) + .expect("test payload must serialize"), + ); + + let error = SliceState::from_patch(&patch).expect_err("version mismatch must fail"); + assert!(error.to_string().contains("version")); +} + +#[test] +fn audit_schema_round_trips_and_filters_event_history() { + let audit = SliceAudit { + slice_index: 2, + fill_ratio: "0.75".to_owned(), + }; + let event = audit.to_event().expect("audit event must serialize"); + assert_eq!(event.event_type, SliceAudit::EVENT_TYPE); + assert_eq!(event.version, SliceAudit::VERSION); + assert_eq!( + SliceAudit::from_event(&event).expect("audit event must deserialize"), + audit + ); + + let unrelated = AlgorithmAuditEvent::new( + "test.other_event", + 1, + serde_json::json!({ + "ignored": true, + }), + ); + assert_eq!( + SliceAudit::collect_from(&[unrelated, event]).expect("audit history must decode"), + vec![audit] + ); +} + +#[test] +fn audit_schema_rejects_version_mismatch() { + let event = AlgorithmAuditEvent::new( + SliceAudit::EVENT_TYPE, + SliceAudit::VERSION + 1, + serde_json::to_value(SliceAudit { + slice_index: 2, + fill_ratio: "0.75".to_owned(), + }) + .expect("test payload must serialize"), + ); + + let error = SliceAudit::from_event(&event).expect_err("version mismatch must fail"); + assert!(error.to_string().contains("version")); +} + +#[test] +fn decision_extension_attaches_typed_state_and_audit() { + let state = SliceState { + slice_index: 3, + request_count: 7, + }; + let audit = SliceAudit { + slice_index: 3, + fill_ratio: "0.75".to_owned(), + }; + + let decision = AlgorithmDecision::from_desired(DesiredState::paused()) + .with_typed_state(&state) + .expect("typed state must attach") + .with_typed_audit(&audit) + .expect("typed audit must attach"); + + let patch = decision.state_patch.expect("typed state patch must exist"); + assert_eq!( + SliceState::from_patch(&patch).expect("state patch must decode"), + state + ); + assert_eq!(decision.audit_events.len(), 1); + assert_eq!( + SliceAudit::from_event(&decision.audit_events[0]).expect("audit event must decode"), + audit + ); +} + +#[test] +fn lifecycle_effects_extension_attaches_typed_state_and_audit() { + let state = SliceState { + slice_index: 4, + request_count: 11, + }; + let audit = SliceAudit { + slice_index: 4, + fill_ratio: "0.50".to_owned(), + }; + + let effects = AlgorithmLifecycleEffects::new() + .with_typed_state(&state) + .expect("typed lifecycle state must attach") + .with_typed_audit(&audit) + .expect("typed lifecycle audit must attach"); + + let patch = effects + .state_patch + .expect("typed lifecycle state patch must exist"); + assert_eq!( + SliceState::from_patch(&patch).expect("lifecycle state patch must decode"), + state + ); + assert_eq!(effects.audit_events.len(), 1); + assert_eq!( + SliceAudit::from_event(&effects.audit_events[0]) + .expect("lifecycle audit event must decode"), + audit + ); +} diff --git a/crates/exh-kit/tests/strategy_prelude.rs b/crates/exh-kit/tests/strategy_prelude.rs new file mode 100644 index 0000000..f5a6dff --- /dev/null +++ b/crates/exh-kit/tests/strategy_prelude.rs @@ -0,0 +1,214 @@ +use async_trait::async_trait; +use exh::ExecutionTarget; +use exh_kit::strategy_prelude::{ + Algorithm, AlgorithmAuditSchema, AlgorithmDecision, AlgorithmDecisionSchemaExt, + AlgorithmLifecycleEffects, AlgorithmLifecycleEffectsSchemaExt, AlgorithmLifecycleEvent, + AlgorithmStateSchema, ChildBudgetContext, ChildBudgetPolicy, ChildId, ChildKey, + ChildOrderFactory, ChildOrderStyle, ChildTarget, DesiredState, EvaluateContext, + LegBudgetAllocation, LifecycleContext, LifecycleEventKind, LifecycleEventView, + MultiAssetCoordinator, MultiAssetLeg, MultiAssetPlan, OrderRequest, ParentAuditEvent, + ParentAuditSchema, ParentExecutionRun, ParentExecutionRunner, ParentRunLifecycleContext, + ParentRunLifecycleEffects, ParentRunLifecycleEffectsSchemaExt, ParentRunState, + ParentRunStateSchemaExt, ParentSchedule, ParentStatePatch, ParentStateSchema, ParentStateView, + ScheduleEvaluation, SignalFeatureRequirement, SignalFeatureRequirements, SignalFeatureSchema, + SignalFrame, SignalFrameFeatureExt, SpotChildOrderInput, TargetProgressView, TargetValueKind, + TerminalState, TimeWindow, +}; +use mkt::types::Decimal; +use serde::{Deserialize, Serialize}; +use time::{Duration, OffsetDateTime}; + +#[derive(Debug)] +struct NoopStrategy; + +#[async_trait] +impl Algorithm for NoopStrategy { + async fn evaluate( + &self, + _context: &EvaluateContext, + ) -> Result { + Ok(AlgorithmDecision::paused()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct TestState { + slice_index: u64, +} + +impl AlgorithmStateSchema for TestState { + const SCHEMA: &'static str = "test.strategy_prelude_state"; + const VERSION: u32 = 1; +} + +impl AlgorithmAuditSchema for TestState { + const EVENT_TYPE: &'static str = "test.strategy_prelude_audit"; + const VERSION: u32 = 1; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct TestFeature { + score: u64, +} + +impl SignalFeatureSchema for TestFeature { + const EXTENSION_KEY: &'static str = "features.strategy_prelude"; + const SCHEMA: &'static str = "test.strategy_prelude_feature"; + const VERSION: u32 = 1; +} + +struct FeatureAwareStrategy; + +impl SignalFeatureRequirements for FeatureAwareStrategy { + fn required_signal_features(&self) -> Vec { + vec![TestFeature::requirement()] + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct TestParentState { + rebalance_count: u64, +} + +impl ParentStateSchema for TestParentState { + const SCHEMA: &'static str = "test.strategy_prelude_parent_state"; + const VERSION: u32 = 1; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct TestParentAudit { + leg_count: u64, +} + +impl ParentAuditSchema for TestParentAudit { + const EVENT_TYPE: &'static str = "test.strategy_prelude_parent_audit"; + const VERSION: u32 = 1; +} + +#[test] +fn prelude_exports_compile_for_strategy_authoring() { + let frame = SignalFrame::builder() + .build() + .expect("signal frame must build") + .with_typed_feature(&TestFeature { score: 7 }) + .expect("typed feature must attach"); + let feature = frame + .require_typed_feature::() + .expect("typed feature must load"); + + assert_eq!(feature, TestFeature { score: 7 }); + assert_eq!( + FeatureAwareStrategy.required_signal_features(), + vec![TestFeature::requirement()] + ); + + let state = TestState { slice_index: 3 }; + let decision = AlgorithmDecision::from_desired(DesiredState::paused()) + .with_typed_state(&state) + .expect("typed state must attach"); + let effects = AlgorithmLifecycleEffects::new() + .with_typed_audit(&state) + .expect("typed audit must attach"); + + assert!(decision.state_patch.is_some()); + assert_eq!(effects.audit_events.len(), 1); + + let start = OffsetDateTime::UNIX_EPOCH; + let schedule = ParentSchedule::linear(TimeWindow::new(start, start + Duration::seconds(10))); + let progress = TargetProgressView::from_target_and_filled( + ExecutionTarget::base_quantity(Decimal::from(10_u32)), + Decimal::from(4_u32), + ); + let evaluation: ScheduleEvaluation = schedule + .evaluate(progress.clone(), start + Duration::seconds(5)) + .expect("schedule window must be valid"); + let child_value = ChildBudgetPolicy::new() + .with_min_child_value(Decimal::ONE) + .target_child_value_with_context( + &progress, + &evaluation, + None, + ChildBudgetContext::new().with_volatility_throttle(Decimal::new(5, 1)), + ) + .expect("context-aware child budget must evaluate"); + + assert_eq!(progress.kind, TargetValueKind::BaseQuantity); + assert_eq!(child_value, Decimal::new(5, 1)); + assert!(matches!( + AlgorithmDecision::finishing(TerminalState::Completed).desired, + DesiredState { children, .. } if children.is_empty() + )); +} + +#[test] +fn prelude_exposes_remaining_core_types() { + let _strategy = NoopStrategy; + let _style = ChildOrderStyle::Passive; + let _child_key = ChildKey::new("prelude-child"); + let _event_kind = LifecycleEventKind::Observed; + + let _ = std::mem::size_of::>(); + let _ = std::mem::size_of::>(); + let _ = std::mem::size_of::>>(); + let _ = std::mem::size_of::>>(); + let _ = std::mem::size_of::>>(); + let _ = std::mem::size_of::>(); + let _ = std::mem::size_of::>(); + let _ = std::mem::size_of::>>(); + let _ = std::mem::size_of::>(); + let _ = std::mem::size_of::>(); + let _ = std::mem::size_of::>(); + let _ = std::mem::size_of::>(); + let _ = std::mem::size_of::>(); + let _ = std::mem::size_of::>(); + let _ = std::mem::size_of::>>(); +} + +#[test] +fn prelude_exports_parent_run_state_schema_helpers() { + let recorded_at = OffsetDateTime::UNIX_EPOCH + Duration::seconds(1); + let state = TestParentState { rebalance_count: 2 }; + let audit = TestParentAudit { leg_count: 3 }; + + let patch: ParentStatePatch = state + .to_parent_patch() + .expect("parent state patch must serialize"); + let mut view = ParentStateView::new(); + view.apply_patch(patch); + assert_eq!( + TestParentState::load_parent_state(&view).expect("parent state view must decode"), + Some(state.clone()) + ); + + let event: ParentAuditEvent = audit + .to_parent_event(recorded_at) + .expect("parent audit event must serialize"); + assert_eq!( + TestParentAudit::from_parent_event(&event).expect("parent audit event must decode"), + audit + ); + + let run_state = ParentRunState::new() + .with_typed_state(&state, recorded_at) + .expect("typed parent state must attach") + .with_typed_audit(&audit, recorded_at) + .expect("typed parent audit must attach"); + + assert_eq!( + TestParentState::load_parent_state(&run_state.state).expect("parent run state must decode"), + Some(state.clone()) + ); + assert_eq!( + TestParentAudit::collect_parent_events(&run_state.audit_events) + .expect("parent audit events must decode"), + vec![audit.clone()] + ); + + let lifecycle_effects = ParentRunLifecycleEffects::new(recorded_at) + .with_typed_state(&state) + .expect("typed parent lifecycle state must attach") + .with_typed_audit(&audit) + .expect("typed parent lifecycle audit must attach"); + + assert!(!lifecycle_effects.is_empty()); +} diff --git a/crates/exh-kit/tests/testing.rs b/crates/exh-kit/tests/testing.rs new file mode 100644 index 0000000..167b5fc --- /dev/null +++ b/crates/exh-kit/tests/testing.rs @@ -0,0 +1,234 @@ +use async_trait::async_trait; +use exh::{ + AdvanceInput, Algorithm, AlgorithmDecision, ChildKey, ChildTarget, Engine, Error, + EvaluateContext, ExecutionId, ExecutionIntent, MemoryJournal, OrderRequest, +}; +use exh_kit::constraints::MarketConstraintService; +use exh_kit::signals::SignalFrame; +use exh_kit::testing::{ + SimulatedVenue, book_frame, decimal, filled_active_child, filled_spot_order, spot_market, + spot_market_fixture, +}; +use mkt::types::{ + ClientOrderId, Decimal, ExchangeId, KnownExchange, MarketKind, MarketQuantityMode, + MarketStatus, OrderId, OrderQuantity, OrderSide, OrderStatus, OrderType, SpotOrderRequest, + Symbol, +}; + +struct SingleChildFixtureAlgo; + +#[async_trait] +impl Algorithm for SingleChildFixtureAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + if context + .snapshot + .active_children + .contains_key(&ChildKey::new("primary")) + { + return Ok(AlgorithmDecision::keep_active(&context.snapshot)); + } + + let book_ticker = + context + .signals + .book_ticker + .as_ref() + .ok_or_else(|| Error::PolicyViolation { + message: "single child fixture requires book ticker".to_owned(), + })?; + let quantity = context + .snapshot + .remaining_base_quantity() + .unwrap_or(Decimal::ZERO); + let request = SpotOrderRequest::builder() + .symbol(context.intent.symbol.clone()) + .side(context.intent.side) + .order_type(OrderType::Limit) + .quantity(OrderQuantity::Base(quantity)) + .price(Some(book_ticker.ask_price)) + .client_order_id(ClientOrderId::new("child-primary")) + .build() + .map(OrderRequest::Spot) + .map_err(|message| Error::PolicyViolation { + message: message.to_string(), + })?; + Ok(AlgorithmDecision::running(vec![ChildTarget::new( + ChildKey::new("primary"), + request, + )])) + } +} + +#[test] +fn spot_market_fixture_sets_spot_permissions_and_constraints() { + let market = spot_market_fixture(Symbol::spot("SOLUSDT")); + let constraints = MarketConstraintService::new(&market); + + assert_eq!(market.exchange_id, ExchangeId::from(KnownExchange::Binance)); + assert_eq!(market.status, MarketStatus::Trading); + assert!(constraints.allows_spot_order_entry()); + assert!(constraints.supports_order_type(OrderType::Limit)); + assert!(constraints.supports_order_type(OrderType::PostOnly)); + assert!(constraints.supports_order_type(OrderType::Market)); + assert!(constraints.supports_quantity_mode( + MarketQuantityMode::Base, + OrderType::Limit, + OrderSide::Buy + )); + assert!(constraints.supports_quantity_mode( + MarketQuantityMode::Base, + OrderType::Market, + OrderSide::Sell + )); + + assert_eq!(constraints.tick_size(), Some(decimal("0.1"))); + assert_eq!( + constraints.min_quantity(OrderType::Limit), + Some(decimal("0.1")) + ); + assert_eq!( + constraints.step_size(OrderType::Limit), + Some(decimal("0.1")) + ); + assert_eq!(constraints.min_notional_or(decimal("0")), decimal("10")); +} + +#[test] +fn spot_market_builder_customizes_constraints() { + let market = spot_market(Symbol::spot("SOLUSDT")) + .with_tick_size(decimal("0.01")) + .with_min_quantity(decimal("0.25")) + .with_step_size(decimal("0.05")) + .with_min_notional(decimal("50")) + .build(); + let constraints = MarketConstraintService::new(&market); + + assert_eq!(constraints.tick_size(), Some(decimal("0.01"))); + assert_eq!( + constraints.min_quantity(OrderType::Limit), + Some(decimal("0.25")) + ); + assert_eq!( + constraints.step_size(OrderType::Limit), + Some(decimal("0.05")) + ); + assert_eq!(constraints.min_notional_or(decimal("0")), decimal("50")); +} + +#[test] +fn spot_market_builder_customizes_supported_order_types() { + let market = spot_market(Symbol::spot("SOLUSDT")) + .with_supported_order_types(vec![OrderType::PostOnly]) + .build(); + let constraints = MarketConstraintService::new(&market); + + assert!(!constraints.supports_order_type(OrderType::Limit)); + assert!(constraints.supports_order_type(OrderType::PostOnly)); + assert!(!constraints.supports_order_type(OrderType::Market)); + assert!(constraints.supports_quantity_mode( + MarketQuantityMode::Base, + OrderType::PostOnly, + OrderSide::Buy + )); + assert!(!constraints.supports_quantity_mode( + MarketQuantityMode::Base, + OrderType::Limit, + OrderSide::Buy + )); +} + +#[test] +fn book_frame_contains_top_of_book() { + let symbol = Symbol::spot("SOLUSDT"); + let frame = book_frame( + &symbol, + decimal("100.1"), + decimal("2.0"), + decimal("100.2"), + decimal("3.0"), + ); + + let ticker = frame + .book_ticker + .expect("book frame fixture must contain a book ticker"); + assert_eq!(ticker.symbol, symbol); + assert_eq!(ticker.bid_price, decimal("100.1")); + assert_eq!(ticker.bid_quantity, decimal("2.0")); + assert_eq!(ticker.ask_price, decimal("100.2")); + assert_eq!(ticker.ask_quantity, decimal("3.0")); +} + +#[test] +fn filled_spot_order_sets_filled_quantities() { + let symbol = Symbol::spot("SOLUSDT"); + let order = filled_spot_order(&symbol, "child-1", decimal("1.5"), decimal("20")); + + assert_eq!(order.symbol, symbol); + assert_eq!(order.market_kind, MarketKind::Spot); + assert_eq!(order.side, OrderSide::Buy); + assert_eq!(order.order_type, OrderType::Limit); + assert_eq!(order.status, OrderStatus::Filled); + assert_eq!(order.quantity, decimal("1.5")); + assert_eq!(order.filled_quantity, decimal("1.5")); + assert_eq!(order.price, Some(decimal("20"))); + assert_eq!(order.cumulative_quote_quantity, Some(decimal("30.0"))); +} + +#[tokio::test] +async fn filled_active_child_inherits_order_identity_from_snapshot() { + let symbol = Symbol::spot("SOLUSDT"); + let key = ChildKey::new("primary"); + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("exec-1")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target_quantity(decimal("2")) + .build() + .expect("intent must build"); + let engine = Engine::new( + SimulatedVenue::default(), + MemoryJournal::default(), + SingleChildFixtureAlgo, + ); + let snapshot = engine + .start(intent, time::OffsetDateTime::UNIX_EPOCH) + .await + .expect("fixture engine start must succeed"); + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new( + time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1), + book_frame( + &symbol, + decimal("19.9"), + decimal("5"), + decimal("20"), + decimal("4"), + ), + Vec::new(), + ), + ) + .await + .expect("fixture engine advance must place the child"); + let snapshot = outcome.into_snapshot(); + assert!(snapshot.active_children.contains_key(&key)); + + let fill = filled_active_child(&snapshot, &key, decimal("1.5"), decimal("21")) + .expect("active child fill must build"); + + assert_eq!(fill.id, OrderId::new("order-child-primary")); + assert_eq!( + fill.client_order_id, + Some(ClientOrderId::new("child-primary")) + ); + assert_eq!(fill.status, OrderStatus::Filled); + assert_eq!(fill.quantity, decimal("2")); + assert_eq!(fill.filled_quantity, decimal("1.5")); + assert_eq!(fill.price, Some(decimal("21"))); + assert_eq!(fill.cumulative_quote_quantity, Some(decimal("31.5"))); +} diff --git a/crates/exh/src/algorithm.rs b/crates/exh/src/algorithm.rs new file mode 100644 index 0000000..a6373ec --- /dev/null +++ b/crates/exh/src/algorithm.rs @@ -0,0 +1,296 @@ +use async_trait::async_trait; +use mkt::types::{ClientOrderId, Order}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use time::OffsetDateTime; + +use crate::driver::OrderRequest; +use crate::engine::{ExecutionSnapshot, TerminalState}; +use crate::error::Error; +use crate::intent::ExecutionIntent; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ChildKey(pub String); + +impl ChildKey { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct ChildTarget { + pub key: ChildKey, + pub request: OrderRequest, +} + +impl ChildTarget { + pub fn new(key: ChildKey, request: OrderRequest) -> Self { + Self { key, request } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum AlgorithmMode { + Running, + Paused, + Finishing { terminal_state: TerminalState }, +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct DesiredState { + pub mode: AlgorithmMode, + pub children: Vec, +} + +impl DesiredState { + pub fn new(mode: AlgorithmMode, children: Vec) -> Self { + Self { mode, children } + } + + pub fn running(children: Vec) -> Self { + Self::new(AlgorithmMode::Running, children) + } + + pub fn paused() -> Self { + Self::new(AlgorithmMode::Paused, Vec::new()) + } + + pub fn finishing(terminal_state: TerminalState) -> Self { + Self::new(AlgorithmMode::Finishing { terminal_state }, Vec::new()) + } + + pub fn keep_active(snapshot: &ExecutionSnapshot) -> Self { + let children = snapshot + .active_children + .values() + .map(|child| ChildTarget::new(child.key.clone(), child.request.clone())) + .collect(); + Self::running(children) + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct AlgorithmDecision { + pub desired: DesiredState, + #[allow(clippy::struct_field_names)] + pub next_wake_at: Option, + pub state_patch: Option, + pub audit_events: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct AlgorithmLifecycleEffects { + pub state_patch: Option, + pub audit_events: Vec, +} + +impl AlgorithmLifecycleEffects { + pub fn new() -> Self { + Self { + state_patch: None, + audit_events: Vec::new(), + } + } + + pub fn state_patch(mut self, state_patch: AlgorithmStatePatch) -> Self { + self.state_patch = Some(state_patch); + self + } + + pub fn audit_event(mut self, audit_event: AlgorithmAuditEvent) -> Self { + self.audit_events.push(audit_event); + self + } + + pub fn is_empty(&self) -> bool { + self.state_patch.is_none() && self.audit_events.is_empty() + } +} + +impl Default for AlgorithmLifecycleEffects { + fn default() -> Self { + Self::new() + } +} + +impl AlgorithmDecision { + pub fn new(desired: DesiredState, next_wake_at: Option) -> Self { + Self { + desired, + next_wake_at, + state_patch: None, + audit_events: Vec::new(), + } + } + + pub fn from_desired(desired: DesiredState) -> Self { + Self::new(desired, None) + } + + pub fn wake_at(mut self, next_wake_at: OffsetDateTime) -> Self { + self.next_wake_at = Some(next_wake_at); + self + } + + pub fn state_patch(mut self, state_patch: AlgorithmStatePatch) -> Self { + self.state_patch = Some(state_patch); + self + } + + pub fn audit_event(mut self, audit_event: AlgorithmAuditEvent) -> Self { + self.audit_events.push(audit_event); + self + } + + pub fn running(children: Vec) -> Self { + Self::from_desired(DesiredState::running(children)) + } + + pub fn paused() -> Self { + Self::from_desired(DesiredState::paused()) + } + + pub fn finishing(terminal_state: TerminalState) -> Self { + Self::from_desired(DesiredState::finishing(terminal_state)) + } + + pub fn keep_active(snapshot: &ExecutionSnapshot) -> Self { + Self::from_desired(DesiredState::keep_active(snapshot)) + } +} + +impl From for AlgorithmDecision { + fn from(desired: DesiredState) -> Self { + Self::from_desired(desired) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct AlgorithmStatePatch { + pub schema: String, + pub version: u32, + pub payload: Value, +} + +impl AlgorithmStatePatch { + pub fn new(schema: impl Into, version: u32, payload: Value) -> Self { + Self { + schema: schema.into(), + version, + payload, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct AlgorithmAuditEvent { + pub event_type: String, + pub version: u32, + pub payload: Value, +} + +impl AlgorithmAuditEvent { + pub fn new(event_type: impl Into, version: u32, payload: Value) -> Self { + Self { + event_type: event_type.into(), + version, + payload, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct AlgorithmStateView { + pub patches_by_schema: BTreeMap, +} + +impl AlgorithmStateView { + pub fn new() -> Self { + Self::default() + } + + pub fn get(&self, schema: &str) -> Option<&AlgorithmStatePatch> { + self.patches_by_schema.get(schema) + } + + pub(crate) fn apply_patch(&mut self, patch: AlgorithmStatePatch) { + self.patches_by_schema.insert(patch.schema.clone(), patch); + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct EvaluateContext { + pub intent: ExecutionIntent, + pub snapshot: ExecutionSnapshot, + pub algorithm_state: AlgorithmStateView, + #[allow(clippy::struct_field_names)] + pub observed_at: OffsetDateTime, + pub signals: S, +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum AlgorithmLifecycleEvent { + ChildPlaced { + key: ChildKey, + request: OrderRequest, + order: Order, + }, + ChildPlaceRejected { + key: ChildKey, + request: OrderRequest, + client_order_id: ClientOrderId, + message: String, + }, + ChildCanceled { + key: ChildKey, + order: Order, + }, + ChildObserved { + key: ChildKey, + order: Order, + }, +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct LifecycleContext { + pub intent: ExecutionIntent, + pub snapshot: ExecutionSnapshot, + #[allow(clippy::struct_field_names)] + pub observed_at: OffsetDateTime, + pub signals: S, + pub event: AlgorithmLifecycleEvent, +} + +#[async_trait] +pub trait Algorithm: Send + Sync +where + S: Clone + Send + Sync + 'static, +{ + async fn evaluate(&self, context: &EvaluateContext) -> Result; + + async fn on_lifecycle( + &self, + _context: &LifecycleContext, + ) -> Result { + Ok(AlgorithmLifecycleEffects::new()) + } +} + +#[allow(dead_code)] +fn _keep_order_type_visible(order: &Order) -> &Order { + order +} diff --git a/crates/exh/src/driver.rs b/crates/exh/src/driver.rs new file mode 100644 index 0000000..323f7a5 --- /dev/null +++ b/crates/exh/src/driver.rs @@ -0,0 +1,146 @@ +use async_trait::async_trait; +use mkt::types::{ + ClientOrderId, Decimal, FuturesCancelOrderRequest, FuturesOrderQuery, FuturesOrderRequest, + MarketKind, Order, OrderKey, OrderQuantity, SpotCancelOrderRequest, SpotOrderQuery, + SpotOrderRequest, Symbol, +}; +use serde::{Deserialize, Serialize}; + +use crate::error::Error; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum OrderRequest { + Spot(SpotOrderRequest), + Futures(FuturesOrderRequest), +} + +impl OrderRequest { + pub fn symbol(&self) -> &Symbol { + match self { + Self::Spot(request) => &request.symbol, + Self::Futures(request) => &request.symbol, + } + } + + pub fn market_kind(&self) -> MarketKind { + self.symbol().kind + } + + pub fn client_order_id(&self) -> Option<&ClientOrderId> { + match self { + Self::Spot(request) => request.client_order_id.as_ref(), + Self::Futures(request) => request.client_order_id.as_ref(), + } + } + + pub fn set_client_order_id(&mut self, client_order_id: ClientOrderId) { + match self { + Self::Spot(request) => { + request.client_order_id = Some(client_order_id); + } + Self::Futures(request) => { + request.client_order_id = Some(client_order_id); + } + } + } + + pub fn execution_quantity(&self) -> Option { + self.execution_base_quantity() + } + + pub fn execution_base_quantity(&self) -> Option { + match self { + Self::Spot(request) => match request.quantity { + OrderQuantity::Base(quantity) => Some(quantity), + OrderQuantity::Quote(_) => None, + _ => None, + }, + Self::Futures(request) => Some(request.quantity), + } + } + + pub fn execution_quote_amount(&self) -> Option { + match self { + Self::Spot(request) => match request.quantity { + OrderQuantity::Quote(quantity) => Some(quantity), + OrderQuantity::Base(quantity) => request.price.map(|price| quantity * price), + _ => None, + }, + Self::Futures(request) => request.price.map(|price| request.quantity * price), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum OrderQuery { + Spot(SpotOrderQuery), + Futures(FuturesOrderQuery), +} + +impl OrderQuery { + pub fn by_client_order_id(symbol: Symbol, client_order_id: ClientOrderId) -> Self { + match symbol.kind { + MarketKind::Spot => Self::Spot(SpotOrderQuery::new( + symbol, + OrderKey::Client(client_order_id), + )), + MarketKind::Derivative(_) => Self::Futures(FuturesOrderQuery::new( + symbol, + OrderKey::Client(client_order_id), + )), + _ => Self::Futures(FuturesOrderQuery::new( + symbol, + OrderKey::Client(client_order_id), + )), + } + } + + pub fn by_exchange_order_id(symbol: Symbol, order_id: mkt::types::OrderId) -> Self { + match symbol.kind { + MarketKind::Spot => { + Self::Spot(SpotOrderQuery::new(symbol, OrderKey::Exchange(order_id))) + } + MarketKind::Derivative(_) => { + Self::Futures(FuturesOrderQuery::new(symbol, OrderKey::Exchange(order_id))) + } + _ => Self::Futures(FuturesOrderQuery::new(symbol, OrderKey::Exchange(order_id))), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum CancelOrderRequest { + Spot(SpotCancelOrderRequest), + Futures(FuturesCancelOrderRequest), +} + +impl CancelOrderRequest { + pub fn by_client_order_id(symbol: Symbol, client_order_id: ClientOrderId) -> Self { + match symbol.kind { + MarketKind::Spot => Self::Spot(SpotCancelOrderRequest::new( + symbol, + OrderKey::Client(client_order_id), + )), + MarketKind::Derivative(_) => Self::Futures(FuturesCancelOrderRequest::new( + symbol, + OrderKey::Client(client_order_id), + )), + _ => Self::Futures(FuturesCancelOrderRequest::new( + symbol, + OrderKey::Client(client_order_id), + )), + } + } +} + +#[async_trait] +pub trait Driver: Send + Sync { + async fn place_order(&self, request: OrderRequest) -> Result; + + async fn query_order(&self, query: OrderQuery) -> Result; + + async fn cancel_order(&self, request: CancelOrderRequest) -> Result; +} diff --git a/crates/exh/src/engine.rs b/crates/exh/src/engine.rs new file mode 100644 index 0000000..c2fb0ca --- /dev/null +++ b/crates/exh/src/engine.rs @@ -0,0 +1,11 @@ +mod core; +mod decision; +mod lifecycle; +pub(crate) mod state; +mod types; + +pub use core::Engine; +pub use state::{ActiveChild, ExecutionProgress, ExecutionSnapshot, PendingAction, TerminalState}; +pub use types::{ + AdvanceInput, AdvanceOutcome, EngineConfig, EngineConfigBuilder, OrderUpdate, ReplaceMode, +}; diff --git a/crates/exh/src/engine/core.rs b/crates/exh/src/engine/core.rs new file mode 100644 index 0000000..2950595 --- /dev/null +++ b/crates/exh/src/engine/core.rs @@ -0,0 +1,641 @@ +use mkt::types::{ClientOrderId, Order}; +use std::collections::BTreeMap; +use time::OffsetDateTime; +use tracing::warn; + +use crate::algorithm::{ + Algorithm, AlgorithmDecision, AlgorithmLifecycleEvent, AlgorithmMode, ChildKey, ChildTarget, + EvaluateContext, +}; +use crate::driver::{CancelOrderRequest, Driver, OrderQuery}; +use crate::engine::state::{ExecutionSnapshot, PendingAction}; +use crate::engine::types::{AdvanceInput, AdvanceOutcome, EngineConfig, OrderUpdate, ReplaceMode}; +use crate::error::Error; +use crate::intent::{ExecutionId, ExecutionIntent}; +use crate::journal::{Journal, JournalEntry, JournalEntryKind, StoredJournal, apply_entry}; + +#[non_exhaustive] +pub struct Engine { + pub(super) driver: D, + pub(super) journal: J, + pub(super) algorithm: A, + pub(super) config: EngineConfig, + _signals: std::marker::PhantomData, +} + +enum ReconcileResult { + Idle, + BlockedByExternalBudget(AdvanceOutcome), + SideEffect(AdvanceOutcome), + Terminal(AdvanceOutcome), +} + +impl Engine +where + D: Driver, + J: Journal, + A: Algorithm, + S: Clone + Send + Sync + 'static, +{ + pub fn new(driver: D, journal: J, algorithm: A) -> Self { + Self::with_config(driver, journal, algorithm, EngineConfig::default()) + } + + pub fn with_config(driver: D, journal: J, algorithm: A, config: EngineConfig) -> Self { + Self { + driver, + journal, + algorithm, + config, + _signals: std::marker::PhantomData, + } + } + + pub async fn start( + &self, + intent: ExecutionIntent, + recorded_at: OffsetDateTime, + ) -> Result { + if let Some(stored) = self.journal.load(&intent.execution_id).await? { + return StoredJournal::reconstruct(&stored); + } + + let mut snapshot = ExecutionSnapshot::from_intent(intent.clone()); + self.record( + &mut snapshot, + recorded_at, + JournalEntryKind::ExecutionCreated { intent }, + ) + .await?; + Ok(snapshot) + } + + pub async fn recover( + &self, + execution_id: &ExecutionId, + ) -> Result, Error> { + self.journal + .load(execution_id) + .await? + .map(|stored| StoredJournal::reconstruct(&stored)) + .transpose() + } + + pub async fn advance( + &self, + snapshot: &ExecutionSnapshot, + input: AdvanceInput, + ) -> Result { + if snapshot.is_terminal() { + return Err(Error::AlreadyTerminal { + execution_id: snapshot.execution_id().0.clone(), + }); + } + + let mut next = snapshot.clone(); + self.record(&mut next, input.observed_at, JournalEntryKind::TickObserved) + .await?; + + self.apply_external_updates( + &mut next, + input.observed_at, + input.signals.clone(), + input.order_updates, + ) + .await?; + + if let Some(outcome) = self + .reconcile_pending_if_due(&mut next, input.observed_at, input.signals.clone()) + .await? + { + return Ok(outcome); + } + + let mut remaining_external_actions = self.config.max_actions_per_advance; + let mut made_progress = false; + for _ in 0..self.config.max_internal_convergence_steps { + let context = EvaluateContext { + intent: next.intent.clone(), + snapshot: next.clone(), + algorithm_state: next.algorithm_state.clone(), + observed_at: input.observed_at, + signals: input.signals.clone(), + }; + let decision = self.algorithm.evaluate(&context).await?; + self.validate_decision(input.observed_at, &decision)?; + self.validate_desired_state(&next, &decision.desired)?; + made_progress |= self + .record_decision_effects(&mut next, input.observed_at, &decision) + .await?; + let next_wake_at = decision.next_wake_at; + + match self + .reconcile_to_desired_state( + &mut next, + input.observed_at, + decision, + &input.signals, + remaining_external_actions, + ) + .await? + { + ReconcileResult::Idle => { + return Ok(if next.is_terminal() { + AdvanceOutcome::Completed { snapshot: next } + } else if made_progress { + AdvanceOutcome::Progressed { + snapshot: next, + next_wake_at, + } + } else { + AdvanceOutcome::Quiescent { + snapshot: next, + next_wake_at, + } + }); + } + ReconcileResult::Terminal(outcome) + | ReconcileResult::BlockedByExternalBudget(outcome) => return Ok(outcome), + ReconcileResult::SideEffect(outcome) => { + made_progress = true; + remaining_external_actions = remaining_external_actions.saturating_sub(1); + next = outcome.into_snapshot(); + if next.is_terminal() { + return Ok(AdvanceOutcome::Completed { snapshot: next }); + } + } + } + } + + Err(Error::PolicyViolation { + message: "max_internal_convergence_steps exhausted".to_owned(), + }) + } + + async fn apply_external_updates( + &self, + snapshot: &mut ExecutionSnapshot, + recorded_at: OffsetDateTime, + signals: S, + order_updates: Vec, + ) -> Result<(), Error> { + for update in order_updates { + let Some(key) = self.resolve_child_key(snapshot, &update.order) else { + warn!( + execution_id = snapshot.execution_id().0.as_str(), + "ignoring unrecognized external order update" + ); + continue; + }; + + let key = key.clone(); + let order = update.order; + self.record( + snapshot, + recorded_at, + JournalEntryKind::ChildObserved { + key: key.clone(), + order: order.clone(), + }, + ) + .await?; + self.record_lifecycle_effects( + snapshot, + recorded_at, + &signals, + AlgorithmLifecycleEvent::ChildObserved { key, order }, + ) + .await; + } + Ok(()) + } + + async fn reconcile_pending_if_due( + &self, + snapshot: &mut ExecutionSnapshot, + recorded_at: OffsetDateTime, + signals: S, + ) -> Result, Error> { + let Some((key, pending_action)) = snapshot + .pending_actions + .iter() + .next() + .map(|(key, action)| (key.clone(), action.clone())) + else { + return Ok(None); + }; + + let elapsed = recorded_at - pending_action.recorded_at(); + let pending_query_after_ms = i128::try_from(self.config.pending_query_after.as_millis()) + .map_err(|_| Error::PolicyViolation { + message: "pending_query_after exceeds supported range".to_owned(), + })?; + if elapsed.whole_milliseconds() < pending_query_after_ms { + return Ok(Some(AdvanceOutcome::Progressed { + snapshot: snapshot.clone(), + next_wake_at: None, + })); + } + + let symbol = snapshot.intent.symbol.clone(); + let query = match &pending_action { + PendingAction::Place { + client_order_id, .. + } + | PendingAction::Cancel { + client_order_id, .. + } => OrderQuery::by_client_order_id(symbol, client_order_id.clone()), + }; + + let order = + self.driver + .query_order(query) + .await + .map_err(|_| Error::PendingActionRecovery { + execution_id: snapshot.execution_id().0.clone(), + })?; + + self.record( + snapshot, + recorded_at, + JournalEntryKind::ChildObserved { + key: key.clone(), + order: order.clone(), + }, + ) + .await?; + self.record_lifecycle_effects( + snapshot, + recorded_at, + &signals, + AlgorithmLifecycleEvent::ChildObserved { key, order }, + ) + .await; + + Ok(Some(if snapshot.is_terminal() { + AdvanceOutcome::Completed { + snapshot: snapshot.clone(), + } + } else { + AdvanceOutcome::Progressed { + snapshot: snapshot.clone(), + next_wake_at: None, + } + })) + } + + async fn reconcile_to_desired_state( + &self, + snapshot: &mut ExecutionSnapshot, + recorded_at: OffsetDateTime, + decision: AlgorithmDecision, + signals: &S, + remaining_external_actions: usize, + ) -> Result { + let desired = decision.desired; + let next_wake_at = decision.next_wake_at; + let desired_children = desired + .children + .into_iter() + .map(|child| (child.key.clone(), child)) + .collect::>(); + + if self.config.replace_mode == ReplaceMode::CancelBeforePlace { + for (key, active) in &snapshot.active_children { + if !desired_children.contains_key(key) { + let client_order_id = + active + .client_order_id() + .ok_or_else(|| Error::InvalidRecovery { + message: format!("active child {} missing client_order_id", key.0), + })?; + if remaining_external_actions == 0 { + return Ok(ReconcileResult::BlockedByExternalBudget( + AdvanceOutcome::Progressed { + snapshot: snapshot.clone(), + next_wake_at, + }, + )); + } + return self + .cancel_child( + snapshot, + recorded_at, + signals, + key.clone(), + client_order_id.clone(), + ) + .await + .map(|outcome| { + ReconcileResult::SideEffect(outcome.with_next_wake_at(next_wake_at)) + }); + } + } + } + + for (key, target) in &desired_children { + if let Some(active) = snapshot.active_children.get(key) + && active.request != target.request + { + return match self.config.replace_mode { + ReplaceMode::CancelBeforePlace => { + let client_order_id = + active + .client_order_id() + .ok_or_else(|| Error::InvalidRecovery { + message: format!( + "active child {} missing client_order_id during replace", + key.0 + ), + })?; + if remaining_external_actions == 0 { + return Ok(ReconcileResult::BlockedByExternalBudget( + AdvanceOutcome::Progressed { + snapshot: snapshot.clone(), + next_wake_at, + }, + )); + } + self.cancel_child( + snapshot, + recorded_at, + signals, + key.clone(), + client_order_id.clone(), + ) + .await + .map(|outcome| { + ReconcileResult::SideEffect(outcome.with_next_wake_at(next_wake_at)) + }) + } + ReplaceMode::PlaceBeforeCancel => Err(Error::PolicyViolation { + message: format!( + "same-key request changes are unsupported in PlaceBeforeCancel mode for child {}", + key.0 + ), + }), + }; + } + } + + for (key, target) in &desired_children { + if !snapshot.active_children.contains_key(key) { + if remaining_external_actions == 0 { + return Ok(ReconcileResult::BlockedByExternalBudget( + AdvanceOutcome::Progressed { + snapshot: snapshot.clone(), + next_wake_at, + }, + )); + } + if self.config.replace_mode == ReplaceMode::PlaceBeforeCancel { + self.validate_place_before_cancel_live_exposure(snapshot, target)?; + } + return self + .place_child(snapshot, recorded_at, signals, key.clone(), target.clone()) + .await + .map(|outcome| { + ReconcileResult::SideEffect(outcome.with_next_wake_at(next_wake_at)) + }); + } + } + + if self.config.replace_mode == ReplaceMode::PlaceBeforeCancel { + for (key, active) in &snapshot.active_children { + if !desired_children.contains_key(key) { + let client_order_id = + active + .client_order_id() + .ok_or_else(|| Error::InvalidRecovery { + message: format!("active child {} missing client_order_id", key.0), + })?; + if remaining_external_actions == 0 { + return Ok(ReconcileResult::BlockedByExternalBudget( + AdvanceOutcome::Progressed { + snapshot: snapshot.clone(), + next_wake_at, + }, + )); + } + return self + .cancel_child( + snapshot, + recorded_at, + signals, + key.clone(), + client_order_id.clone(), + ) + .await + .map(|outcome| { + ReconcileResult::SideEffect(outcome.with_next_wake_at(next_wake_at)) + }); + } + } + } + + if snapshot.active_children.is_empty() { + match desired.mode { + AlgorithmMode::Finishing { terminal_state } => { + self.record( + snapshot, + recorded_at, + JournalEntryKind::Terminal { terminal_state }, + ) + .await?; + return Ok(ReconcileResult::Terminal(AdvanceOutcome::Completed { + snapshot: snapshot.clone(), + })); + } + AlgorithmMode::Paused | AlgorithmMode::Running => {} + } + } + + Ok(ReconcileResult::Idle) + } + + async fn place_child( + &self, + snapshot: &mut ExecutionSnapshot, + recorded_at: OffsetDateTime, + signals: &S, + key: ChildKey, + target: ChildTarget, + ) -> Result { + let mut request = target.request; + let client_order_id = request.client_order_id().cloned().unwrap_or_else(|| { + ClientOrderId::new(format!( + "{}-{}", + snapshot.execution_id().0, + snapshot.next_sequence + )) + }); + request.set_client_order_id(client_order_id.clone()); + + self.record( + snapshot, + recorded_at, + JournalEntryKind::ChildPlacePending { + key: key.clone(), + request: request.clone(), + client_order_id: client_order_id.clone(), + }, + ) + .await?; + + match self.driver.place_order(request.clone()).await { + Ok(order) => { + self.record( + snapshot, + recorded_at, + JournalEntryKind::ChildPlaced { + key: key.clone(), + request: request.clone(), + order: order.clone(), + }, + ) + .await?; + self.record_lifecycle_effects( + snapshot, + recorded_at, + signals, + AlgorithmLifecycleEvent::ChildPlaced { + key, + request, + order, + }, + ) + .await; + Ok(AdvanceOutcome::Progressed { + snapshot: snapshot.clone(), + next_wake_at: None, + }) + } + Err(error) => { + let message = error.to_string(); + self.record( + snapshot, + recorded_at, + JournalEntryKind::ChildPlaceRejected { + key: key.clone(), + request: request.clone(), + client_order_id: client_order_id.clone(), + message: message.clone(), + }, + ) + .await?; + self.record_lifecycle_effects( + snapshot, + recorded_at, + signals, + AlgorithmLifecycleEvent::ChildPlaceRejected { + key, + request, + client_order_id, + message, + }, + ) + .await; + Err(error) + } + } + } + + async fn cancel_child( + &self, + snapshot: &mut ExecutionSnapshot, + recorded_at: OffsetDateTime, + signals: &S, + key: ChildKey, + client_order_id: ClientOrderId, + ) -> Result { + self.record( + snapshot, + recorded_at, + JournalEntryKind::ChildCancelPending { + key: key.clone(), + client_order_id: client_order_id.clone(), + }, + ) + .await?; + + let request = + CancelOrderRequest::by_client_order_id(snapshot.intent.symbol.clone(), client_order_id); + let order = self.driver.cancel_order(request).await?; + self.record( + snapshot, + recorded_at, + JournalEntryKind::ChildCanceled { + key: key.clone(), + order: order.clone(), + }, + ) + .await?; + self.record_lifecycle_effects( + snapshot, + recorded_at, + signals, + AlgorithmLifecycleEvent::ChildCanceled { key, order }, + ) + .await; + Ok(AdvanceOutcome::Progressed { + snapshot: snapshot.clone(), + next_wake_at: None, + }) + } + + fn resolve_child_key(&self, snapshot: &ExecutionSnapshot, order: &Order) -> Option { + let client_order_id = order.client_order_id.as_ref(); + if let Some(client_order_id) = client_order_id { + if let Some((key, _)) = + snapshot + .pending_actions + .iter() + .find(|(_, pending)| match pending { + PendingAction::Place { + client_order_id: pending_id, + .. + } + | PendingAction::Cancel { + client_order_id: pending_id, + .. + } => pending_id == client_order_id, + }) + { + return Some(key.clone()); + } + if let Some((key, _)) = snapshot.active_children.iter().find(|(_, child)| { + child + .client_order_id() + .map(|existing| existing == client_order_id) + .unwrap_or(false) + }) { + return Some(key.clone()); + } + } + + snapshot + .active_children + .iter() + .find(|(_, child)| child.order.id == order.id) + .map(|(key, _)| key.clone()) + } + + pub(super) async fn record( + &self, + snapshot: &mut ExecutionSnapshot, + recorded_at: OffsetDateTime, + kind: JournalEntryKind, + ) -> Result<(), Error> { + let sequence = snapshot.next_sequence; + let entry = JournalEntry { + execution_id: snapshot.intent.execution_id.clone(), + recorded_at, + sequence, + kind, + }; + + self.journal.append(entry.clone()).await?; + apply_entry(snapshot, &entry)?; + Ok(()) + } +} diff --git a/crates/exh/src/engine/decision.rs b/crates/exh/src/engine/decision.rs new file mode 100644 index 0000000..7e80014 --- /dev/null +++ b/crates/exh/src/engine/decision.rs @@ -0,0 +1,173 @@ +use mkt::types::Decimal; +use std::collections::BTreeSet; +use time::OffsetDateTime; + +use crate::algorithm::{Algorithm, AlgorithmDecision, AlgorithmMode, ChildTarget, DesiredState}; +use crate::driver::{Driver, OrderRequest}; +use crate::engine::core::Engine; +use crate::engine::state::{ActiveChild, ExecutionProgress, ExecutionSnapshot}; +use crate::error::Error; +use crate::intent::ExecutionTarget; +use crate::journal::{Journal, JournalEntryKind}; + +impl Engine +where + D: Driver, + J: Journal, + A: Algorithm, + S: Clone + Send + Sync + 'static, +{ + pub(super) fn validate_decision( + &self, + observed_at: OffsetDateTime, + decision: &AlgorithmDecision, + ) -> Result<(), Error> { + if let Some(next_wake_at) = decision.next_wake_at + && next_wake_at < observed_at + { + return Err(Error::PolicyViolation { + message: "algorithm next_wake_at must not be before observed_at".to_owned(), + }); + } + Ok(()) + } + + pub(super) fn validate_desired_state( + &self, + snapshot: &ExecutionSnapshot, + desired: &DesiredState, + ) -> Result<(), Error> { + let mut seen_keys = BTreeSet::new(); + let mut requested_target_value = Decimal::ZERO; + + for child in &desired.children { + if !seen_keys.insert(child.key.clone()) { + return Err(Error::PolicyViolation { + message: format!("duplicate child key {}", child.key.0), + }); + } + let target_value = request_target_value(snapshot.intent.target, &child.request) + .ok_or_else(|| Error::PolicyViolation { + message: format!( + "child {} must expose execution value for target", + child.key.0 + ), + })?; + requested_target_value += target_value; + } + + if snapshot.target_progress_value() + requested_target_value + > snapshot.intent.target.value() + { + return Err(Error::PolicyViolation { + message: "desired child set exceeds execution target".to_owned(), + }); + } + + match desired.mode { + AlgorithmMode::Running => {} + AlgorithmMode::Paused | AlgorithmMode::Finishing { .. } + if !desired.children.is_empty() => + { + return Err(Error::PolicyViolation { + message: "paused/finishing desired state must not request children".to_owned(), + }); + } + AlgorithmMode::Paused | AlgorithmMode::Finishing { .. } => {} + } + + Ok(()) + } + + pub(super) fn validate_place_before_cancel_live_exposure( + &self, + snapshot: &ExecutionSnapshot, + target: &ChildTarget, + ) -> Result<(), Error> { + let new_target_value = request_target_value(snapshot.intent.target, &target.request) + .ok_or_else(|| Error::PolicyViolation { + message: format!( + "child {} must expose execution value for target", + target.key.0 + ), + })?; + let mut possible_target_value = snapshot.target_progress_value() + new_target_value; + for active in snapshot.active_children.values() { + possible_target_value += active_remaining_target_value(snapshot, active)?; + } + + let max_live_target_value = self + .config + .place_before_cancel_live_exposure_limit(snapshot.intent.target.value()); + if possible_target_value > max_live_target_value { + return Err(Error::PolicyViolation { + message: format!( + "place-before-cancel live exposure {} exceeds max live target value {} for child {}", + possible_target_value, max_live_target_value, target.key.0 + ), + }); + } + + Ok(()) + } + + pub(super) async fn record_decision_effects( + &self, + snapshot: &mut ExecutionSnapshot, + recorded_at: OffsetDateTime, + decision: &AlgorithmDecision, + ) -> Result { + let mut recorded = false; + if let Some(patch) = decision.state_patch.clone() { + self.record( + snapshot, + recorded_at, + JournalEntryKind::AlgorithmStatePatched { patch }, + ) + .await?; + recorded = true; + } + + for event in &decision.audit_events { + self.record( + snapshot, + recorded_at, + JournalEntryKind::AlgorithmAuditRecorded { + event: event.clone(), + }, + ) + .await?; + recorded = true; + } + + Ok(recorded) + } +} + +fn request_target_value(target: ExecutionTarget, request: &OrderRequest) -> Option { + match target { + ExecutionTarget::BaseQuantity { .. } => request.execution_base_quantity(), + ExecutionTarget::QuoteBudget { .. } => request.execution_quote_amount(), + } +} + +fn active_remaining_target_value( + snapshot: &ExecutionSnapshot, + active: &ActiveChild, +) -> Result { + let original_target_value = request_target_value(snapshot.intent.target, &active.request) + .ok_or_else(|| Error::InvalidRecovery { + message: format!( + "active child {} missing execution value for target", + active.key.0 + ), + })?; + let filled_progress = + ExecutionProgress::from_order_for_target(&active.order, snapshot.intent.target)?; + let filled_target_value = match snapshot.intent.target { + ExecutionTarget::BaseQuantity { .. } => filled_progress.filled_base_quantity, + ExecutionTarget::QuoteBudget { .. } => filled_progress.cumulative_quote_quantity, + }; + + Ok((original_target_value - filled_target_value).max(Decimal::ZERO)) +} diff --git a/crates/exh/src/engine/lifecycle.rs b/crates/exh/src/engine/lifecycle.rs new file mode 100644 index 0000000..1239505 --- /dev/null +++ b/crates/exh/src/engine/lifecycle.rs @@ -0,0 +1,93 @@ +use time::OffsetDateTime; +use tracing::warn; + +use crate::algorithm::{ + Algorithm, AlgorithmLifecycleEffects, AlgorithmLifecycleEvent, LifecycleContext, +}; +use crate::driver::Driver; +use crate::engine::core::Engine; +use crate::engine::state::ExecutionSnapshot; +use crate::error::Error; +use crate::journal::{Journal, JournalEntryKind}; + +impl Engine +where + D: Driver, + J: Journal, + A: Algorithm, + S: Clone + Send + Sync + 'static, +{ + pub(super) async fn record_lifecycle_effects( + &self, + snapshot: &mut ExecutionSnapshot, + recorded_at: OffsetDateTime, + signals: &S, + event: AlgorithmLifecycleEvent, + ) { + let effects = match self + .algorithm + .on_lifecycle(&LifecycleContext { + intent: snapshot.intent.clone(), + snapshot: snapshot.clone(), + observed_at: recorded_at, + signals: signals.clone(), + event: event.clone(), + }) + .await + { + Ok(effects) => effects, + Err(error) => { + warn!( + execution_id = snapshot.execution_id().0.as_str(), + error = %error, + event = ?event, + "algorithm lifecycle hook failed after confirmed engine event" + ); + return; + } + }; + + if effects.is_empty() { + return; + } + + if let Err(error) = self + .record_lifecycle_journal_effects(snapshot, recorded_at, effects) + .await + { + warn!( + execution_id = snapshot.execution_id().0.as_str(), + error = %error, + event = ?event, + "algorithm lifecycle effects were not fully recorded" + ); + } + } + + async fn record_lifecycle_journal_effects( + &self, + snapshot: &mut ExecutionSnapshot, + recorded_at: OffsetDateTime, + effects: AlgorithmLifecycleEffects, + ) -> Result<(), Error> { + if let Some(patch) = effects.state_patch { + self.record( + snapshot, + recorded_at, + JournalEntryKind::AlgorithmLifecycleStatePatched { patch }, + ) + .await?; + } + + for event in effects.audit_events { + self.record( + snapshot, + recorded_at, + JournalEntryKind::AlgorithmLifecycleAuditRecorded { event }, + ) + .await?; + } + + Ok(()) + } +} diff --git a/crates/exh/src/engine/state.rs b/crates/exh/src/engine/state.rs new file mode 100644 index 0000000..cb5e464 --- /dev/null +++ b/crates/exh/src/engine/state.rs @@ -0,0 +1,337 @@ +use mkt::types::{ClientOrderId, Decimal, Order, OrderStatus}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use time::OffsetDateTime; + +use crate::algorithm::{AlgorithmAuditEvent, AlgorithmStatePatch, AlgorithmStateView, ChildKey}; +use crate::driver::OrderRequest; +use crate::error::Error; +use crate::intent::{ExecutionId, ExecutionIntent, ExecutionTarget}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum TerminalState { + Completed, + Aborted, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ActiveChild { + pub key: ChildKey, + pub request: OrderRequest, + pub order: Order, +} + +impl ActiveChild { + pub fn client_order_id(&self) -> Option<&ClientOrderId> { + self.order + .client_order_id + .as_ref() + .or_else(|| self.request.client_order_id()) + } + + pub fn filled_quantity(&self) -> Decimal { + self.order.filled_quantity + } + + pub fn status(&self) -> OrderStatus { + self.order.status + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum PendingAction { + Place { + key: ChildKey, + request: OrderRequest, + client_order_id: ClientOrderId, + #[serde(with = "time::serde::timestamp::milliseconds")] + recorded_at: OffsetDateTime, + }, + Cancel { + key: ChildKey, + client_order_id: ClientOrderId, + #[serde(with = "time::serde::timestamp::milliseconds")] + recorded_at: OffsetDateTime, + }, +} + +impl PendingAction { + pub fn key(&self) -> &ChildKey { + match self { + Self::Place { key, .. } | Self::Cancel { key, .. } => key, + } + } + + pub fn recorded_at(&self) -> OffsetDateTime { + match self { + Self::Place { recorded_at, .. } | Self::Cancel { recorded_at, .. } => *recorded_at, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ExecutionProgress { + pub filled_base_quantity: Decimal, + pub cumulative_quote_quantity: Decimal, +} + +impl ExecutionProgress { + pub fn zero() -> Self { + Self::default() + } + + pub fn from_order_for_target(order: &Order, target: ExecutionTarget) -> Result { + Ok(Self { + filled_base_quantity: order.filled_quantity, + cumulative_quote_quantity: cumulative_quote_quantity(order, target)?, + }) + } + + fn delta_since(self, previous: Self) -> Option { + if self.filled_base_quantity < previous.filled_base_quantity + || self.cumulative_quote_quantity < previous.cumulative_quote_quantity + { + return None; + } + + Some(Self { + filled_base_quantity: self.filled_base_quantity - previous.filled_base_quantity, + cumulative_quote_quantity: self.cumulative_quote_quantity + - previous.cumulative_quote_quantity, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ExecutionSnapshot { + pub intent: ExecutionIntent, + pub progress: ExecutionProgress, + pub algorithm_state: AlgorithmStateView, + pub algorithm_audit_events: Vec, + pub active_children: BTreeMap, + pub pending_actions: BTreeMap, + pub terminal_state: Option, + #[serde(with = "time::serde::timestamp::milliseconds::option")] + pub started_at: Option, + pub next_sequence: u64, + pub journaled_sequence: u64, + #[serde(with = "time::serde::timestamp::milliseconds")] + pub last_updated_at: OffsetDateTime, +} + +impl ExecutionSnapshot { + pub fn from_intent(intent: ExecutionIntent) -> Self { + Self { + intent, + progress: ExecutionProgress::zero(), + algorithm_state: AlgorithmStateView::new(), + algorithm_audit_events: Vec::new(), + active_children: BTreeMap::new(), + pending_actions: BTreeMap::new(), + terminal_state: None, + started_at: None, + next_sequence: 1, + journaled_sequence: 0, + last_updated_at: OffsetDateTime::UNIX_EPOCH, + } + } + + #[deprecated( + note = "use remaining_base_quantity(), remaining_target_value(), or exh-kit::progress::TargetProgressView" + )] + pub fn remaining_quantity(&self) -> Decimal { + self.remaining_base_quantity().unwrap_or(Decimal::ZERO) + } + + pub fn remaining_base_quantity(&self) -> Option { + match self.intent.target { + ExecutionTarget::BaseQuantity { quantity } => { + Some((quantity - self.progress.filled_base_quantity).max(Decimal::ZERO)) + } + ExecutionTarget::QuoteBudget { .. } => None, + } + } + + pub fn remaining_quote_budget(&self) -> Option { + match self.intent.target { + ExecutionTarget::BaseQuantity { .. } => None, + ExecutionTarget::QuoteBudget { budget } => { + Some((budget - self.progress.cumulative_quote_quantity).max(Decimal::ZERO)) + } + } + } + + pub fn remaining_target_value(&self) -> Decimal { + match self.intent.target { + ExecutionTarget::BaseQuantity { quantity } => { + (quantity - self.progress.filled_base_quantity).max(Decimal::ZERO) + } + ExecutionTarget::QuoteBudget { budget } => { + (budget - self.progress.cumulative_quote_quantity).max(Decimal::ZERO) + } + } + } + + pub fn target_progress_value(&self) -> Decimal { + match self.intent.target { + ExecutionTarget::BaseQuantity { .. } => self.progress.filled_base_quantity, + ExecutionTarget::QuoteBudget { .. } => self.progress.cumulative_quote_quantity, + } + } + + pub fn filled_base_quantity(&self) -> Decimal { + self.progress.filled_base_quantity + } + + pub fn cumulative_quote_quantity(&self) -> Decimal { + self.progress.cumulative_quote_quantity + } + + pub fn execution_id(&self) -> &ExecutionId { + &self.intent.execution_id + } + + pub fn is_terminal(&self) -> bool { + self.terminal_state.is_some() + } + + pub(crate) fn observe_order_for_key( + &mut self, + key: &ChildKey, + order: Order, + ) -> Result<(), Error> { + if self.pending_actions.contains_key(key) || self.active_children.contains_key(key) { + return self.merge_or_attach_child_observation(key, order); + } + + Ok(()) + } + + fn merge_or_attach_child_observation( + &mut self, + key: &ChildKey, + order: Order, + ) -> Result<(), Error> { + if let Some(active) = self.active_children.get_mut(key) { + let previous_progress = + ExecutionProgress::from_order_for_target(&active.order, self.intent.target)?; + let current_progress = + ExecutionProgress::from_order_for_target(&order, self.intent.target)?; + let Some(delta) = current_progress.delta_since(previous_progress) else { + return Ok(()); + }; + + apply_progress_delta( + &mut self.progress, + self.intent.target, + delta, + &self.intent.execution_id, + )?; + active.order = order.clone(); + if terminal_status(order.status) { + self.active_children.remove(key); + self.pending_actions.remove(key); + } + return Ok(()); + } + + if let Some(PendingAction::Place { request, .. }) = self.pending_actions.remove(key) { + let progress = ExecutionProgress::from_order_for_target(&order, self.intent.target)?; + apply_progress_delta( + &mut self.progress, + self.intent.target, + progress, + &self.intent.execution_id, + )?; + if !terminal_status(order.status) { + self.active_children.insert( + key.clone(), + ActiveChild { + key: key.clone(), + request, + order, + }, + ); + } + } + + Ok(()) + } + + pub(crate) fn apply_algorithm_state_patch(&mut self, patch: AlgorithmStatePatch) { + self.algorithm_state.apply_patch(patch); + } + + pub(crate) fn record_algorithm_audit_event(&mut self, event: AlgorithmAuditEvent) { + self.algorithm_audit_events.push(event); + } +} + +pub(crate) fn apply_order_progress( + progress: &mut ExecutionProgress, + target: ExecutionTarget, + order: &Order, + execution_id: &ExecutionId, +) -> Result<(), Error> { + let delta = ExecutionProgress::from_order_for_target(order, target)?; + apply_progress_delta(progress, target, delta, execution_id) +} + +fn apply_progress_delta( + progress: &mut ExecutionProgress, + target: ExecutionTarget, + delta: ExecutionProgress, + execution_id: &ExecutionId, +) -> Result<(), Error> { + let next = ExecutionProgress { + filled_base_quantity: progress.filled_base_quantity + delta.filled_base_quantity, + cumulative_quote_quantity: progress.cumulative_quote_quantity + + delta.cumulative_quote_quantity, + }; + + let next_target_value = match target { + ExecutionTarget::BaseQuantity { .. } => next.filled_base_quantity, + ExecutionTarget::QuoteBudget { .. } => next.cumulative_quote_quantity, + }; + if next_target_value > target.value() { + return Err(Error::InvalidRecovery { + message: format!("order progress would overfill execution {}", execution_id.0), + }); + } + + *progress = next; + Ok(()) +} + +fn cumulative_quote_quantity(order: &Order, target: ExecutionTarget) -> Result { + if let Some(quantity) = order.cumulative_quote_quantity { + return Ok(quantity); + } + if order.filled_quantity <= Decimal::ZERO { + return Ok(Decimal::ZERO); + } + if let Some(price) = order.average_price.or(order.price) { + return Ok(order.filled_quantity * price); + } + if target.is_quote_budget() { + return Err(Error::InvalidRecovery { + message: format!( + "quote-target order {} is missing cumulative quote quantity and realized price", + order.id.0 + ), + }); + } + Ok(Decimal::ZERO) +} + +fn terminal_status(status: OrderStatus) -> bool { + matches!( + status, + OrderStatus::Filled | OrderStatus::Canceled | OrderStatus::Rejected | OrderStatus::Expired + ) +} diff --git a/crates/exh/src/engine/types.rs b/crates/exh/src/engine/types.rs new file mode 100644 index 0000000..09db7ed --- /dev/null +++ b/crates/exh/src/engine/types.rs @@ -0,0 +1,211 @@ +use derive_builder::Builder; +use mkt::types::{Decimal, Order}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use time::OffsetDateTime; + +use crate::engine::state::ExecutionSnapshot; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum ReplaceMode { + /// Cancel stale active children before placing newly desired children. + CancelBeforePlace, + /// Place newly desired distinct-key children before canceling stale active children. + /// + /// This mode is distinct-key only: it may temporarily overlap old and new + /// active children during rebalancing, but every place/cancel still consumes + /// one external action and live exposure remains bounded by + /// `EngineConfig::place_before_cancel_max_live_target_value`. + /// Same-key request changes are rejected because the kernel does not model + /// amend-in-place or two active children with the same key. + PlaceBeforeCancel, +} + +#[derive(Debug, Clone, PartialEq, Eq, Builder)] +#[non_exhaustive] +#[builder(pattern = "owned", setter(into))] +#[builder(build_fn(validate = "Self::validate"))] +pub struct EngineConfig { + #[builder(default = "Duration::from_secs(5)")] + pub pending_query_after: Duration, + #[builder(default = "1")] + pub max_actions_per_advance: usize, + #[builder(default = "4")] + pub max_internal_convergence_steps: usize, + #[builder(default = "ReplaceMode::CancelBeforePlace")] + pub replace_mode: ReplaceMode, + /// Maximum possible parent-target value while placing before canceling. + /// + /// Accounting includes filled target progress, remaining live active-child + /// value, and the new child value being placed. `None` defaults to the + /// parent target value unless `place_before_cancel_live_exposure_multiplier` + /// is set, so temporary overlap must be explicitly enabled. + #[builder(default)] + pub place_before_cancel_max_live_target_value: Option, + /// Multiplier applied to the parent target value for place-before-cancel + /// live exposure. + /// + /// This is a strategy-friendly alternative to + /// `place_before_cancel_max_live_target_value`; configure only one of them. + #[builder(default)] + pub place_before_cancel_live_exposure_multiplier: Option, +} + +impl Default for EngineConfig { + fn default() -> Self { + Self { + pending_query_after: Duration::from_secs(5), + max_actions_per_advance: 1, + max_internal_convergence_steps: 4, + replace_mode: ReplaceMode::CancelBeforePlace, + place_before_cancel_max_live_target_value: None, + place_before_cancel_live_exposure_multiplier: None, + } + } +} + +impl EngineConfig { + pub fn builder() -> EngineConfigBuilder { + EngineConfigBuilder::default() + } + + pub(crate) fn place_before_cancel_live_exposure_limit( + &self, + parent_target_value: Decimal, + ) -> Decimal { + if let Some(max_live_target_value) = self.place_before_cancel_max_live_target_value { + return max_live_target_value; + } + if let Some(multiplier) = self.place_before_cancel_live_exposure_multiplier { + return parent_target_value * multiplier; + } + parent_target_value + } +} + +impl EngineConfigBuilder { + fn validate(&self) -> Result<(), String> { + if matches!(self.max_actions_per_advance, Some(0)) { + return Err("max_actions_per_advance must be greater than zero".to_owned()); + } + if matches!(self.max_internal_convergence_steps, Some(0)) { + return Err("max_internal_convergence_steps must be greater than zero".to_owned()); + } + if let Some(Some(max_live_target_value)) = + self.place_before_cancel_max_live_target_value.as_ref() + && *max_live_target_value <= Decimal::ZERO + { + return Err( + "place_before_cancel_max_live_target_value must be greater than zero".to_owned(), + ); + } + if let Some(Some(multiplier)) = self.place_before_cancel_live_exposure_multiplier.as_ref() + && *multiplier < Decimal::ONE + { + return Err( + "place_before_cancel_live_exposure_multiplier must be at least one".to_owned(), + ); + } + if matches!( + ( + &self.place_before_cancel_max_live_target_value, + &self.place_before_cancel_live_exposure_multiplier, + ), + (Some(Some(_)), Some(Some(_))) + ) { + return Err("configure only one place-before-cancel live exposure limit".to_owned()); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct OrderUpdate { + pub order: Order, +} + +impl OrderUpdate { + pub fn new(order: Order) -> Self { + Self { order } + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct AdvanceInput { + #[allow(clippy::struct_field_names)] + pub observed_at: OffsetDateTime, + pub signals: S, + pub order_updates: Vec, +} + +impl AdvanceInput { + pub fn new(observed_at: OffsetDateTime, signals: S, order_updates: Vec) -> Self { + Self { + observed_at, + signals, + order_updates, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum AdvanceOutcome { + Progressed { + snapshot: ExecutionSnapshot, + #[allow(clippy::struct_field_names)] + next_wake_at: Option, + }, + Quiescent { + snapshot: ExecutionSnapshot, + #[allow(clippy::struct_field_names)] + next_wake_at: Option, + }, + Completed { + snapshot: ExecutionSnapshot, + }, +} + +impl AdvanceOutcome { + pub fn snapshot(&self) -> &ExecutionSnapshot { + match self { + Self::Progressed { snapshot, .. } + | Self::Quiescent { snapshot, .. } + | Self::Completed { snapshot } => snapshot, + } + } + + pub fn next_wake_at(&self) -> Option { + match self { + Self::Progressed { next_wake_at, .. } | Self::Quiescent { next_wake_at, .. } => { + *next_wake_at + } + Self::Completed { .. } => None, + } + } + + pub fn into_snapshot(self) -> ExecutionSnapshot { + match self { + Self::Progressed { snapshot, .. } + | Self::Quiescent { snapshot, .. } + | Self::Completed { snapshot } => snapshot, + } + } + + pub(crate) fn with_next_wake_at(self, next_wake_at: Option) -> Self { + match self { + Self::Progressed { snapshot, .. } => Self::Progressed { + snapshot, + next_wake_at, + }, + Self::Quiescent { snapshot, .. } => Self::Quiescent { + snapshot, + next_wake_at, + }, + Self::Completed { snapshot } => Self::Completed { snapshot }, + } + } +} diff --git a/crates/exh/src/error.rs b/crates/exh/src/error.rs new file mode 100644 index 0000000..da86e5c --- /dev/null +++ b/crates/exh/src/error.rs @@ -0,0 +1,26 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Error { + #[error("journal append failed: {message}")] + JournalAppend { message: String }, + #[error("journal load failed: {message}")] + JournalLoad { message: String }, + #[error("driver place order failed: {message}")] + DriverPlace { message: String }, + #[error("driver cancel order failed: {message}")] + DriverCancel { message: String }, + #[error("driver query order failed: {message}")] + DriverQuery { message: String }, + #[error("algorithm rejected desired state: {message}")] + PolicyViolation { message: String }, + #[error("invalid recovery event stream: {message}")] + InvalidRecovery { message: String }, + #[error("journal conflict: {message}")] + JournalConflict { message: String }, + #[error("execution already terminal: {execution_id}")] + AlreadyTerminal { execution_id: String }, + #[error("recovery requires pending action reconciliation: {execution_id}")] + PendingActionRecovery { execution_id: String }, +} diff --git a/crates/exh/src/intent.rs b/crates/exh/src/intent.rs new file mode 100644 index 0000000..2556f96 --- /dev/null +++ b/crates/exh/src/intent.rs @@ -0,0 +1,103 @@ +use derive_builder::Builder; +use mkt::types::{Decimal, MarketKind, OrderSide, Symbol}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ExecutionId(pub String); + +impl ExecutionId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum RetryDirective { + RetryNow, + WaitForPolicy, + Abort, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum ExecutionTarget { + BaseQuantity { quantity: Decimal }, + QuoteBudget { budget: Decimal }, +} + +impl ExecutionTarget { + pub fn base_quantity(quantity: Decimal) -> Self { + Self::BaseQuantity { quantity } + } + + pub fn quote_budget(budget: Decimal) -> Self { + Self::QuoteBudget { budget } + } + + pub fn value(self) -> Decimal { + match self { + Self::BaseQuantity { quantity } => quantity, + Self::QuoteBudget { budget } => budget, + } + } + + pub fn is_base_quantity(self) -> bool { + matches!(self, Self::BaseQuantity { .. }) + } + + pub fn is_quote_budget(self) -> bool { + matches!(self, Self::QuoteBudget { .. }) + } +} + +#[derive(Debug, Clone, PartialEq, Builder, Serialize, Deserialize)] +#[non_exhaustive] +#[builder(pattern = "owned", setter(into))] +#[builder(build_fn(validate = "Self::validate"))] +pub struct ExecutionIntent { + pub execution_id: ExecutionId, + pub symbol: Symbol, + pub market_kind: MarketKind, + pub side: OrderSide, + #[builder(setter(custom))] + pub target: ExecutionTarget, + #[builder(default)] + pub limit_price_cap: Option, + #[builder(default = "RetryDirective::WaitForPolicy")] + pub retry_directive: RetryDirective, + #[builder(default)] + pub strategy_tag: Option, +} + +impl ExecutionIntent { + pub fn builder() -> ExecutionIntentBuilder { + ExecutionIntentBuilder::default() + } +} + +impl ExecutionIntentBuilder { + pub fn target(mut self, target: ExecutionTarget) -> Self { + self.target = Some(target); + self + } + + pub fn target_quantity(self, quantity: Decimal) -> Self { + self.target(ExecutionTarget::base_quantity(quantity)) + } + + pub fn quote_budget(self, budget: Decimal) -> Self { + self.target(ExecutionTarget::quote_budget(budget)) + } + + fn validate(&self) -> Result<(), String> { + let Some(target) = self.target else { + return Err("target is required".to_owned()); + }; + if target.value() <= Decimal::ZERO { + return Err("target value must be greater than zero".to_owned()); + } + Ok(()) + } +} diff --git a/crates/exh/src/journal.rs b/crates/exh/src/journal.rs new file mode 100644 index 0000000..a6604c4 --- /dev/null +++ b/crates/exh/src/journal.rs @@ -0,0 +1,425 @@ +use async_trait::async_trait; +use mkt::types::{ClientOrderId, Order, OrderStatus}; +use rusqlite::{Connection, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Mutex; +use std::time::Duration; +use time::OffsetDateTime; + +use crate::algorithm::{AlgorithmAuditEvent, AlgorithmStatePatch, ChildKey}; +use crate::driver::OrderRequest; +use crate::engine::state::apply_order_progress; +use crate::engine::{ActiveChild, ExecutionSnapshot, PendingAction, TerminalState}; +use crate::error::Error; +use crate::intent::{ExecutionId, ExecutionIntent}; + +const JOURNAL_SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS journal_entries ( + execution_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + recorded_at_ms INTEGER NOT NULL, + payload_json TEXT NOT NULL, + PRIMARY KEY (execution_id, sequence) +); +"#; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum JournalEntryKind { + ExecutionCreated { + intent: ExecutionIntent, + }, + TickObserved, + ChildPlacePending { + key: ChildKey, + request: OrderRequest, + client_order_id: ClientOrderId, + }, + ChildPlaced { + key: ChildKey, + request: OrderRequest, + order: Order, + }, + ChildPlaceRejected { + key: ChildKey, + request: OrderRequest, + client_order_id: ClientOrderId, + message: String, + }, + ChildCancelPending { + key: ChildKey, + client_order_id: ClientOrderId, + }, + ChildCanceled { + key: ChildKey, + order: Order, + }, + ChildObserved { + key: ChildKey, + order: Order, + }, + AlgorithmStatePatched { + patch: AlgorithmStatePatch, + }, + AlgorithmAuditRecorded { + event: AlgorithmAuditEvent, + }, + AlgorithmLifecycleStatePatched { + patch: AlgorithmStatePatch, + }, + AlgorithmLifecycleAuditRecorded { + event: AlgorithmAuditEvent, + }, + Terminal { + terminal_state: TerminalState, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct JournalEntry { + pub execution_id: ExecutionId, + #[serde(with = "time::serde::timestamp::milliseconds")] + pub recorded_at: OffsetDateTime, + pub sequence: u64, + pub kind: JournalEntryKind, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct StoredExecution { + pub intent: ExecutionIntent, + pub entries: Vec, +} + +impl StoredExecution { + pub fn new(intent: ExecutionIntent, entries: Vec) -> Self { + Self { intent, entries } + } +} + +pub trait StoredJournal { + fn reconstruct(&self) -> Result; +} + +impl StoredJournal for StoredExecution { + fn reconstruct(&self) -> Result { + let mut snapshot = ExecutionSnapshot::from_intent(self.intent.clone()); + for entry in &self.entries { + apply_entry(&mut snapshot, entry)?; + } + Ok(snapshot) + } +} + +#[async_trait] +pub trait Journal: Send + Sync { + async fn append(&self, entry: JournalEntry) -> Result<(), Error>; + async fn load(&self, execution_id: &ExecutionId) -> Result, Error>; +} + +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct MemoryJournal { + entries: Mutex>, +} + +impl MemoryJournal { + pub fn entries(&self) -> Vec { + self.entries + .lock() + .expect("memory journal mutex poisoned; runtime state is inconsistent") + .clone() + } +} + +#[async_trait] +impl Journal for MemoryJournal { + async fn append(&self, entry: JournalEntry) -> Result<(), Error> { + let mut entries = self.entries.lock().map_err(|_| Error::JournalAppend { + message: "memory journal mutex poisoned".to_owned(), + })?; + + if entries.iter().any(|existing| { + existing.execution_id == entry.execution_id && existing.sequence == entry.sequence + }) { + return Err(Error::JournalConflict { + message: format!( + "duplicate sequence {} for execution {}", + entry.sequence, entry.execution_id.0 + ), + }); + } + + entries.push(entry); + Ok(()) + } + + async fn load(&self, execution_id: &ExecutionId) -> Result, Error> { + let entries = self.entries.lock().map_err(|_| Error::JournalLoad { + message: "memory journal mutex poisoned".to_owned(), + })?; + stored_execution_from_entries( + entries + .iter() + .filter(|entry| entry.execution_id == *execution_id) + .cloned() + .collect(), + execution_id, + ) + } +} + +#[derive(Debug)] +#[non_exhaustive] +pub struct SqliteJournal { + connection: Mutex, +} + +impl SqliteJournal { + pub fn open(path: impl AsRef) -> Result { + let connection = Connection::open(path).map_err(|error| Error::JournalLoad { + message: format!("open sqlite journal: {error}"), + })?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(|error| Error::JournalLoad { + message: format!("configure sqlite busy timeout: {error}"), + })?; + connection + .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get::<_, String>(0)) + .map_err(|error| Error::JournalLoad { + message: format!("enable sqlite WAL mode: {error}"), + })?; + connection + .pragma_update(None, "synchronous", "FULL") + .map_err(|error| Error::JournalLoad { + message: format!("configure sqlite synchronous mode: {error}"), + })?; + connection + .execute_batch(JOURNAL_SCHEMA) + .map_err(|error| Error::JournalLoad { + message: format!("initialize sqlite journal schema: {error}"), + })?; + + Ok(Self { + connection: Mutex::new(connection), + }) + } +} + +#[async_trait] +impl Journal for SqliteJournal { + async fn append(&self, entry: JournalEntry) -> Result<(), Error> { + let payload = serde_json::to_string(&entry).map_err(|error| Error::JournalAppend { + message: format!("serialize journal entry: {error}"), + })?; + let recorded_at_ms = i64::try_from(entry.recorded_at.unix_timestamp_nanos() / 1_000_000) + .map_err(|_| Error::JournalAppend { + message: format!( + "journal timestamp out of millisecond range for execution {}", + entry.execution_id.0 + ), + })?; + let sequence = i64::try_from(entry.sequence).map_err(|_| Error::JournalAppend { + message: format!( + "journal sequence {} is out of i64 range for execution {}", + entry.sequence, entry.execution_id.0 + ), + })?; + + let mut connection = self.connection.lock().map_err(|_| Error::JournalAppend { + message: "sqlite journal mutex poisoned".to_owned(), + })?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| Error::JournalAppend { + message: format!("begin sqlite append transaction: {error}"), + })?; + let affected_rows = tx + .execute( + "INSERT INTO journal_entries (execution_id, sequence, recorded_at_ms, payload_json) + VALUES (?1, ?2, ?3, ?4)", + params![entry.execution_id.0, sequence, recorded_at_ms, payload], + ) + .map_err(|error| Error::JournalAppend { + message: format!("insert sqlite journal entry: {error}"), + })?; + if affected_rows != 1 { + return Err(Error::JournalAppend { + message: "sqlite append affected unexpected row count".to_owned(), + }); + } + tx.commit().map_err(|error| Error::JournalAppend { + message: format!("commit sqlite append transaction: {error}"), + })?; + Ok(()) + } + + async fn load(&self, execution_id: &ExecutionId) -> Result, Error> { + let connection = self.connection.lock().map_err(|_| Error::JournalLoad { + message: "sqlite journal mutex poisoned".to_owned(), + })?; + let mut statement = connection + .prepare( + "SELECT payload_json + FROM journal_entries + WHERE execution_id = ?1 + ORDER BY sequence ASC", + ) + .map_err(|error| Error::JournalLoad { + message: format!("prepare sqlite journal replay query: {error}"), + })?; + let rows = statement + .query_map(params![execution_id.0.clone()], |row| { + row.get::<_, String>(0) + }) + .map_err(|error| Error::JournalLoad { + message: format!("query sqlite journal entries: {error}"), + })?; + + let mut entries = Vec::new(); + for row in rows { + let payload = row.map_err(|error| Error::JournalLoad { + message: format!("read sqlite journal entry row: {error}"), + })?; + let entry = serde_json::from_str::(&payload).map_err(|error| { + Error::JournalLoad { + message: format!("deserialize sqlite journal entry: {error}"), + } + })?; + entries.push(entry); + } + + stored_execution_from_entries(entries, execution_id) + } +} + +fn stored_execution_from_entries( + mut entries: Vec, + execution_id: &ExecutionId, +) -> Result, Error> { + if entries.is_empty() { + return Ok(None); + } + + entries.sort_by_key(|entry| entry.sequence); + let intent = entries + .iter() + .find_map(|entry| match &entry.kind { + JournalEntryKind::ExecutionCreated { intent } => Some(intent.clone()), + _ => None, + }) + .ok_or_else(|| Error::InvalidRecovery { + message: format!("missing ExecutionCreated entry for {}", execution_id.0), + })?; + Ok(Some(StoredExecution { intent, entries })) +} + +pub(crate) fn apply_entry( + snapshot: &mut ExecutionSnapshot, + entry: &JournalEntry, +) -> Result<(), Error> { + snapshot.last_updated_at = entry.recorded_at; + snapshot.journaled_sequence = entry.sequence; + snapshot.next_sequence = snapshot.next_sequence.max(entry.sequence + 1); + + match &entry.kind { + JournalEntryKind::ExecutionCreated { intent } => { + snapshot.intent = intent.clone(); + if snapshot.started_at.is_none() { + snapshot.started_at = Some(entry.recorded_at); + } + } + JournalEntryKind::TickObserved => {} + JournalEntryKind::ChildPlacePending { + key, + request, + client_order_id, + } => { + snapshot.pending_actions.insert( + key.clone(), + PendingAction::Place { + key: key.clone(), + request: request.clone(), + client_order_id: client_order_id.clone(), + recorded_at: entry.recorded_at, + }, + ); + } + JournalEntryKind::ChildPlaced { + key, + request, + order, + } => { + snapshot.pending_actions.remove(key); + apply_order_progress( + &mut snapshot.progress, + snapshot.intent.target, + order, + &snapshot.intent.execution_id, + )?; + + if matches!( + order.status, + OrderStatus::New | OrderStatus::PartiallyFilled + ) { + snapshot.active_children.insert( + key.clone(), + ActiveChild { + key: key.clone(), + request: request.clone(), + order: order.clone(), + }, + ); + } else { + snapshot.active_children.remove(key); + } + } + JournalEntryKind::ChildPlaceRejected { key, .. } => { + snapshot.pending_actions.remove(key); + } + JournalEntryKind::ChildCancelPending { + key, + client_order_id, + } => { + snapshot.pending_actions.insert( + key.clone(), + PendingAction::Cancel { + key: key.clone(), + client_order_id: client_order_id.clone(), + recorded_at: entry.recorded_at, + }, + ); + } + JournalEntryKind::ChildCanceled { key, .. } => { + snapshot.pending_actions.remove(key); + snapshot.active_children.remove(key); + } + JournalEntryKind::ChildObserved { key, order } => { + snapshot.observe_order_for_key(key, order.clone())?; + snapshot.pending_actions.remove(key); + } + JournalEntryKind::AlgorithmStatePatched { patch } => { + snapshot.apply_algorithm_state_patch(patch.clone()); + } + JournalEntryKind::AlgorithmAuditRecorded { event } => { + snapshot.record_algorithm_audit_event(event.clone()); + } + JournalEntryKind::AlgorithmLifecycleStatePatched { patch } => { + snapshot.apply_algorithm_state_patch(patch.clone()); + } + JournalEntryKind::AlgorithmLifecycleAuditRecorded { event } => { + snapshot.record_algorithm_audit_event(event.clone()); + } + JournalEntryKind::Terminal { terminal_state } => { + snapshot.terminal_state = Some(*terminal_state); + snapshot.active_children = BTreeMap::new(); + snapshot.pending_actions = BTreeMap::new(); + } + } + + Ok(()) +} diff --git a/crates/exh/src/lib.rs b/crates/exh/src/lib.rs new file mode 100644 index 0000000..4e55ecb --- /dev/null +++ b/crates/exh/src/lib.rs @@ -0,0 +1,27 @@ +//! Reliable execution kernel built on top of `mkt` trading primitives. + +pub mod algorithm; +pub mod driver; +pub mod engine; +pub mod error; +pub mod intent; +pub mod journal; + +pub use algorithm::{ + Algorithm, AlgorithmAuditEvent, AlgorithmDecision, AlgorithmLifecycleEffects, + AlgorithmLifecycleEvent, AlgorithmMode, AlgorithmStatePatch, AlgorithmStateView, ChildKey, + ChildTarget, DesiredState, EvaluateContext, LifecycleContext, +}; +pub use driver::{CancelOrderRequest, Driver, OrderQuery, OrderRequest}; +pub use engine::{ + ActiveChild, AdvanceInput, AdvanceOutcome, Engine, EngineConfig, EngineConfigBuilder, + ExecutionProgress, ExecutionSnapshot, OrderUpdate, PendingAction, ReplaceMode, TerminalState, +}; +pub use error::Error; +pub use intent::{ + ExecutionId, ExecutionIntent, ExecutionIntentBuilder, ExecutionTarget, RetryDirective, +}; +pub use journal::{ + Journal, JournalEntry, JournalEntryKind, MemoryJournal, SqliteJournal, StoredExecution, + StoredJournal, +}; diff --git a/crates/exh/tests/engine.rs b/crates/exh/tests/engine.rs new file mode 100644 index 0000000..b80c9c1 --- /dev/null +++ b/crates/exh/tests/engine.rs @@ -0,0 +1,550 @@ +mod support; + +use exh::{ + AdvanceInput, AdvanceOutcome, AlgorithmMode, ChildKey, Engine, ExecutionId, ExecutionIntent, + MemoryJournal, TerminalState, +}; +use mkt::types::{Decimal, MarketKind, OrderSide, OrderStatus, Symbol}; +use support::{ + MetadataAlgo, OversizedQuoteBudgetAlgo, QuoteBudgetAlgo, SingleChildAlgo, TestVenue, + fill_order, observed_order, quote_fill_order, signal_frame, spot_buy_intent, +}; +use time::{Duration, OffsetDateTime}; + +#[tokio::test] +async fn engine_places_and_observes_fill_from_external_update() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = MemoryJournal::default(); + let algorithm = SingleChildAlgo { + mode: AlgorithmMode::Running, + next_wake_at: None, + }; + let engine = Engine::new(venue, journal, algorithm); + + let intent = spot_buy_intent("exec-1", &symbol, Decimal::new(2, 0)); + let mut snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .unwrap(); + snapshot = outcome.into_snapshot(); + assert!( + snapshot + .active_children + .contains_key(&ChildKey::new("primary")) + ); + + let fill = fill_order(&symbol, "child-primary", Decimal::new(2, 0)); + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(2), + signal_frame(&symbol, 100), + vec![exh::OrderUpdate::new(fill)], + ), + ) + .await + .unwrap(); + snapshot = outcome.into_snapshot(); + assert_eq!(snapshot.filled_base_quantity(), Decimal::new(2, 0)); +} + +#[tokio::test] +async fn engine_returns_next_wake_at_on_quiescent_decision() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = MemoryJournal::default(); + let wake_at = OffsetDateTime::UNIX_EPOCH + Duration::seconds(10); + let algorithm = SingleChildAlgo { + mode: AlgorithmMode::Paused, + next_wake_at: Some(wake_at), + }; + let engine = Engine::new(venue, journal, algorithm); + + let intent = spot_buy_intent("exec-wake-1", &symbol, Decimal::new(2, 0)); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .unwrap(); + + assert_eq!(outcome.next_wake_at(), Some(wake_at)); + assert!(matches!(outcome, AdvanceOutcome::Quiescent { .. })); +} + +#[tokio::test] +async fn engine_returns_next_wake_at_on_progressed_decision() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = MemoryJournal::default(); + let wake_at = OffsetDateTime::UNIX_EPOCH + Duration::seconds(10); + let algorithm = SingleChildAlgo { + mode: AlgorithmMode::Running, + next_wake_at: Some(wake_at), + }; + let engine = Engine::new(venue, journal, algorithm); + + let intent = spot_buy_intent("exec-wake-2", &symbol, Decimal::new(2, 0)); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .unwrap(); + + assert_eq!(outcome.next_wake_at(), Some(wake_at)); + assert!(matches!(outcome, AdvanceOutcome::Progressed { .. })); +} + +#[tokio::test] +async fn engine_rejects_algorithm_decision_with_past_next_wake_at() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = MemoryJournal::default(); + let algorithm = SingleChildAlgo { + mode: AlgorithmMode::Paused, + next_wake_at: Some(OffsetDateTime::UNIX_EPOCH), + }; + let engine = Engine::new(venue, journal, algorithm); + + let intent = spot_buy_intent("exec-wake-3", &symbol, Decimal::new(2, 0)); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let error = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .expect_err("past next_wake_at must be rejected"); + + assert!(error.to_string().contains("next_wake_at")); +} + +#[tokio::test] +async fn engine_pending_place_recovers_via_query() { + let symbol = Symbol::spot("ETHUSDT"); + let venue = TestVenue::default(); + let journal = MemoryJournal::default(); + let algorithm = SingleChildAlgo { + mode: AlgorithmMode::Running, + next_wake_at: None, + }; + let engine = Engine::with_config( + venue.clone(), + journal, + algorithm, + exh::EngineConfig::builder() + .pending_query_after(std::time::Duration::ZERO) + .max_actions_per_advance(1usize) + .replace_mode(exh::ReplaceMode::CancelBeforePlace) + .build() + .expect("engine config must build"), + ); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("exec-2")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target_quantity(Decimal::new(1, 0)) + .build() + .expect("intent must build"); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + let placed = fill_order(&symbol, "child-primary", Decimal::ZERO); + venue.seed_query_result(placed); + + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .unwrap(); + + let snapshot = outcome.into_snapshot(); + assert!( + snapshot + .active_children + .contains_key(&ChildKey::new("primary")) + ); +} + +#[tokio::test] +async fn engine_accepts_terminal_place_response_without_external_follow_up() { + let symbol = Symbol::spot("SOLUSDT"); + let venue = TestVenue::default(); + let journal = MemoryJournal::default(); + let algorithm = SingleChildAlgo { + mode: AlgorithmMode::Running, + next_wake_at: None, + }; + let engine = Engine::new(venue.clone(), journal, algorithm); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("exec-3")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target_quantity(Decimal::new(2, 0)) + .build() + .expect("intent must build"); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + venue.seed_place_result(observed_order( + &symbol, + "child-primary", + Decimal::new(2, 0), + Decimal::new(1, 0), + OrderStatus::Expired, + )); + + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .unwrap(); + + let snapshot = outcome.into_snapshot(); + + assert_eq!(snapshot.filled_base_quantity(), Decimal::new(1, 0)); + assert!(snapshot.active_children.is_empty()); + assert!(snapshot.pending_actions.is_empty()); +} + +#[tokio::test] +async fn engine_completes_full_terminal_place_response_in_same_advance() { + let symbol = Symbol::spot("SOLUSDT"); + let venue = TestVenue::default(); + let journal = MemoryJournal::default(); + let algorithm = SingleChildAlgo { + mode: AlgorithmMode::Running, + next_wake_at: None, + }; + let engine = Engine::new(venue.clone(), journal, algorithm); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("exec-4")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target_quantity(Decimal::new(2, 0)) + .build() + .expect("intent must build"); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + venue.seed_place_result(observed_order( + &symbol, + "child-primary", + Decimal::new(2, 0), + Decimal::new(2, 0), + OrderStatus::Filled, + )); + + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .unwrap(); + + assert!(matches!(outcome, AdvanceOutcome::Completed { .. })); + let snapshot = outcome.into_snapshot(); + assert_eq!(snapshot.filled_base_quantity(), Decimal::new(2, 0)); + assert_eq!(snapshot.terminal_state, Some(TerminalState::Completed)); +} + +#[tokio::test] +async fn engine_persists_algorithm_state_patch_and_audit_event() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = MemoryJournal::default(); + let engine = Engine::new(venue, journal, MetadataAlgo); + + let execution_id = ExecutionId::new("exec-metadata-1"); + let intent = ExecutionIntent::builder() + .execution_id(execution_id.clone()) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target_quantity(Decimal::new(2, 0)) + .build() + .expect("intent must build"); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .unwrap(); + + assert!(matches!(outcome, AdvanceOutcome::Progressed { .. })); + let snapshot = outcome.into_snapshot(); + let patch = snapshot + .algorithm_state + .get("test.slice_state") + .expect("state patch should be available"); + assert_eq!(patch.version, 1); + assert_eq!(patch.payload["slice_index"], serde_json::json!(3)); + assert_eq!(snapshot.algorithm_audit_events.len(), 1); + assert_eq!( + snapshot.algorithm_audit_events[0].event_type, + "test.slice_report" + ); + + let recovered = engine + .recover(&execution_id) + .await + .expect("recover should succeed") + .expect("execution should exist"); + assert_eq!( + recovered + .algorithm_state + .get("test.slice_state") + .expect("recovered state patch should be available") + .payload["request_count"], + serde_json::json!(7) + ); + assert_eq!(recovered.algorithm_audit_events.len(), 1); +} + +#[tokio::test] +async fn engine_tracks_quote_budget_progress_from_terminal_place_response() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = MemoryJournal::default(); + let engine = Engine::new( + venue.clone(), + journal, + QuoteBudgetAlgo { + quote_amount: Decimal::new(100, 0), + }, + ); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("exec-quote-1")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .quote_budget(Decimal::new(100, 0)) + .build() + .expect("intent must build"); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + venue.seed_place_result(quote_fill_order( + &symbol, + "quote-child", + Decimal::new(1, 0), + Decimal::new(4, 1), + Decimal::new(40, 0), + OrderStatus::Expired, + )); + + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .unwrap(); + + let snapshot = outcome.into_snapshot(); + + assert_eq!(snapshot.filled_base_quantity(), Decimal::new(4, 1)); + assert_eq!(snapshot.cumulative_quote_quantity(), Decimal::new(40, 0)); + assert_eq!(snapshot.remaining_quote_budget(), Some(Decimal::new(60, 0))); + assert!(snapshot.active_children.is_empty()); +} + +#[tokio::test] +async fn engine_tracks_quote_budget_progress_from_external_update() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = MemoryJournal::default(); + let engine = Engine::new( + venue, + journal, + QuoteBudgetAlgo { + quote_amount: Decimal::new(100, 0), + }, + ); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("exec-quote-2")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .quote_budget(Decimal::new(100, 0)) + .build() + .expect("intent must build"); + let mut snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + snapshot = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .unwrap() + .into_snapshot(); + assert!( + snapshot + .active_children + .contains_key(&ChildKey::new("quote")) + ); + + let outcome = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(2), + signal_frame(&symbol, 100), + vec![exh::OrderUpdate::new(quote_fill_order( + &symbol, + "quote-child", + Decimal::new(1, 0), + Decimal::new(1, 0), + Decimal::new(100, 0), + OrderStatus::Filled, + ))], + ), + ) + .await + .unwrap(); + + let snapshot = outcome.into_snapshot(); + + assert_eq!(snapshot.filled_base_quantity(), Decimal::new(1, 0)); + assert_eq!(snapshot.cumulative_quote_quantity(), Decimal::new(100, 0)); + assert_eq!(snapshot.remaining_quote_budget(), Some(Decimal::ZERO)); + assert_eq!(snapshot.terminal_state, Some(TerminalState::Completed)); +} + +#[tokio::test] +async fn engine_rejects_quote_budget_children_that_exceed_remaining_budget() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = MemoryJournal::default(); + let engine = Engine::new( + venue, + journal, + OversizedQuoteBudgetAlgo { + quote_amount: Decimal::new(101, 0), + }, + ); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("exec-quote-3")) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .quote_budget(Decimal::new(100, 0)) + .build() + .expect("intent must build"); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let error = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .expect_err("oversized quote child must be rejected"); + + assert!(error.to_string().contains("exceeds execution target")); +} diff --git a/crates/exh/tests/support/lifecycle.rs b/crates/exh/tests/support/lifecycle.rs new file mode 100644 index 0000000..2f5410d --- /dev/null +++ b/crates/exh/tests/support/lifecycle.rs @@ -0,0 +1,558 @@ +use async_trait::async_trait; +use exh::{ + AdvanceInput, Algorithm, AlgorithmAuditEvent, AlgorithmDecision, AlgorithmLifecycleEffects, + AlgorithmLifecycleEvent, ChildKey, DesiredState, Engine, EvaluateContext, Journal, + JournalEntry, JournalEntryKind, MemoryJournal, ReplaceMode, StoredExecution, TerminalState, +}; +use mkt::types::{Decimal, Symbol}; +use time::{Duration, OffsetDateTime}; + +use super::{ + DistinctKeyReplaceAlgo, LifecycleMetadataAlgo, SharedJournal, TestSignals, TestVenue, + TestVenueAction, fill_order, primary_child, signal_frame, spot_buy_intent, +}; + +#[tokio::test] +async fn engine_records_placed_and_observed_lifecycle_metadata_after_confirmed_events() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = SharedJournal::default(); + let engine = Engine::new(venue, journal.clone(), LifecycleMetadataAlgo); + let intent = spot_buy_intent( + "exec-lifecycle-placed-observed", + &symbol, + Decimal::new(2, 0), + ); + let execution_id = intent.execution_id.clone(); + let mut snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + snapshot = engine + .advance(&snapshot, input_at(&symbol, 1, 100, Vec::new())) + .await + .unwrap() + .into_snapshot(); + assert_lifecycle_after(&journal.entries(), |entry| { + matches!(entry.kind, JournalEntryKind::ChildPlaced { .. }) + }); + + snapshot = engine + .advance( + &snapshot, + input_at( + &symbol, + 2, + 100, + vec![exh::OrderUpdate::new(fill_order( + &symbol, + "child-primary", + Decimal::new(2, 0), + ))], + ), + ) + .await + .unwrap() + .into_snapshot(); + assert_eq!(snapshot.terminal_state, Some(TerminalState::Completed)); + assert_lifecycle_after(&journal.entries(), |entry| { + matches!(entry.kind, JournalEntryKind::ChildObserved { .. }) + }); + + assert_replayed_lifecycle(&engine, &execution_id, "observed").await; +} + +#[tokio::test] +async fn engine_records_rejected_place_lifecycle_metadata_after_rejection() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + venue.seed_place_error("child-primary", "venue rejected child"); + let journal = SharedJournal::default(); + let engine = Engine::new(venue, journal.clone(), LifecycleMetadataAlgo); + let intent = spot_buy_intent("exec-lifecycle-rejected", &symbol, Decimal::new(1, 0)); + let execution_id = intent.execution_id.clone(); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let error = engine + .advance(&snapshot, input_at(&symbol, 1, 100, Vec::new())) + .await + .expect_err("place rejection should surface driver error"); + assert!(error.to_string().contains("venue rejected child")); + assert_lifecycle_after(&journal.entries(), |entry| { + matches!(entry.kind, JournalEntryKind::ChildPlaceRejected { .. }) + }); + + assert_replayed_lifecycle(&engine, &execution_id, "place_rejected").await; +} + +#[tokio::test] +async fn engine_records_canceled_child_lifecycle_metadata_after_cancel() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = SharedJournal::default(); + let engine = Engine::new(venue, journal.clone(), CancelAfterPlaceAlgo); + let intent = spot_buy_intent("exec-lifecycle-canceled", &symbol, Decimal::new(1, 0)); + let execution_id = intent.execution_id.clone(); + let mut snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + snapshot = engine + .advance(&snapshot, input_at(&symbol, 1, 100, Vec::new())) + .await + .unwrap() + .into_snapshot(); + assert!( + snapshot + .active_children + .contains_key(&ChildKey::new("primary")) + ); + + engine + .advance(&snapshot, input_at(&symbol, 2, 100, Vec::new())) + .await + .unwrap(); + assert_lifecycle_after(&journal.entries(), |entry| { + matches!(entry.kind, JournalEntryKind::ChildCanceled { .. }) + }); + + assert_replayed_lifecycle(&engine, &execution_id, "canceled").await; +} + +#[tokio::test] +async fn lifecycle_hook_failure_does_not_mask_confirmed_place() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = SharedJournal::default(); + let engine = Engine::new(venue, journal.clone(), FailingLifecycleAlgo); + let intent = spot_buy_intent( + "exec-lifecycle-hook-fails-place", + &symbol, + Decimal::new(1, 0), + ); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let outcome = engine + .advance(&snapshot, input_at(&symbol, 1, 100, Vec::new())) + .await + .expect("confirmed place must not be masked by lifecycle failure"); + let snapshot = outcome.into_snapshot(); + + assert!( + snapshot + .active_children + .contains_key(&ChildKey::new("primary")) + ); + assert!( + journal + .entries() + .iter() + .any(|entry| { matches!(entry.kind, JournalEntryKind::ChildPlaced { .. }) }) + ); + assert!(!has_lifecycle_entries(&journal.entries())); +} + +#[tokio::test] +async fn lifecycle_hook_failure_does_not_mask_place_rejection() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + venue.seed_place_error("child-primary", "venue rejected child"); + let journal = SharedJournal::default(); + let engine = Engine::new(venue, journal.clone(), FailingLifecycleAlgo); + let intent = spot_buy_intent( + "exec-lifecycle-hook-fails-rejection", + &symbol, + Decimal::new(1, 0), + ); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let error = engine + .advance(&snapshot, input_at(&symbol, 1, 100, Vec::new())) + .await + .expect_err("place rejection should still surface driver error"); + + assert!(error.to_string().contains("venue rejected child")); + assert!( + journal + .entries() + .iter() + .any(|entry| { matches!(entry.kind, JournalEntryKind::ChildPlaceRejected { .. }) }) + ); + assert!(!has_lifecycle_entries(&journal.entries())); +} + +#[tokio::test] +async fn lifecycle_journal_failure_does_not_mask_confirmed_place() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = RejectLifecycleJournal::default(); + let engine = Engine::new(venue, journal.clone(), LifecycleMetadataAlgo); + let intent = spot_buy_intent( + "exec-lifecycle-journal-fails-place", + &symbol, + Decimal::new(1, 0), + ); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let outcome = engine + .advance(&snapshot, input_at(&symbol, 1, 100, Vec::new())) + .await + .expect("confirmed place must not be masked by lifecycle journal failure"); + let snapshot = outcome.into_snapshot(); + + assert!( + snapshot + .active_children + .contains_key(&ChildKey::new("primary")) + ); + assert!( + journal + .entries() + .iter() + .any(|entry| { matches!(entry.kind, JournalEntryKind::ChildPlaced { .. }) }) + ); + assert!(!has_lifecycle_entries(&journal.entries())); +} + +#[tokio::test] +async fn place_before_cancel_rejects_overlap_without_explicit_live_exposure_cap() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let engine = Engine::with_config( + venue.clone(), + MemoryJournal::default(), + DistinctKeyReplaceAlgo, + exh::EngineConfig::builder() + .max_actions_per_advance(1usize) + .replace_mode(ReplaceMode::PlaceBeforeCancel) + .build() + .expect("engine config must build"), + ); + let intent = spot_buy_intent("exec-replace-default-live-cap", &symbol, Decimal::new(1, 0)); + let mut snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + snapshot = engine + .advance(&snapshot, input_at(&symbol, 1, 100, Vec::new())) + .await + .unwrap() + .into_snapshot(); + let error = engine + .advance(&snapshot, input_at(&symbol, 2, 100, Vec::new())) + .await + .expect_err("default place-before-cancel cap must reject overlap"); + + assert!(error.to_string().contains("live exposure")); + assert_eq!( + venue.actions(), + vec![TestVenueAction::Place("child-old".to_owned())] + ); +} + +#[tokio::test] +async fn place_before_cancel_replacement_places_new_key_before_canceling_stale_key() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let engine = Engine::with_config( + venue.clone(), + MemoryJournal::default(), + DistinctKeyReplaceAlgo, + exh::EngineConfig::builder() + .max_actions_per_advance(1usize) + .replace_mode(ReplaceMode::PlaceBeforeCancel) + .place_before_cancel_live_exposure_multiplier(Decimal::new(2, 0)) + .build() + .expect("engine config must build"), + ); + let intent = spot_buy_intent("exec-replace-distinct-key", &symbol, Decimal::new(1, 0)); + let mut snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + for second in 1..=2 { + snapshot = engine + .advance(&snapshot, input_at(&symbol, second, 100, Vec::new())) + .await + .unwrap() + .into_snapshot(); + } + assert_eq!( + venue.actions(), + vec![ + TestVenueAction::Place("child-old".to_owned()), + TestVenueAction::Place("child-new".to_owned()) + ] + ); + assert!(snapshot.active_children.contains_key(&ChildKey::new("old"))); + assert!(snapshot.active_children.contains_key(&ChildKey::new("new"))); + + engine + .advance(&snapshot, input_at(&symbol, 3, 100, Vec::new())) + .await + .unwrap(); + assert_eq!( + venue.actions(), + vec![ + TestVenueAction::Place("child-old".to_owned()), + TestVenueAction::Place("child-new".to_owned()), + TestVenueAction::Cancel("child-old".to_owned()) + ] + ); +} + +#[test] +fn place_before_cancel_rejects_ambiguous_live_exposure_config() { + let error = exh::EngineConfig::builder() + .replace_mode(ReplaceMode::PlaceBeforeCancel) + .place_before_cancel_max_live_target_value(Decimal::new(2, 0)) + .place_before_cancel_live_exposure_multiplier(Decimal::new(2, 0)) + .build() + .expect_err("ambiguous live exposure config must be rejected"); + + assert!(error.to_string().contains("only one")); +} + +#[tokio::test] +async fn place_before_cancel_rejects_same_key_request_changes() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let engine = Engine::with_config( + venue.clone(), + MemoryJournal::default(), + LifecycleMetadataAlgo, + exh::EngineConfig::builder() + .replace_mode(ReplaceMode::PlaceBeforeCancel) + .build() + .expect("engine config must build"), + ); + let intent = spot_buy_intent("exec-replace-same-key", &symbol, Decimal::new(1, 0)); + let mut snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + snapshot = engine + .advance(&snapshot, input_at(&symbol, 1, 100, Vec::new())) + .await + .unwrap() + .into_snapshot(); + let error = engine + .advance(&snapshot, input_at(&symbol, 2, 101, Vec::new())) + .await + .expect_err("same-key request change should be rejected"); + + assert!(error.to_string().contains("same-key request changes")); + assert!(error.to_string().contains("unsupported")); + assert_eq!( + venue.actions(), + vec![TestVenueAction::Place("child-primary".to_owned())] + ); +} + +#[derive(Debug, Clone)] +struct CancelAfterPlaceAlgo; + +#[async_trait] +impl Algorithm for CancelAfterPlaceAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + if context + .snapshot + .active_children + .contains_key(&ChildKey::new("primary")) + { + return Ok(DesiredState::paused().into()); + } + Ok(DesiredState::running(vec![primary_child(context, "primary")?]).into()) + } + + async fn on_lifecycle( + &self, + context: &exh::LifecycleContext, + ) -> Result { + Ok(lifecycle_effects(&context.event)) + } +} + +#[derive(Debug, Clone)] +struct FailingLifecycleAlgo; + +#[async_trait] +impl Algorithm for FailingLifecycleAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + Ok(DesiredState::running(vec![primary_child(context, "primary")?]).into()) + } + + async fn on_lifecycle( + &self, + _context: &exh::LifecycleContext, + ) -> Result { + Err(exh::Error::PolicyViolation { + message: "lifecycle hook failed".to_owned(), + }) + } +} + +#[derive(Debug, Clone, Default)] +struct RejectLifecycleJournal { + inner: SharedJournal, +} + +impl RejectLifecycleJournal { + fn entries(&self) -> Vec { + self.inner.entries() + } +} + +#[async_trait] +impl Journal for RejectLifecycleJournal { + async fn append(&self, entry: JournalEntry) -> Result<(), exh::Error> { + if matches!( + &entry.kind, + JournalEntryKind::AlgorithmLifecycleStatePatched { .. } + | JournalEntryKind::AlgorithmLifecycleAuditRecorded { .. } + ) { + return Err(exh::Error::JournalAppend { + message: "reject lifecycle entry".to_owned(), + }); + } + self.inner.append(entry).await + } + + async fn load( + &self, + execution_id: &exh::ExecutionId, + ) -> Result, exh::Error> { + self.inner.load(execution_id).await + } +} + +fn lifecycle_effects(event: &AlgorithmLifecycleEvent) -> AlgorithmLifecycleEffects { + let event_name = match event { + AlgorithmLifecycleEvent::ChildPlaced { .. } => "placed", + AlgorithmLifecycleEvent::ChildPlaceRejected { .. } => "place_rejected", + AlgorithmLifecycleEvent::ChildCanceled { .. } => "canceled", + AlgorithmLifecycleEvent::ChildObserved { .. } => "observed", + _ => "unknown", + }; + AlgorithmLifecycleEffects::new() + .state_patch(exh::AlgorithmStatePatch::new( + "test.lifecycle_state", + 1, + serde_json::json!({ "event": event_name }), + )) + .audit_event(AlgorithmAuditEvent::new( + format!("test.lifecycle.{event_name}"), + 1, + serde_json::json!({ "event": event_name }), + )) +} + +async fn assert_replayed_lifecycle( + engine: &Engine, + execution_id: &exh::ExecutionId, + event_name: &str, +) where + A: Algorithm, +{ + let recovered = engine + .recover(execution_id) + .await + .expect("recover should succeed") + .expect("execution should exist"); + assert_eq!( + recovered + .algorithm_state + .get("test.lifecycle_state") + .expect("lifecycle state should replay") + .payload["event"], + serde_json::json!(event_name) + ); + assert!( + recovered + .algorithm_audit_events + .iter() + .any(|event| event.event_type == format!("test.lifecycle.{event_name}")) + ); +} + +fn assert_lifecycle_after( + entries: &[JournalEntry], + confirmed_event: impl Fn(&JournalEntry) -> bool, +) { + let confirmed = entries + .iter() + .position(confirmed_event) + .expect("confirmed lifecycle source entry must exist"); + let state = entries + .iter() + .enumerate() + .skip(confirmed + 1) + .find(|(_, entry)| { + matches!( + entry.kind, + JournalEntryKind::AlgorithmLifecycleStatePatched { .. } + ) + }) + .map(|(index, _)| index) + .expect("lifecycle state entry must exist"); + let audit = entries + .iter() + .enumerate() + .skip(confirmed + 1) + .find(|(_, entry)| { + matches!( + entry.kind, + JournalEntryKind::AlgorithmLifecycleAuditRecorded { .. } + ) + }) + .map(|(index, _)| index) + .expect("lifecycle audit entry must exist"); + assert!(confirmed < state); + assert!(confirmed < audit); +} + +fn has_lifecycle_entries(entries: &[JournalEntry]) -> bool { + entries.iter().any(|entry| { + matches!( + entry.kind, + JournalEntryKind::AlgorithmLifecycleStatePatched { .. } + | JournalEntryKind::AlgorithmLifecycleAuditRecorded { .. } + ) + }) +} + +fn input_at( + symbol: &Symbol, + seconds: i64, + price: i64, + order_updates: Vec, +) -> AdvanceInput { + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(seconds), + signal_frame(symbol, price), + order_updates, + ) +} diff --git a/crates/exh/tests/support/mod.rs b/crates/exh/tests/support/mod.rs new file mode 100644 index 0000000..18b93e1 --- /dev/null +++ b/crates/exh/tests/support/mod.rs @@ -0,0 +1,627 @@ +use async_trait::async_trait; +use exh::{ + Algorithm, AlgorithmAuditEvent, AlgorithmDecision, AlgorithmLifecycleEffects, + AlgorithmLifecycleEvent, AlgorithmMode, AlgorithmStatePatch, ChildKey, ChildTarget, + DesiredState, Driver, EvaluateContext, Journal, JournalEntry, LifecycleContext, OrderQuery, + OrderRequest, StoredExecution, TerminalState, +}; +use mkt::types::{ + ClientOrderId, Decimal, MarketKind, Order, OrderId, OrderKey, OrderQuantity, OrderSide, + OrderStatus, OrderType, SpotCancelOrderRequest, SpotOrderQuery, SpotOrderRequest, Symbol, +}; +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use time::OffsetDateTime; + +mod lifecycle; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct TestSignals { + last_price: Decimal, +} + +#[derive(Debug, Default)] +struct TestVenueState { + orders_by_client_order_id: BTreeMap, + queued_place_errors_by_client_order_id: BTreeMap, + queued_place_results_by_client_order_id: BTreeMap, + queued_updates_by_client_order_id: BTreeMap, + actions: Vec, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct TestVenue { + state: Arc>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TestVenueAction { + Place(String), + Cancel(String), +} + +impl TestVenue { + pub(crate) fn seed_place_result(&self, order: Order) { + let Some(client_order_id) = &order.client_order_id else { + panic!("place seed order must have client_order_id"); + }; + self.state + .lock() + .expect("test venue mutex poisoned") + .queued_place_results_by_client_order_id + .insert(client_order_id.0.clone(), order); + } + + pub(crate) fn seed_place_error(&self, client_order_id: &str, message: &str) { + self.state + .lock() + .expect("test venue mutex poisoned") + .queued_place_errors_by_client_order_id + .insert(client_order_id.to_owned(), message.to_owned()); + } + + pub(crate) fn seed_query_result(&self, order: Order) { + let Some(client_order_id) = &order.client_order_id else { + panic!("query seed order must have client_order_id"); + }; + self.state + .lock() + .expect("test venue mutex poisoned") + .queued_updates_by_client_order_id + .insert(client_order_id.0.clone(), order); + } + + pub(crate) fn actions(&self) -> Vec { + self.state + .lock() + .expect("test venue mutex poisoned") + .actions + .clone() + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct SharedJournal { + entries: Arc>>, +} + +impl SharedJournal { + pub(crate) fn entries(&self) -> Vec { + self.entries + .lock() + .expect("shared journal mutex poisoned") + .clone() + } +} + +#[async_trait] +impl Journal for SharedJournal { + async fn append(&self, entry: JournalEntry) -> Result<(), exh::Error> { + let mut entries = self.entries.lock().map_err(|_| exh::Error::JournalAppend { + message: "shared journal mutex poisoned".to_owned(), + })?; + if entries.iter().any(|existing| { + existing.execution_id == entry.execution_id && existing.sequence == entry.sequence + }) { + return Err(exh::Error::JournalConflict { + message: format!( + "duplicate sequence {} for execution {}", + entry.sequence, entry.execution_id.0 + ), + }); + } + entries.push(entry); + Ok(()) + } + + async fn load( + &self, + execution_id: &exh::ExecutionId, + ) -> Result, exh::Error> { + let mut entries = self + .entries + .lock() + .map_err(|_| exh::Error::JournalLoad { + message: "shared journal mutex poisoned".to_owned(), + })? + .iter() + .filter(|entry| entry.execution_id == *execution_id) + .cloned() + .collect::>(); + if entries.is_empty() { + return Ok(None); + } + entries.sort_by_key(|entry| entry.sequence); + let intent = entries + .iter() + .find_map(|entry| match &entry.kind { + exh::JournalEntryKind::ExecutionCreated { intent } => Some(intent.clone()), + _ => None, + }) + .ok_or_else(|| exh::Error::InvalidRecovery { + message: format!("missing ExecutionCreated entry for {}", execution_id.0), + })?; + Ok(Some(StoredExecution::new(intent, entries))) + } +} + +#[async_trait] +impl Driver for TestVenue { + async fn place_order(&self, request: OrderRequest) -> Result { + let client_order_id = + request + .client_order_id() + .cloned() + .ok_or_else(|| exh::Error::DriverPlace { + message: "missing client order id".to_owned(), + })?; + let mut state = self.state.lock().expect("test venue mutex poisoned"); + state + .actions + .push(TestVenueAction::Place(client_order_id.0.clone())); + if let Some(message) = state + .queued_place_errors_by_client_order_id + .remove(&client_order_id.0) + { + return Err(exh::Error::DriverPlace { message }); + } + if let Some(order) = state + .queued_place_results_by_client_order_id + .remove(&client_order_id.0) + { + state + .orders_by_client_order_id + .insert(client_order_id.0.clone(), order.clone()); + return Ok(order); + } + + let (symbol, side, order_type, price, quantity) = match request { + OrderRequest::Spot(request) => ( + request.symbol, + request.side, + request.order_type, + request.price, + match request.quantity { + OrderQuantity::Base(quantity) => quantity, + OrderQuantity::Quote(_) => Decimal::ONE, + _ => Decimal::ONE, + }, + ), + OrderRequest::Futures(request) => ( + request.symbol, + request.side, + request.order_type, + request.price, + request.quantity, + ), + _ => { + return Err(exh::Error::DriverPlace { + message: "unsupported order request variant in test venue".to_owned(), + }); + } + }; + let order = Order::builder() + .id(OrderId::new(format!("order-{}", client_order_id.0))) + .client_order_id(Some(client_order_id.clone())) + .symbol(symbol.clone()) + .market_kind(symbol.kind) + .side(side) + .order_type(order_type) + .status(OrderStatus::New) + .price(price) + .quantity(quantity) + .filled_quantity(Decimal::ZERO) + .created_at(OffsetDateTime::UNIX_EPOCH) + .build() + .map_err(|message| exh::Error::DriverPlace { + message: message.to_string(), + })?; + state + .orders_by_client_order_id + .insert(client_order_id.0, order.clone()); + Ok(order) + } + + async fn query_order(&self, query: OrderQuery) -> Result { + let client_order_id = match query { + OrderQuery::Spot(SpotOrderQuery { key, .. }) => match key { + OrderKey::Client(client_order_id) => client_order_id, + OrderKey::Exchange(order_id) => ClientOrderId::new(order_id.0), + _ => { + return Err(exh::Error::DriverQuery { + message: "unsupported spot order key variant".to_owned(), + }); + } + }, + OrderQuery::Futures(query) => match query.key { + OrderKey::Client(client_order_id) => client_order_id, + OrderKey::Exchange(order_id) => ClientOrderId::new(order_id.0), + _ => { + return Err(exh::Error::DriverQuery { + message: "unsupported futures order key variant".to_owned(), + }); + } + }, + _ => { + return Err(exh::Error::DriverQuery { + message: "unsupported order query variant in test venue".to_owned(), + }); + } + }; + + let mut state = self.state.lock().expect("test venue mutex poisoned"); + if let Some(order) = state + .queued_updates_by_client_order_id + .remove(&client_order_id.0) + { + state + .orders_by_client_order_id + .insert(client_order_id.0.clone(), order.clone()); + return Ok(order); + } + + state + .orders_by_client_order_id + .get(&client_order_id.0) + .cloned() + .ok_or_else(|| exh::Error::DriverQuery { + message: format!("missing order {}", client_order_id.0), + }) + } + + async fn cancel_order(&self, request: exh::CancelOrderRequest) -> Result { + let client_order_id = match request { + exh::CancelOrderRequest::Spot(SpotCancelOrderRequest { key, .. }) => match key { + OrderKey::Client(client_order_id) => client_order_id, + OrderKey::Exchange(order_id) => ClientOrderId::new(order_id.0), + _ => { + return Err(exh::Error::DriverCancel { + message: "unsupported spot cancel key variant".to_owned(), + }); + } + }, + exh::CancelOrderRequest::Futures(request) => match request.key { + OrderKey::Client(client_order_id) => client_order_id, + OrderKey::Exchange(order_id) => ClientOrderId::new(order_id.0), + _ => { + return Err(exh::Error::DriverCancel { + message: "unsupported futures cancel key variant".to_owned(), + }); + } + }, + _ => { + return Err(exh::Error::DriverCancel { + message: "unsupported cancel request variant in test venue".to_owned(), + }); + } + }; + let mut state = self.state.lock().expect("test venue mutex poisoned"); + state + .actions + .push(TestVenueAction::Cancel(client_order_id.0.clone())); + let order = state + .orders_by_client_order_id + .get_mut(&client_order_id.0) + .ok_or_else(|| exh::Error::DriverCancel { + message: format!("missing order {}", client_order_id.0), + })?; + order.status = OrderStatus::Canceled; + Ok(order.clone()) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct SingleChildAlgo { + pub(crate) mode: AlgorithmMode, + pub(crate) next_wake_at: Option, +} + +#[async_trait] +impl Algorithm for SingleChildAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + let desired = match self.mode { + AlgorithmMode::Running => { + let remaining_quantity = context + .snapshot + .remaining_base_quantity() + .unwrap_or(Decimal::ZERO); + if remaining_quantity <= Decimal::ZERO { + DesiredState::finishing(TerminalState::Completed) + } else { + let request = SpotOrderRequest::builder() + .symbol(context.intent.symbol.clone()) + .side(context.intent.side) + .order_type(OrderType::Limit) + .quantity(OrderQuantity::Base(remaining_quantity)) + .price(context.signals.last_price) + .client_order_id(ClientOrderId::new("child-primary")) + .build() + .map(OrderRequest::Spot) + .map_err(|message| exh::Error::PolicyViolation { + message: message.to_string(), + })?; + DesiredState::running(vec![ChildTarget::new(ChildKey::new("primary"), request)]) + } + } + AlgorithmMode::Paused => DesiredState::paused(), + AlgorithmMode::Finishing { terminal_state } => DesiredState::finishing(terminal_state), + _ => DesiredState::paused(), + }; + Ok(AlgorithmDecision::new(desired, self.next_wake_at)) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct QuoteBudgetAlgo { + pub(crate) quote_amount: Decimal, +} + +#[async_trait] +impl Algorithm for QuoteBudgetAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + let Some(remaining_budget) = context.snapshot.remaining_quote_budget() else { + return Err(exh::Error::PolicyViolation { + message: "quote budget algo requires quote target".to_owned(), + }); + }; + if remaining_budget <= Decimal::ZERO { + return Ok(DesiredState::finishing(TerminalState::Completed).into()); + } + + let request = SpotOrderRequest::builder() + .symbol(context.intent.symbol.clone()) + .side(context.intent.side) + .order_type(OrderType::Market) + .quantity(OrderQuantity::Quote( + self.quote_amount.min(remaining_budget), + )) + .client_order_id(ClientOrderId::new("quote-child")) + .build() + .map(OrderRequest::Spot) + .map_err(|message| exh::Error::PolicyViolation { + message: message.to_string(), + })?; + Ok(DesiredState::running(vec![ChildTarget::new(ChildKey::new("quote"), request)]).into()) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct OversizedQuoteBudgetAlgo { + pub(crate) quote_amount: Decimal, +} + +#[async_trait] +impl Algorithm for OversizedQuoteBudgetAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + let request = SpotOrderRequest::builder() + .symbol(context.intent.symbol.clone()) + .side(context.intent.side) + .order_type(OrderType::Market) + .quantity(OrderQuantity::Quote(self.quote_amount)) + .client_order_id(ClientOrderId::new("quote-child")) + .build() + .map(OrderRequest::Spot) + .map_err(|message| exh::Error::PolicyViolation { + message: message.to_string(), + })?; + Ok(DesiredState::running(vec![ChildTarget::new(ChildKey::new("quote"), request)]).into()) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct MetadataAlgo; + +#[async_trait] +impl Algorithm for MetadataAlgo { + async fn evaluate( + &self, + _context: &EvaluateContext, + ) -> Result { + Ok(AlgorithmDecision::paused() + .state_patch(AlgorithmStatePatch::new( + "test.slice_state", + 1, + serde_json::json!({ + "slice_index": 3, + "request_count": 7, + }), + )) + .audit_event(AlgorithmAuditEvent::new( + "test.slice_report", + 1, + serde_json::json!({ + "fill_ratio": "0.75", + "stop_reason": "none", + }), + ))) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct LifecycleMetadataAlgo; + +#[async_trait] +impl Algorithm for LifecycleMetadataAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + if context + .snapshot + .remaining_base_quantity() + .unwrap_or(Decimal::ZERO) + <= Decimal::ZERO + { + return Ok(DesiredState::finishing(TerminalState::Completed).into()); + } + if context + .snapshot + .active_children + .contains_key(&ChildKey::new("primary")) + { + return Ok(DesiredState::running(vec![primary_child(context, "primary")?]).into()); + } + Ok(DesiredState::running(vec![primary_child(context, "primary")?]).into()) + } + + async fn on_lifecycle( + &self, + context: &LifecycleContext, + ) -> Result { + let event_name = match &context.event { + AlgorithmLifecycleEvent::ChildPlaced { .. } => "placed", + AlgorithmLifecycleEvent::ChildPlaceRejected { .. } => "place_rejected", + AlgorithmLifecycleEvent::ChildCanceled { .. } => "canceled", + AlgorithmLifecycleEvent::ChildObserved { .. } => "observed", + _ => "unknown", + }; + Ok(AlgorithmLifecycleEffects::new() + .state_patch(AlgorithmStatePatch::new( + "test.lifecycle_state", + 1, + serde_json::json!({ "event": event_name }), + )) + .audit_event(AlgorithmAuditEvent::new( + format!("test.lifecycle.{event_name}"), + 1, + serde_json::json!({ "event": event_name }), + ))) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct DistinctKeyReplaceAlgo; + +#[async_trait] +impl Algorithm for DistinctKeyReplaceAlgo { + async fn evaluate( + &self, + context: &EvaluateContext, + ) -> Result { + let key = if context + .snapshot + .active_children + .contains_key(&ChildKey::new("old")) + || context + .snapshot + .active_children + .contains_key(&ChildKey::new("new")) + { + "new" + } else { + "old" + }; + Ok(DesiredState::running(vec![primary_child(context, key)?]).into()) + } +} + +pub(crate) fn primary_child( + context: &EvaluateContext, + key: &str, +) -> Result { + let request = SpotOrderRequest::builder() + .symbol(context.intent.symbol.clone()) + .side(context.intent.side) + .order_type(OrderType::Limit) + .quantity(OrderQuantity::Base( + context + .snapshot + .remaining_base_quantity() + .unwrap_or(Decimal::ZERO), + )) + .price(context.signals.last_price) + .client_order_id(ClientOrderId::new(format!("child-{key}"))) + .build() + .map(OrderRequest::Spot) + .map_err(|message| exh::Error::PolicyViolation { + message: message.to_string(), + })?; + Ok(ChildTarget::new(ChildKey::new(key), request)) +} + +pub(crate) fn signal_frame(_symbol: &Symbol, price: i64) -> TestSignals { + TestSignals { + last_price: Decimal::new(price, 0), + } +} + +pub(crate) fn spot_buy_intent( + execution_id: &str, + symbol: &Symbol, + quantity: Decimal, +) -> exh::ExecutionIntent { + exh::ExecutionIntent::builder() + .execution_id(exh::ExecutionId::new(execution_id)) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .target_quantity(quantity) + .build() + .expect("intent must build") +} + +pub(crate) fn fill_order(symbol: &Symbol, client_order_id: &str, quantity: Decimal) -> Order { + observed_order( + symbol, + client_order_id, + quantity, + quantity, + OrderStatus::Filled, + ) +} + +pub(crate) fn quote_fill_order( + symbol: &Symbol, + client_order_id: &str, + quantity: Decimal, + filled_quantity: Decimal, + cumulative_quote_quantity: Decimal, + status: OrderStatus, +) -> Order { + Order::builder() + .id(OrderId::new(format!("order-{client_order_id}"))) + .client_order_id(Some(ClientOrderId::new(client_order_id))) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .order_type(OrderType::Market) + .status(status) + .quantity(quantity) + .filled_quantity(filled_quantity) + .cumulative_quote_quantity(Some(cumulative_quote_quantity)) + .created_at(OffsetDateTime::UNIX_EPOCH) + .build() + .expect("quote fill order must build") +} + +pub(crate) fn observed_order( + symbol: &Symbol, + client_order_id: &str, + quantity: Decimal, + filled_quantity: Decimal, + status: OrderStatus, +) -> Order { + Order::builder() + .id(OrderId::new(format!("order-{client_order_id}"))) + .client_order_id(Some(ClientOrderId::new(client_order_id))) + .symbol(symbol.clone()) + .market_kind(MarketKind::Spot) + .side(OrderSide::Buy) + .order_type(OrderType::Limit) + .status(status) + .price(Some(Decimal::new(100, 0))) + .quantity(quantity) + .filled_quantity(filled_quantity) + .created_at(OffsetDateTime::UNIX_EPOCH) + .build() + .expect("observed order must build") +} From 695f43eecf86173584a00f725f6ebde22d1948f3 Mon Sep 17 00:00:00 2001 From: Ewig Midori Date: Tue, 19 May 2026 09:20:35 +0000 Subject: [PATCH 2/2] fix: tighten execution API invariants --- crates/exh-kit/examples/adaptive_ioc_port.rs | 7 +- .../examples/basket_parent_allocator.rs | 3 +- crates/exh-kit/examples/deadline_catchup.rs | 3 +- .../examples/hierarchical_iceberg_oehrl.rs | 5 +- crates/exh-kit/examples/maker_ladder.rs | 3 +- .../examples/market_limit_rl_switch.rs | 3 +- crates/exh-kit/examples/robust_vwap.rs | 3 +- crates/exh-kit/examples/target_aware_pov.rs | 3 +- crates/exh-kit/src/multi_asset/helpers.rs | 6 +- crates/exh-kit/tests/multi_asset.rs | 10 +- crates/exh-kit/tests/progress.rs | 5 +- crates/exh-kit/tests/testing.rs | 1 - crates/exh/src/driver.rs | 63 ++++++---- crates/exh/src/engine.rs | 3 +- crates/exh/src/engine/core.rs | 52 +++++--- crates/exh/src/engine/lifecycle.rs | 15 ++- crates/exh/src/engine/types.rs | 50 ++++++++ crates/exh/src/error.rs | 11 +- crates/exh/src/intent.rs | 5 +- crates/exh/src/lib.rs | 3 +- crates/exh/tests/engine.rs | 115 ++++++++++++++++-- crates/exh/tests/support/lifecycle.rs | 91 +++++++++++++- crates/exh/tests/support/mod.rs | 1 - 23 files changed, 363 insertions(+), 98 deletions(-) diff --git a/crates/exh-kit/examples/adaptive_ioc_port.rs b/crates/exh-kit/examples/adaptive_ioc_port.rs index d6e1318..50b10cd 100644 --- a/crates/exh-kit/examples/adaptive_ioc_port.rs +++ b/crates/exh-kit/examples/adaptive_ioc_port.rs @@ -13,9 +13,9 @@ use mkt::prelude::{ MarketInfo, QuantityModeSupport, Symbol, TradingConstraints, TradingPermissions, }; use mkt::types::{ - Decimal, ExchangeId, KnownExchange, LotSizeFilter, MarketKind, MarketQuantityMode, - MarketStatus, NotionalConstraints, Order, OrderBook, OrderBookLevel, OrderId, OrderQuantity, - OrderSide, OrderStatus, OrderType, PriceFilter, + Decimal, ExchangeId, KnownExchange, LotSizeFilter, MarketQuantityMode, MarketStatus, + NotionalConstraints, Order, OrderBook, OrderBookLevel, OrderId, OrderQuantity, OrderSide, + OrderStatus, OrderType, PriceFilter, }; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; @@ -546,7 +546,6 @@ async fn main() -> Result<(), Box> { let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("adaptive-ioc-port")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Sell) .target_quantity(decimal("2.0")) .build()?; diff --git a/crates/exh-kit/examples/basket_parent_allocator.rs b/crates/exh-kit/examples/basket_parent_allocator.rs index 51a3aad..f643875 100644 --- a/crates/exh-kit/examples/basket_parent_allocator.rs +++ b/crates/exh-kit/examples/basket_parent_allocator.rs @@ -5,7 +5,7 @@ use exh_kit::strategy_prelude::{ ParentStateSchema, }; use exh_kit::testing::decimal; -use mkt::types::{Decimal, MarketKind, OrderSide, Symbol}; +use mkt::types::{Decimal, OrderSide, Symbol}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; @@ -36,7 +36,6 @@ fn intent(id: &str, symbol: &str, target: &str) -> ExecutionIntent { ExecutionIntent::builder() .execution_id(ExecutionId::new(id)) .symbol(Symbol::spot(symbol)) - .market_kind(MarketKind::Spot) .side(OrderSide::Sell) .target_quantity(decimal(target)) .build() diff --git a/crates/exh-kit/examples/deadline_catchup.rs b/crates/exh-kit/examples/deadline_catchup.rs index 8a811bf..75b979f 100644 --- a/crates/exh-kit/examples/deadline_catchup.rs +++ b/crates/exh-kit/examples/deadline_catchup.rs @@ -7,7 +7,7 @@ use exh_kit::strategy_prelude::{ }; use exh_kit::testing::{SimulatedVenue, spot_market_fixture}; use mkt::prelude::MarketInfo; -use mkt::types::{Decimal, MarketKind, OrderSide, Symbol}; +use mkt::types::{Decimal, OrderSide, Symbol}; use time::{Duration, OffsetDateTime}; #[derive(Debug, Clone)] @@ -120,7 +120,6 @@ async fn main() -> Result<(), Box> { let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("deadline-demo")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target_quantity(Decimal::new(2, 0)) .build()?; diff --git a/crates/exh-kit/examples/hierarchical_iceberg_oehrl.rs b/crates/exh-kit/examples/hierarchical_iceberg_oehrl.rs index da14553..6450059 100644 --- a/crates/exh-kit/examples/hierarchical_iceberg_oehrl.rs +++ b/crates/exh-kit/examples/hierarchical_iceberg_oehrl.rs @@ -12,7 +12,7 @@ use exh_kit::testing::{ SimulatedVenue, book_frame, decimal, filled_active_child, spot_market_fixture, }; use mkt::prelude::MarketInfo; -use mkt::types::{Decimal, MarketKind, OrderSide, OrderStatus, Symbol}; +use mkt::types::{Decimal, OrderSide, OrderStatus, Symbol}; use serde::{Deserialize, Serialize}; use time::{Duration, OffsetDateTime}; @@ -393,12 +393,11 @@ async fn main() -> Result<(), Box> { .max_actions_per_advance(1usize) .build() .expect("engine config must build"), - ); + )?; let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("oehrl-iceberg-demo")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target_quantity(decimal("1.2")) .strategy_tag(Some("hierarchical-iceberg-oehrl".to_owned())) diff --git a/crates/exh-kit/examples/maker_ladder.rs b/crates/exh-kit/examples/maker_ladder.rs index 8b0a276..839be7f 100644 --- a/crates/exh-kit/examples/maker_ladder.rs +++ b/crates/exh-kit/examples/maker_ladder.rs @@ -6,7 +6,7 @@ use exh_kit::strategy_prelude::{ }; use exh_kit::testing::{SimulatedVenue, spot_market_fixture}; use mkt::prelude::MarketInfo; -use mkt::types::{Decimal, MarketKind, OrderSide, Symbol}; +use mkt::types::{Decimal, OrderSide, Symbol}; use time::OffsetDateTime; #[derive(Debug, Clone)] @@ -78,7 +78,6 @@ async fn main() -> Result<(), Box> { let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("ladder-demo")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target_quantity(Decimal::new(3, 0)) .build()?; diff --git a/crates/exh-kit/examples/market_limit_rl_switch.rs b/crates/exh-kit/examples/market_limit_rl_switch.rs index 31eca7d..5f2f5b9 100644 --- a/crates/exh-kit/examples/market_limit_rl_switch.rs +++ b/crates/exh-kit/examples/market_limit_rl_switch.rs @@ -12,7 +12,7 @@ use exh_kit::testing::{ SimulatedVenue, book_frame, decimal, filled_active_child, spot_market_fixture, }; use mkt::prelude::MarketInfo; -use mkt::types::{Decimal, MarketKind, OrderSide, Symbol}; +use mkt::types::{Decimal, OrderSide, Symbol}; use serde::{Deserialize, Serialize}; use time::{Duration, OffsetDateTime}; @@ -175,7 +175,6 @@ async fn main() -> Result<(), Box> { let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("market-limit-switch-demo")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target_quantity(decimal("1.2")) .build()?; diff --git a/crates/exh-kit/examples/robust_vwap.rs b/crates/exh-kit/examples/robust_vwap.rs index 2a2876f..af89234 100644 --- a/crates/exh-kit/examples/robust_vwap.rs +++ b/crates/exh-kit/examples/robust_vwap.rs @@ -8,7 +8,7 @@ use exh_kit::strategy_prelude::{ }; use exh_kit::testing::{SimulatedVenue, filled_active_child, spot_market_fixture}; use mkt::prelude::MarketInfo; -use mkt::types::{Decimal, MarketKind, OrderSide, Symbol}; +use mkt::types::{Decimal, OrderSide, Symbol}; use time::{Duration, OffsetDateTime}; #[derive(Debug, Clone)] @@ -135,7 +135,6 @@ async fn main() -> Result<(), Box> { let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("vwap-demo")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target_quantity(Decimal::new(5, 0)) .build()?; diff --git a/crates/exh-kit/examples/target_aware_pov.rs b/crates/exh-kit/examples/target_aware_pov.rs index b960547..212b0dd 100644 --- a/crates/exh-kit/examples/target_aware_pov.rs +++ b/crates/exh-kit/examples/target_aware_pov.rs @@ -9,7 +9,7 @@ use exh_kit::schedule::{ChildBudgetPolicy, ParentSchedule, TimeWindow}; use exh_kit::signals::{SignalFrame, SignalMetrics}; use exh_kit::testing::{SimulatedVenue, decimal, filled_active_child, spot_market}; use mkt::prelude::MarketInfo; -use mkt::types::{Decimal, LastPrice, MarketKind, OrderSide, OrderType, Symbol}; +use mkt::types::{Decimal, LastPrice, OrderSide, OrderType, Symbol}; use time::{Duration, OffsetDateTime}; #[derive(Debug, Clone)] @@ -172,7 +172,6 @@ async fn main() -> Result<(), Box> { let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("target-aware-pov-demo")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .quote_budget(decimal("6000")) .build()?; diff --git a/crates/exh-kit/src/multi_asset/helpers.rs b/crates/exh-kit/src/multi_asset/helpers.rs index 08ce23e..88358e8 100644 --- a/crates/exh-kit/src/multi_asset/helpers.rs +++ b/crates/exh-kit/src/multi_asset/helpers.rs @@ -32,11 +32,13 @@ pub(super) fn validate_leg_snapshot( ), }); } - if actual.market_kind != planned.market_kind { + if actual.market_kind() != planned.market_kind() { return Err(Error::PolicyViolation { message: format!( "snapshot market kind {} does not match planned leg {} for execution {}", - actual.market_kind, planned.market_kind, planned.execution_id.0 + actual.market_kind(), + planned.market_kind(), + planned.execution_id.0 ), }); } diff --git a/crates/exh-kit/tests/multi_asset.rs b/crates/exh-kit/tests/multi_asset.rs index da4be0d..aedd30b 100644 --- a/crates/exh-kit/tests/multi_asset.rs +++ b/crates/exh-kit/tests/multi_asset.rs @@ -39,7 +39,6 @@ fn intent(id: &str, symbol: &str, target: &str) -> ExecutionIntent { ExecutionIntent::builder() .execution_id(ExecutionId::new(id)) .symbol(Symbol::spot(symbol)) - .market_kind(MarketKind::Spot) .side(OrderSide::Sell) .target_quantity(decimal(target)) .build() @@ -265,8 +264,12 @@ fn multi_asset_coordinator_rejects_snapshot_identity_mismatch() { intent("leg-a", "CCCUSDT", "10"), ExecutionIntent::builder() .execution_id(ExecutionId::new("leg-a")) - .symbol(Symbol::spot("AAAUSDT")) - .market_kind(MarketKind::linear_perpetual()) + .symbol(Symbol::derivative( + MarketKind::linear_perpetual() + .derivative_kind() + .expect("linear perpetual is derivative"), + "AAAUSDT", + )) .side(OrderSide::Sell) .target_quantity(decimal("10")) .build() @@ -274,7 +277,6 @@ fn multi_asset_coordinator_rejects_snapshot_identity_mismatch() { ExecutionIntent::builder() .execution_id(ExecutionId::new("leg-a")) .symbol(Symbol::spot("AAAUSDT")) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target_quantity(decimal("10")) .build() diff --git a/crates/exh-kit/tests/progress.rs b/crates/exh-kit/tests/progress.rs index 36b75a6..0c6f0b2 100644 --- a/crates/exh-kit/tests/progress.rs +++ b/crates/exh-kit/tests/progress.rs @@ -1,8 +1,6 @@ use exh::{ExecutionId, ExecutionIntent, ExecutionProgress, ExecutionSnapshot, OrderRequest}; use exh_kit::progress::{TargetProgressView, TargetValueKind}; -use mkt::types::{ - Decimal, MarketKind, OrderQuantity, OrderSide, OrderType, SpotOrderRequest, Symbol, -}; +use mkt::types::{Decimal, OrderQuantity, OrderSide, OrderType, SpotOrderRequest, Symbol}; use std::str::FromStr; fn decimal(value: &str) -> Decimal { @@ -13,7 +11,6 @@ fn intent(target: exh::ExecutionTarget) -> ExecutionIntent { ExecutionIntent::builder() .execution_id(ExecutionId::new("progress-test")) .symbol(Symbol::spot("ETHUSDT")) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target(target) .build() diff --git a/crates/exh-kit/tests/testing.rs b/crates/exh-kit/tests/testing.rs index 167b5fc..d0515ec 100644 --- a/crates/exh-kit/tests/testing.rs +++ b/crates/exh-kit/tests/testing.rs @@ -184,7 +184,6 @@ async fn filled_active_child_inherits_order_identity_from_snapshot() { let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("exec-1")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target_quantity(decimal("2")) .build() diff --git a/crates/exh/src/driver.rs b/crates/exh/src/driver.rs index 323f7a5..37a8b0a 100644 --- a/crates/exh/src/driver.rs +++ b/crates/exh/src/driver.rs @@ -80,32 +80,41 @@ pub enum OrderQuery { } impl OrderQuery { - pub fn by_client_order_id(symbol: Symbol, client_order_id: ClientOrderId) -> Self { + pub fn by_client_order_id( + symbol: Symbol, + client_order_id: ClientOrderId, + ) -> Result { match symbol.kind { - MarketKind::Spot => Self::Spot(SpotOrderQuery::new( + MarketKind::Spot => Ok(Self::Spot(SpotOrderQuery::new( symbol, OrderKey::Client(client_order_id), - )), - MarketKind::Derivative(_) => Self::Futures(FuturesOrderQuery::new( + ))), + MarketKind::Derivative(_) => Ok(Self::Futures(FuturesOrderQuery::new( symbol, OrderKey::Client(client_order_id), - )), - _ => Self::Futures(FuturesOrderQuery::new( - symbol, - OrderKey::Client(client_order_id), - )), + ))), + _ => Err(Error::PolicyViolation { + message: format!("unsupported market kind {} for order query", symbol.kind), + }), } } - pub fn by_exchange_order_id(symbol: Symbol, order_id: mkt::types::OrderId) -> Self { + pub fn by_exchange_order_id( + symbol: Symbol, + order_id: mkt::types::OrderId, + ) -> Result { match symbol.kind { - MarketKind::Spot => { - Self::Spot(SpotOrderQuery::new(symbol, OrderKey::Exchange(order_id))) - } - MarketKind::Derivative(_) => { - Self::Futures(FuturesOrderQuery::new(symbol, OrderKey::Exchange(order_id))) - } - _ => Self::Futures(FuturesOrderQuery::new(symbol, OrderKey::Exchange(order_id))), + MarketKind::Spot => Ok(Self::Spot(SpotOrderQuery::new( + symbol, + OrderKey::Exchange(order_id), + ))), + MarketKind::Derivative(_) => Ok(Self::Futures(FuturesOrderQuery::new( + symbol, + OrderKey::Exchange(order_id), + ))), + _ => Err(Error::PolicyViolation { + message: format!("unsupported market kind {} for order query", symbol.kind), + }), } } } @@ -118,20 +127,22 @@ pub enum CancelOrderRequest { } impl CancelOrderRequest { - pub fn by_client_order_id(symbol: Symbol, client_order_id: ClientOrderId) -> Self { + pub fn by_client_order_id( + symbol: Symbol, + client_order_id: ClientOrderId, + ) -> Result { match symbol.kind { - MarketKind::Spot => Self::Spot(SpotCancelOrderRequest::new( - symbol, - OrderKey::Client(client_order_id), - )), - MarketKind::Derivative(_) => Self::Futures(FuturesCancelOrderRequest::new( + MarketKind::Spot => Ok(Self::Spot(SpotCancelOrderRequest::new( symbol, OrderKey::Client(client_order_id), - )), - _ => Self::Futures(FuturesCancelOrderRequest::new( + ))), + MarketKind::Derivative(_) => Ok(Self::Futures(FuturesCancelOrderRequest::new( symbol, OrderKey::Client(client_order_id), - )), + ))), + _ => Err(Error::PolicyViolation { + message: format!("unsupported market kind {} for cancel order", symbol.kind), + }), } } } diff --git a/crates/exh/src/engine.rs b/crates/exh/src/engine.rs index c2fb0ca..451948c 100644 --- a/crates/exh/src/engine.rs +++ b/crates/exh/src/engine.rs @@ -7,5 +7,6 @@ mod types; pub use core::Engine; pub use state::{ActiveChild, ExecutionProgress, ExecutionSnapshot, PendingAction, TerminalState}; pub use types::{ - AdvanceInput, AdvanceOutcome, EngineConfig, EngineConfigBuilder, OrderUpdate, ReplaceMode, + AdvanceInput, AdvanceOutcome, EngineConfig, EngineConfigBuilder, LifecycleFailurePolicy, + OrderUpdate, ReplaceMode, }; diff --git a/crates/exh/src/engine/core.rs b/crates/exh/src/engine/core.rs index 2950595..3f37dd0 100644 --- a/crates/exh/src/engine/core.rs +++ b/crates/exh/src/engine/core.rs @@ -38,17 +38,29 @@ where S: Clone + Send + Sync + 'static, { pub fn new(driver: D, journal: J, algorithm: A) -> Self { - Self::with_config(driver, journal, algorithm, EngineConfig::default()) + Self { + driver, + journal, + algorithm, + config: EngineConfig::default(), + _signals: std::marker::PhantomData, + } } - pub fn with_config(driver: D, journal: J, algorithm: A, config: EngineConfig) -> Self { - Self { + pub fn with_config( + driver: D, + journal: J, + algorithm: A, + config: EngineConfig, + ) -> Result { + config.validate()?; + Ok(Self { driver, journal, algorithm, config, _signals: std::marker::PhantomData, - } + }) } pub async fn start( @@ -205,7 +217,7 @@ where &signals, AlgorithmLifecycleEvent::ChildObserved { key, order }, ) - .await; + .await?; } Ok(()) } @@ -244,16 +256,16 @@ where } | PendingAction::Cancel { client_order_id, .. - } => OrderQuery::by_client_order_id(symbol, client_order_id.clone()), + } => OrderQuery::by_client_order_id(symbol, client_order_id.clone())?, }; - let order = - self.driver - .query_order(query) - .await - .map_err(|_| Error::PendingActionRecovery { - execution_id: snapshot.execution_id().0.clone(), - })?; + let order = self.driver.query_order(query).await.map_err(|source| { + Error::PendingActionRecovery { + execution_id: snapshot.execution_id().0.clone(), + message: "query_order failed while reconciling pending action".to_owned(), + source: Box::new(source), + } + })?; self.record( snapshot, @@ -270,7 +282,7 @@ where &signals, AlgorithmLifecycleEvent::ChildObserved { key, order }, ) - .await; + .await?; Ok(Some(if snapshot.is_terminal() { AdvanceOutcome::Completed { @@ -504,7 +516,7 @@ where order, }, ) - .await; + .await?; Ok(AdvanceOutcome::Progressed { snapshot: snapshot.clone(), next_wake_at: None, @@ -534,7 +546,7 @@ where message, }, ) - .await; + .await?; Err(error) } } @@ -558,8 +570,10 @@ where ) .await?; - let request = - CancelOrderRequest::by_client_order_id(snapshot.intent.symbol.clone(), client_order_id); + let request = CancelOrderRequest::by_client_order_id( + snapshot.intent.symbol.clone(), + client_order_id, + )?; let order = self.driver.cancel_order(request).await?; self.record( snapshot, @@ -576,7 +590,7 @@ where signals, AlgorithmLifecycleEvent::ChildCanceled { key, order }, ) - .await; + .await?; Ok(AdvanceOutcome::Progressed { snapshot: snapshot.clone(), next_wake_at: None, diff --git a/crates/exh/src/engine/lifecycle.rs b/crates/exh/src/engine/lifecycle.rs index 1239505..2190317 100644 --- a/crates/exh/src/engine/lifecycle.rs +++ b/crates/exh/src/engine/lifecycle.rs @@ -7,6 +7,7 @@ use crate::algorithm::{ use crate::driver::Driver; use crate::engine::core::Engine; use crate::engine::state::ExecutionSnapshot; +use crate::engine::types::LifecycleFailurePolicy; use crate::error::Error; use crate::journal::{Journal, JournalEntryKind}; @@ -23,7 +24,7 @@ where recorded_at: OffsetDateTime, signals: &S, event: AlgorithmLifecycleEvent, - ) { + ) -> Result<(), Error> { let effects = match self .algorithm .on_lifecycle(&LifecycleContext { @@ -43,12 +44,15 @@ where event = ?event, "algorithm lifecycle hook failed after confirmed engine event" ); - return; + return match self.config.lifecycle_failure_policy { + LifecycleFailurePolicy::BestEffort => Ok(()), + LifecycleFailurePolicy::Propagate => Err(error), + }; } }; if effects.is_empty() { - return; + return Ok(()); } if let Err(error) = self @@ -61,7 +65,12 @@ where event = ?event, "algorithm lifecycle effects were not fully recorded" ); + return match self.config.lifecycle_failure_policy { + LifecycleFailurePolicy::BestEffort => Ok(()), + LifecycleFailurePolicy::Propagate => Err(error), + }; } + Ok(()) } async fn record_lifecycle_journal_effects( diff --git a/crates/exh/src/engine/types.rs b/crates/exh/src/engine/types.rs index 09db7ed..1567461 100644 --- a/crates/exh/src/engine/types.rs +++ b/crates/exh/src/engine/types.rs @@ -5,6 +5,7 @@ use std::time::Duration; use time::OffsetDateTime; use crate::engine::state::ExecutionSnapshot; +use crate::error::Error; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] @@ -22,6 +23,15 @@ pub enum ReplaceMode { PlaceBeforeCancel, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum LifecycleFailurePolicy { + /// Log lifecycle hook/effect failures and keep the confirmed engine event. + BestEffort, + /// Return lifecycle hook/effect failures to the caller after recording the confirmed engine event. + Propagate, +} + #[derive(Debug, Clone, PartialEq, Eq, Builder)] #[non_exhaustive] #[builder(pattern = "owned", setter(into))] @@ -35,6 +45,8 @@ pub struct EngineConfig { pub max_internal_convergence_steps: usize, #[builder(default = "ReplaceMode::CancelBeforePlace")] pub replace_mode: ReplaceMode, + #[builder(default = "LifecycleFailurePolicy::BestEffort")] + pub lifecycle_failure_policy: LifecycleFailurePolicy, /// Maximum possible parent-target value while placing before canceling. /// /// Accounting includes filled target progress, remaining live active-child @@ -59,6 +71,7 @@ impl Default for EngineConfig { max_actions_per_advance: 1, max_internal_convergence_steps: 4, replace_mode: ReplaceMode::CancelBeforePlace, + lifecycle_failure_policy: LifecycleFailurePolicy::BestEffort, place_before_cancel_max_live_target_value: None, place_before_cancel_live_exposure_multiplier: None, } @@ -70,6 +83,43 @@ impl EngineConfig { EngineConfigBuilder::default() } + pub fn validate(&self) -> Result<(), Error> { + if self.max_actions_per_advance == 0 { + return Err(Error::InvalidConfig { + message: "max_actions_per_advance must be greater than zero".to_owned(), + }); + } + if self.max_internal_convergence_steps == 0 { + return Err(Error::InvalidConfig { + message: "max_internal_convergence_steps must be greater than zero".to_owned(), + }); + } + if let Some(max_live_target_value) = self.place_before_cancel_max_live_target_value + && max_live_target_value <= Decimal::ZERO + { + return Err(Error::InvalidConfig { + message: "place_before_cancel_max_live_target_value must be greater than zero" + .to_owned(), + }); + } + if let Some(multiplier) = self.place_before_cancel_live_exposure_multiplier + && multiplier < Decimal::ONE + { + return Err(Error::InvalidConfig { + message: "place_before_cancel_live_exposure_multiplier must be at least one" + .to_owned(), + }); + } + if self.place_before_cancel_max_live_target_value.is_some() + && self.place_before_cancel_live_exposure_multiplier.is_some() + { + return Err(Error::InvalidConfig { + message: "configure only one place-before-cancel live exposure limit".to_owned(), + }); + } + Ok(()) + } + pub(crate) fn place_before_cancel_live_exposure_limit( &self, parent_target_value: Decimal, diff --git a/crates/exh/src/error.rs b/crates/exh/src/error.rs index da86e5c..72e9058 100644 --- a/crates/exh/src/error.rs +++ b/crates/exh/src/error.rs @@ -21,6 +21,13 @@ pub enum Error { JournalConflict { message: String }, #[error("execution already terminal: {execution_id}")] AlreadyTerminal { execution_id: String }, - #[error("recovery requires pending action reconciliation: {execution_id}")] - PendingActionRecovery { execution_id: String }, + #[error("invalid engine config: {message}")] + InvalidConfig { message: String }, + #[error("recovery requires pending action reconciliation: {execution_id}: {message}")] + PendingActionRecovery { + execution_id: String, + message: String, + #[source] + source: Box, + }, } diff --git a/crates/exh/src/intent.rs b/crates/exh/src/intent.rs index 2556f96..f24b972 100644 --- a/crates/exh/src/intent.rs +++ b/crates/exh/src/intent.rs @@ -59,7 +59,6 @@ impl ExecutionTarget { pub struct ExecutionIntent { pub execution_id: ExecutionId, pub symbol: Symbol, - pub market_kind: MarketKind, pub side: OrderSide, #[builder(setter(custom))] pub target: ExecutionTarget, @@ -75,6 +74,10 @@ impl ExecutionIntent { pub fn builder() -> ExecutionIntentBuilder { ExecutionIntentBuilder::default() } + + pub fn market_kind(&self) -> MarketKind { + self.symbol.kind + } } impl ExecutionIntentBuilder { diff --git a/crates/exh/src/lib.rs b/crates/exh/src/lib.rs index 4e55ecb..3f47dc4 100644 --- a/crates/exh/src/lib.rs +++ b/crates/exh/src/lib.rs @@ -15,7 +15,8 @@ pub use algorithm::{ pub use driver::{CancelOrderRequest, Driver, OrderQuery, OrderRequest}; pub use engine::{ ActiveChild, AdvanceInput, AdvanceOutcome, Engine, EngineConfig, EngineConfigBuilder, - ExecutionProgress, ExecutionSnapshot, OrderUpdate, PendingAction, ReplaceMode, TerminalState, + ExecutionProgress, ExecutionSnapshot, LifecycleFailurePolicy, OrderUpdate, PendingAction, + ReplaceMode, TerminalState, }; pub use error::Error; pub use intent::{ diff --git a/crates/exh/tests/engine.rs b/crates/exh/tests/engine.rs index b80c9c1..4f99dc9 100644 --- a/crates/exh/tests/engine.rs +++ b/crates/exh/tests/engine.rs @@ -2,9 +2,9 @@ mod support; use exh::{ AdvanceInput, AdvanceOutcome, AlgorithmMode, ChildKey, Engine, ExecutionId, ExecutionIntent, - MemoryJournal, TerminalState, + Journal, JournalEntry, JournalEntryKind, MemoryJournal, StoredExecution, TerminalState, }; -use mkt::types::{Decimal, MarketKind, OrderSide, OrderStatus, Symbol}; +use mkt::types::{Decimal, OrderSide, OrderStatus, Symbol}; use support::{ MetadataAlgo, OversizedQuoteBudgetAlgo, QuoteBudgetAlgo, SingleChildAlgo, TestVenue, fill_order, observed_order, quote_fill_order, signal_frame, spot_buy_intent, @@ -181,12 +181,12 @@ async fn engine_pending_place_recovers_via_query() { .replace_mode(exh::ReplaceMode::CancelBeforePlace) .build() .expect("engine config must build"), - ); + ) + .expect("valid engine config must be accepted"); let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("exec-2")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target_quantity(Decimal::new(1, 0)) .build() @@ -218,6 +218,107 @@ async fn engine_pending_place_recovers_via_query() { ); } +#[tokio::test] +async fn pending_action_recovery_preserves_query_error_context() { + let symbol = Symbol::spot("ETHUSDT"); + let venue = TestVenue::default(); + venue.seed_place_error("child-primary", "venue accepted place but left it pending"); + let journal = RejectPlaceRejectedJournal::default(); + let algorithm = SingleChildAlgo { + mode: AlgorithmMode::Running, + next_wake_at: None, + }; + let engine = Engine::with_config( + venue, + journal, + algorithm, + exh::EngineConfig::builder() + .pending_query_after(std::time::Duration::ZERO) + .build() + .expect("engine config must build"), + ) + .expect("valid engine config must be accepted"); + + let intent = ExecutionIntent::builder() + .execution_id(ExecutionId::new("exec-pending-query-error")) + .symbol(symbol.clone()) + .side(OrderSide::Buy) + .target_quantity(Decimal::new(1, 0)) + .build() + .expect("intent must build"); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let _ = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .expect_err("place failure should leave pending recovery state"); + + let snapshot = engine + .recover(&ExecutionId::new("exec-pending-query-error")) + .await + .expect("recover should succeed") + .expect("execution should exist"); + + let error = engine + .advance( + &snapshot, + AdvanceInput::new( + OffsetDateTime::UNIX_EPOCH + Duration::seconds(1), + signal_frame(&symbol, 100), + Vec::new(), + ), + ) + .await + .expect_err("missing query result should surface recovery error"); + + assert!(matches!(error, exh::Error::PendingActionRecovery { .. })); + assert!(error.to_string().contains("query_order failed")); + let mut source = std::error::Error::source(&error); + let mut found_query_error = false; + while let Some(error) = source { + if error.to_string().contains("missing order child-primary") { + found_query_error = true; + break; + } + source = error.source(); + } + assert!(found_query_error); +} + +#[derive(Debug, Clone, Default)] +struct RejectPlaceRejectedJournal { + inner: support::SharedJournal, +} + +#[async_trait::async_trait] +impl Journal for RejectPlaceRejectedJournal { + async fn append(&self, entry: JournalEntry) -> Result<(), exh::Error> { + if matches!(entry.kind, JournalEntryKind::ChildPlaceRejected { .. }) { + return Err(exh::Error::JournalAppend { + message: "simulated crash before place rejection could be recorded".to_owned(), + }); + } + self.inner.append(entry).await + } + + async fn load( + &self, + execution_id: &ExecutionId, + ) -> Result, exh::Error> { + self.inner.load(execution_id).await + } +} + #[tokio::test] async fn engine_accepts_terminal_place_response_without_external_follow_up() { let symbol = Symbol::spot("SOLUSDT"); @@ -232,7 +333,6 @@ async fn engine_accepts_terminal_place_response_without_external_follow_up() { let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("exec-3")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target_quantity(Decimal::new(2, 0)) .build() @@ -283,7 +383,6 @@ async fn engine_completes_full_terminal_place_response_in_same_advance() { let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("exec-4")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target_quantity(Decimal::new(2, 0)) .build() @@ -330,7 +429,6 @@ async fn engine_persists_algorithm_state_patch_and_audit_event() { let intent = ExecutionIntent::builder() .execution_id(execution_id.clone()) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target_quantity(Decimal::new(2, 0)) .build() @@ -398,7 +496,6 @@ async fn engine_tracks_quote_budget_progress_from_terminal_place_response() { let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("exec-quote-1")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .quote_budget(Decimal::new(100, 0)) .build() @@ -453,7 +550,6 @@ async fn engine_tracks_quote_budget_progress_from_external_update() { let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("exec-quote-2")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .quote_budget(Decimal::new(100, 0)) .build() @@ -524,7 +620,6 @@ async fn engine_rejects_quote_budget_children_that_exceed_remaining_budget() { let intent = ExecutionIntent::builder() .execution_id(ExecutionId::new("exec-quote-3")) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .quote_budget(Decimal::new(100, 0)) .build() diff --git a/crates/exh/tests/support/lifecycle.rs b/crates/exh/tests/support/lifecycle.rs index 2f5410d..3eabaa4 100644 --- a/crates/exh/tests/support/lifecycle.rs +++ b/crates/exh/tests/support/lifecycle.rs @@ -2,7 +2,8 @@ use async_trait::async_trait; use exh::{ AdvanceInput, Algorithm, AlgorithmAuditEvent, AlgorithmDecision, AlgorithmLifecycleEffects, AlgorithmLifecycleEvent, ChildKey, DesiredState, Engine, EvaluateContext, Journal, - JournalEntry, JournalEntryKind, MemoryJournal, ReplaceMode, StoredExecution, TerminalState, + JournalEntry, JournalEntryKind, LifecycleFailurePolicy, MemoryJournal, ReplaceMode, + StoredExecution, TerminalState, }; use mkt::types::{Decimal, Symbol}; use time::{Duration, OffsetDateTime}; @@ -228,6 +229,85 @@ async fn lifecycle_journal_failure_does_not_mask_confirmed_place() { assert!(!has_lifecycle_entries(&journal.entries())); } +#[tokio::test] +async fn lifecycle_hook_failure_can_propagate_after_confirmed_place() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = SharedJournal::default(); + let engine = Engine::with_config( + venue, + journal.clone(), + FailingLifecycleAlgo, + exh::EngineConfig::builder() + .lifecycle_failure_policy(LifecycleFailurePolicy::Propagate) + .build() + .expect("engine config must build"), + ) + .expect("valid engine config must be accepted"); + let intent = spot_buy_intent( + "exec-lifecycle-hook-propagates-place", + &symbol, + Decimal::new(1, 0), + ); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let error = engine + .advance(&snapshot, input_at(&symbol, 1, 100, Vec::new())) + .await + .expect_err("lifecycle hook failure should propagate"); + + assert!(error.to_string().contains("lifecycle hook failed")); + assert!( + journal + .entries() + .iter() + .any(|entry| { matches!(entry.kind, JournalEntryKind::ChildPlaced { .. }) }) + ); + assert!(!has_lifecycle_entries(&journal.entries())); +} + +#[tokio::test] +async fn lifecycle_journal_failure_can_propagate_after_confirmed_place() { + let symbol = Symbol::spot("BTCUSDT"); + let venue = TestVenue::default(); + let journal = RejectLifecycleJournal::default(); + let engine = Engine::with_config( + venue, + journal.clone(), + LifecycleMetadataAlgo, + exh::EngineConfig::builder() + .lifecycle_failure_policy(LifecycleFailurePolicy::Propagate) + .build() + .expect("engine config must build"), + ) + .expect("valid engine config must be accepted"); + let intent = spot_buy_intent( + "exec-lifecycle-journal-propagates-place", + &symbol, + Decimal::new(1, 0), + ); + let snapshot = engine + .start(intent, OffsetDateTime::UNIX_EPOCH) + .await + .unwrap(); + + let error = engine + .advance(&snapshot, input_at(&symbol, 1, 100, Vec::new())) + .await + .expect_err("lifecycle journal failure should propagate"); + + assert!(error.to_string().contains("reject lifecycle entry")); + assert!( + journal + .entries() + .iter() + .any(|entry| { matches!(entry.kind, JournalEntryKind::ChildPlaced { .. }) }) + ); +} + #[tokio::test] async fn place_before_cancel_rejects_overlap_without_explicit_live_exposure_cap() { let symbol = Symbol::spot("BTCUSDT"); @@ -241,7 +321,8 @@ async fn place_before_cancel_rejects_overlap_without_explicit_live_exposure_cap( .replace_mode(ReplaceMode::PlaceBeforeCancel) .build() .expect("engine config must build"), - ); + ) + .expect("valid engine config must be accepted"); let intent = spot_buy_intent("exec-replace-default-live-cap", &symbol, Decimal::new(1, 0)); let mut snapshot = engine .start(intent, OffsetDateTime::UNIX_EPOCH) @@ -279,7 +360,8 @@ async fn place_before_cancel_replacement_places_new_key_before_canceling_stale_k .place_before_cancel_live_exposure_multiplier(Decimal::new(2, 0)) .build() .expect("engine config must build"), - ); + ) + .expect("valid engine config must be accepted"); let intent = spot_buy_intent("exec-replace-distinct-key", &symbol, Decimal::new(1, 0)); let mut snapshot = engine .start(intent, OffsetDateTime::UNIX_EPOCH) @@ -341,7 +423,8 @@ async fn place_before_cancel_rejects_same_key_request_changes() { .replace_mode(ReplaceMode::PlaceBeforeCancel) .build() .expect("engine config must build"), - ); + ) + .expect("valid engine config must be accepted"); let intent = spot_buy_intent("exec-replace-same-key", &symbol, Decimal::new(1, 0)); let mut snapshot = engine .start(intent, OffsetDateTime::UNIX_EPOCH) diff --git a/crates/exh/tests/support/mod.rs b/crates/exh/tests/support/mod.rs index 18b93e1..62910e8 100644 --- a/crates/exh/tests/support/mod.rs +++ b/crates/exh/tests/support/mod.rs @@ -562,7 +562,6 @@ pub(crate) fn spot_buy_intent( exh::ExecutionIntent::builder() .execution_id(exh::ExecutionId::new(execution_id)) .symbol(symbol.clone()) - .market_kind(MarketKind::Spot) .side(OrderSide::Buy) .target_quantity(quantity) .build()