From b8e6053679b5f545ee895bb9024f2ff0f8994b5a Mon Sep 17 00:00:00 2001 From: BossX Date: Fri, 10 Jul 2026 09:39:19 +0800 Subject: [PATCH 1/6] fix: isolate maker-owned orders and pending quotes --- crates/standx-cli/src/commands/maker/cycle.rs | 86 ++++++++++++---- crates/standx-cli/src/commands/maker/mod.rs | 97 ++++++++++++++++--- 2 files changed, 153 insertions(+), 30 deletions(-) diff --git a/crates/standx-cli/src/commands/maker/cycle.rs b/crates/standx-cli/src/commands/maker/cycle.rs index 5de626b..4a78b1c 100644 --- a/crates/standx-cli/src/commands/maker/cycle.rs +++ b/crates/standx-cli/src/commands/maker/cycle.rs @@ -1,5 +1,8 @@ use super::output::{emit_maker_cycle, log_maker_event}; -use super::{is_order_rejection, open_qty_adopts, PendingPlace}; +use super::{ + is_maker_order, is_order_rejection, open_qty_adopts, pending_covers_slot, PendingPlace, + MAKER_CL_ORD_ID_PREFIX, +}; use crate::cli::*; use anyhow::Result; use standx_sdk::client::order::CreateOrderParams; @@ -106,6 +109,7 @@ pub(super) async fn maker_cycle( let tick = cfg.price_tick(); *resting = orders .into_iter() + .filter(is_maker_order) .map(|o| { let price: f64 = o.price.parse().unwrap_or(0.0); let qty: f64 = o.qty.parse().unwrap_or(0.0); @@ -135,9 +139,10 @@ pub(super) async fn maker_cycle( let p = pending.remove(idx); (p.level, p.ref_center, p.cycle) } - // Unknown order (manual or unmatched): sentinel - // level so reconcile cancels it as stale — the - // bot owns all orders on this symbol. + // An older maker order without in-memory state: + // sentinel level makes reconciliation replace it. + // Manual orders were filtered above and cannot + // enter the strategy state. None => (u32::MAX, mark, cycle), }; adopted.insert(o.id.clone(), meta); @@ -192,6 +197,30 @@ pub(super) async fn maker_cycle( let actions = reconcile( cfg, mark, position, best_bid, best_ask, &desired, resting, cycle, ); + // The pure reconciler intentionally knows nothing about transport state. + // Remove desired placements whose slots are still reserved by an HTTP + // submission before both execution and telemetry, so output never claims + // a duplicate place occurred. + let actions: Vec = actions + .into_iter() + .filter(|action| match action { + Action::Place(q) if live && pending_covers_slot(pending, q.side, q.level) => { + log_maker_event( + output_format, + symbol, + cycle, + "place_pending", + q.side, + q.level, + q.price, + cfg.price_decimals, + "awaiting asynchronous order confirmation", + ); + false + } + _ => true, + }) + .collect(); // The quote center these places are built around — the anti-flicker // anchor stored on each placed quote (equals mark when skew is off). @@ -247,7 +276,7 @@ pub(super) async fn maker_cycle( } Action::Place(q) => { if live { - let cl_ord_id = format!("sxmk-{}", uuid::Uuid::new_v4()); + let cl_ord_id = format!("{}{}", MAKER_CL_ORD_ID_PREFIX, uuid::Uuid::new_v4()); match client .create_order(CreateOrderParams { symbol: symbol.to_string(), @@ -339,25 +368,43 @@ pub(super) async fn maker_cycle( Ok((places, cancels, holds, fills.len() as u64)) } -/// Cancel-all with retries; verifies the book is actually clean afterwards. -pub(super) async fn cancel_all_with_retry( +/// Cancel maker-owned orders with retries, preserving manual/API orders. +pub(super) async fn cancel_maker_orders_with_retry( client: &StandXClient, symbol: &str, attempts: u32, ) -> Result<()> { let mut last_err: Option = None; for attempt in 1..=attempts { - match client.cancel_all_orders(symbol).await { + let result = async { + let orders = client.get_open_orders(Some(symbol)).await?; + let order_ids = orders + .iter() + .filter(|order| is_maker_order(order)) + .map(|order| { + order.id.parse::().map_err(|_| { + anyhow::anyhow!( + "maker-owned order has non-integer exchange ID '{}'", + order.id + ) + }) + }) + .collect::>>()?; + client.cancel_orders(&order_ids).await?; + Ok::<_, anyhow::Error>(()) + } + .await; + match result { Ok(()) => { last_err = None; break; } Err(e) => { eprintln!( - "⚠️ cancel-all attempt {}/{} failed: {}", + "⚠️ maker-order cancellation attempt {}/{} failed: {}", attempt, attempts, e ); - last_err = Some(e.into()); + last_err = Some(e); if attempt < attempts { tokio::time::sleep(Duration::from_secs(2)).await; } @@ -365,16 +412,21 @@ pub(super) async fn cancel_all_with_retry( } } - // Verify: a failed cancel leaves live orders unattended. + // Verify only maker-owned orders. Foreign orders are intentionally left + // untouched and must not turn a clean maker shutdown into an error. match client.get_open_orders(Some(symbol)).await { - Ok(orders) if orders.is_empty() => { - println!("✅ All {} orders cancelled", symbol); + Ok(orders) if orders.iter().all(|order| !is_maker_order(order)) => { + println!("✅ All maker-owned {} orders cancelled", symbol); Ok(()) } Ok(orders) => { - let ids: Vec<_> = orders.iter().map(|o| o.id.as_str()).collect(); + let ids: Vec<_> = orders + .iter() + .filter(|order| is_maker_order(order)) + .map(|order| order.id.as_str()) + .collect(); Err(anyhow::anyhow!( - "⚠️ RESIDUAL ORDERS on {} after cancel-all: [{}] — cancel manually with 'standx order cancel-all {}'", + "⚠️ RESIDUAL MAKER ORDERS on {} after cancellation: [{}] — inspect or cancel manually with 'standx order cancel-all {}'", symbol, ids.join(", "), symbol @@ -382,12 +434,12 @@ pub(super) async fn cancel_all_with_retry( } Err(e) => match last_err { Some(cancel_err) => Err(anyhow::anyhow!( - "cancel-all failed ({}) and verification failed ({}) — check open orders manually", + "maker-order cancellation failed ({}) and verification failed ({}) — check open orders manually", cancel_err, e )), None => Err(anyhow::anyhow!( - "cancel-all succeeded but verification failed ({}) — check open orders manually", + "maker-order cancellation succeeded but verification failed ({}) — check open orders manually", e )), }, diff --git a/crates/standx-cli/src/commands/maker/mod.rs b/crates/standx-cli/src/commands/maker/mod.rs index 5b1fe6e..dcf788b 100644 --- a/crates/standx-cli/src/commands/maker/mod.rs +++ b/crates/standx-cli/src/commands/maker/mod.rs @@ -3,7 +3,7 @@ use anyhow::Result; use standx_sdk::auth::Credentials; use standx_sdk::client::StandXClient; use standx_sdk::error::Error as StandxError; -use standx_sdk::models::OrderSide; +use standx_sdk::models::{Order, OrderSide}; use standx_sdk::order_response::OrderResponse; use std::collections::HashMap; use std::time::Duration; @@ -13,7 +13,7 @@ mod cycle; mod feed; mod output; -use cycle::{cancel_all_with_retry, maker_cycle}; +use cycle::{cancel_maker_orders_with_retry, maker_cycle}; use feed::{market_snapshot, spawn_market_feed}; // ============================================================================ @@ -23,6 +23,9 @@ use feed::{market_snapshot, spawn_market_feed}; /// Env var gating live order placement. The live path ships code-complete but /// locked until it has been supervised-tested against production. const LIVE_MAKER_ENV: &str = "STANDX_ENABLE_LIVE_MAKER"; +/// Prefix reserved for orders generated by this maker command. Never use it +/// for hand-entered orders: the maker may cancel any order bearing this tag. +const MAKER_CL_ORD_ID_PREFIX: &str = "sxmk-"; /// Why the maker loop stopped. enum MakerExit { @@ -46,6 +49,23 @@ struct PendingPlace { cycle: u64, } +/// Whether an exchange order was created by this maker command. +fn is_maker_order(order: &Order) -> bool { + order + .cl_ord_id + .as_deref() + .is_some_and(|id| id.starts_with(MAKER_CL_ORD_ID_PREFIX)) +} + +/// A submitted placement reserves its `(side, level)` until it is either +/// rejected, visible as an open order, or times out. This stops polling lag +/// from turning one desired quote into multiple real orders. +fn pending_covers_slot(pending: &[PendingPlace], side: OrderSide, level: u32) -> bool { + pending + .iter() + .any(|place| place.side == side && place.level == level) +} + fn apply_order_responses( receiver: &mut tokio::sync::mpsc::Receiver, pending: &mut Vec, @@ -423,6 +443,22 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm "Live mode requires a private key for order signing. Run 'standx auth login' with --private-key." )); } + let open_orders = client.get_open_orders(Some(&symbol)).await?; + let manual_orders = open_orders + .iter() + .filter(|order| !is_maker_order(order)) + .count(); + if manual_orders > 0 { + eprintln!( + "ℹ️ preserving {} manually-managed order(s) on {}; only {} orders are managed", + manual_orders, symbol, MAKER_CL_ORD_ID_PREFIX + ); + } + // Clean only leftover orders owned by this maker. Manual/API orders + // are not part of the strategy's reconciliation state and must never + // be adopted or cancelled as stale. + cancel_maker_orders_with_retry(&client, &symbol, 3).await?; + let stream = OrderResponseStream::new( order_session_id .as_deref() @@ -431,10 +467,6 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm let (responses, handle) = stream.connect().await?; order_responses = Some(responses); order_response_handle = Some(handle); - // Start from a clean book so reconciliation isn't confused by - // leftovers from a previous run. The bot owns ALL orders on this - // symbol while running. - client.cancel_all_orders(&symbol).await?; } let mode = if args.live { "LIVE" } else { "PAPER" }; @@ -464,10 +496,10 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm println!("│ touch crosses a quote, so position & skew move. --live for real."); } else { println!( - "│ ⚠️ LIVE: the bot manages ALL orders on {} — manual", - symbol + "│ ⚠️ LIVE: the bot manages only {} orders on {}", + MAKER_CL_ORD_ID_PREFIX, symbol ); - println!("│ orders on this symbol will be cancelled as stale."); + println!("│ manual/API orders are preserved and ignored."); } if args.no_ws { println!("│ feed: REST polling (--no-ws)"); @@ -503,7 +535,7 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm }; println!("│ risk alerts: {} → {}", parts.join(", "), sink); } - println!("│ Ctrl+C to stop (cancels all resting orders on exit)"); + println!("│ Ctrl+C to stop (cancels maker-owned resting orders on exit)"); println!("└──────────────────────────────────────────────────────────┘"); } @@ -719,12 +751,12 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm ); } } - if args.live { - cancel_all_with_retry(&client, &symbol, 3).await?; - } if let Some(handle) = order_response_handle { handle.abort(); } + if args.live { + cancel_maker_orders_with_retry(&client, &symbol, 3).await?; + } // Notify stop on every exit path. Await delivery so the message lands // before the process exits. @@ -836,6 +868,45 @@ mod tests { assert!(open_qty_adopts(0.01 + 1e-9, 0.01)); } + #[test] + fn maker_order_ownership_uses_reserved_client_id_prefix() { + let order = |cl_ord_id: Option<&str>| Order { + id: "123".to_string(), + cl_ord_id: cl_ord_id.map(str::to_string), + symbol: "BTC-USD".to_string(), + side: OrderSide::Buy, + order_type: standx_sdk::models::OrderType::Limit, + qty: "0.01".to_string(), + fill_qty: "0".to_string(), + price: "100".to_string(), + status: standx_sdk::models::OrderStatus::New, + created_at: "now".to_string(), + updated_at: "now".to_string(), + }; + + assert!(is_maker_order(&order(Some("sxmk-7f2b")))); + assert!(!is_maker_order(&order(Some("manual-7f2b")))); + assert!(!is_maker_order(&order(None))); + } + + #[test] + fn pending_order_reserves_its_quote_slot() { + let pending = vec![PendingPlace { + request_id: "request-1".to_string(), + cl_ord_id: "sxmk-1".to_string(), + side: OrderSide::Buy, + price: 100.0, + qty: 0.01, + level: 0, + ref_center: 100.0, + cycle: 1, + }]; + + assert!(pending_covers_slot(&pending, OrderSide::Buy, 0)); + assert!(!pending_covers_slot(&pending, OrderSide::Buy, 1)); + assert!(!pending_covers_slot(&pending, OrderSide::Sell, 0)); + } + #[test] fn async_rejection_removes_only_matching_pending_place() { let (sender, mut receiver) = tokio::sync::mpsc::channel(4); From ba709de7fae51b311df725f962d768383c47948f Mon Sep 17 00:00:00 2001 From: BossX Date: Fri, 10 Jul 2026 09:43:31 +0800 Subject: [PATCH 2/6] fix: cap maker worst-case quote exposure --- crates/standx-cli/src/commands/maker/cycle.rs | 11 ++- crates/standx-sdk/src/maker.rs | 95 +++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/crates/standx-cli/src/commands/maker/cycle.rs b/crates/standx-cli/src/commands/maker/cycle.rs index 4a78b1c..4c4b81c 100644 --- a/crates/standx-cli/src/commands/maker/cycle.rs +++ b/crates/standx-cli/src/commands/maker/cycle.rs @@ -34,8 +34,8 @@ pub(super) async fn maker_cycle( output_format: OutputFormat, ) -> Result<(u64, u64, u64, u64)> { use standx_sdk::maker::{ - compute_desired_quotes, format_decimals, mark_mid_divergence_bps, paper_quote_filled, - reconcile, skew_center, Action, RestingQuote, + cap_desired_exposure, compute_desired_quotes, format_decimals, mark_mid_divergence_bps, + paper_quote_filled, reconcile, skew_center, Action, RestingQuote, }; // 0. Feed the volatility breaker every cycle (even when a later guard @@ -192,7 +192,12 @@ pub(super) async fn maker_cycle( let desired = if halted { Vec::new() } else { - compute_desired_quotes(cfg, mark, best_bid, best_ask, position) + let raw = compute_desired_quotes(cfg, mark, best_bid, best_ask, position); + let pending_slots = pending + .iter() + .map(|place| (place.side, place.level)) + .collect::>(); + cap_desired_exposure(cfg, position, &raw, &pending_slots) }; let actions = reconcile( cfg, mark, position, best_bid, best_ask, &desired, resting, cycle, diff --git a/crates/standx-sdk/src/maker.rs b/crates/standx-sdk/src/maker.rs index 3e4da76..c9cca40 100644 --- a/crates/standx-sdk/src/maker.rs +++ b/crates/standx-sdk/src/maker.rs @@ -653,6 +653,48 @@ pub fn compute_desired_quotes( out } +/// Limit a desired ladder so that all quotes on either side filling cannot +/// push the account beyond `max_position`. +/// +/// Position-only suppression is insufficient for a multi-level ladder: while +/// the current position may be inside the cap, several resting bids (or asks) +/// can all fill before the next reconciliation cycle. This guard budgets each +/// directional ladder independently. `reserved_slots` are considered first; +/// callers use them for submitted-but-not-yet-visible orders, so transport +/// delay cannot make a later level lose its exposure reservation. +pub fn cap_desired_exposure( + cfg: &MakerConfig, + position: f64, + desired: &[DesiredQuote], + reserved_slots: &[(OrderSide, u32)], +) -> Vec { + let mut buy_budget = (cfg.max_position - position).max(0.0); + let mut sell_budget = (cfg.max_position + position).max(0.0); + let mut candidates = desired.to_vec(); + // Stable ordering keeps the configured inner-to-outer ladder order while + // moving only submitted-but-not-yet-visible slots to the front. + candidates.sort_by_key(|quote| !reserved_slots.contains(&(quote.side, quote.level))); + + candidates + .into_iter() + .filter(|quote| { + let budget = match quote.side { + OrderSide::Buy => &mut buy_budget, + OrderSide::Sell => &mut sell_budget, + }; + // Retain only full, tick-aligned orders. Shrinking a level would + // create a quantity not represented by the strategy's config and + // could fall below the venue's minimum order size. + if quote.qty <= *budget + f64::EPSILON { + *budget = (*budget - quote.qty).max(0.0); + true + } else { + false + } + }) + .collect() +} + /// Diff desired vs resting quotes, applying the anti-flicker hold rule. /// /// Decision table per resting quote (checked in order): @@ -1211,6 +1253,59 @@ mod tests { assert_eq!(base, with_pos); } + #[test] + fn exposure_cap_limits_all_same_side_fills() { + let mut c = cfg(); + c.levels = 3; + c.size = 0.02; + c.max_position = 0.05; + let raw = compute_desired_quotes(&c, 100.0, None, None, 0.03); + let capped = cap_desired_exposure(&c, 0.03, &raw, &[]); + + // At +0.03, only one additional 0.02 buy can be exposed. All three + // sells remain safe: even if they all fill, the position is -0.03. + assert_eq!( + capped + .iter() + .filter(|quote| quote.side == OrderSide::Buy) + .count(), + 1 + ); + assert_eq!( + capped + .iter() + .filter(|quote| quote.side == OrderSide::Sell) + .count(), + 3 + ); + let buy_qty: f64 = capped + .iter() + .filter(|quote| quote.side == OrderSide::Buy) + .map(|quote| quote.qty) + .sum(); + assert!(0.03 + buy_qty <= c.max_position + 1e-9); + } + + #[test] + fn exposure_cap_reserves_pending_slot_before_new_levels() { + let mut c = cfg(); + c.levels = 3; + c.size = 0.02; + c.max_position = 0.05; + let raw = compute_desired_quotes(&c, 100.0, None, None, 0.03); + let capped = cap_desired_exposure(&c, 0.03, &raw, &[(OrderSide::Buy, 2)]); + + // The in-flight outer bid gets the only 0.02 buy budget. A later + // reconcile cannot place level 0 in addition while level 2 is still + // awaiting exchange visibility. + assert!(capped + .iter() + .any(|quote| quote.side == OrderSide::Buy && quote.level == 2)); + assert!(!capped + .iter() + .any(|quote| quote.side == OrderSide::Buy && quote.level == 0)); + } + // 20. Inventory ratio saturates at ±1 past max_position. #[test] fn skew_clamps_at_full_inventory() { From 0391283f863a4f46ada89e3668033174f3cfd2e8 Mon Sep 17 00:00:00 2001 From: BossX Date: Fri, 10 Jul 2026 09:46:44 +0800 Subject: [PATCH 3/6] fix: fail closed when maker order stream disconnects --- crates/standx-cli/src/commands/maker/cycle.rs | 7 + crates/standx-cli/src/commands/maker/mod.rs | 59 ++++++- crates/standx-sdk/src/order_response.rs | 154 +++++++++++++++++- 3 files changed, 205 insertions(+), 15 deletions(-) diff --git a/crates/standx-cli/src/commands/maker/cycle.rs b/crates/standx-cli/src/commands/maker/cycle.rs index 4c4b81c..2d460b3 100644 --- a/crates/standx-cli/src/commands/maker/cycle.rs +++ b/crates/standx-cli/src/commands/maker/cycle.rs @@ -9,6 +9,7 @@ use standx_sdk::client::order::CreateOrderParams; use standx_sdk::client::StandXClient; use standx_sdk::models::{OrderSide, OrderType, TimeInForce}; use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; /// One reconcile cycle over an already-acquired market snapshot. @@ -32,6 +33,7 @@ pub(super) async fn maker_cycle( stats: &mut standx_sdk::maker::MakerStats, breaker: &mut standx_sdk::maker::VolBreaker, output_format: OutputFormat, + order_response_health: Option<&AtomicBool>, ) -> Result<(u64, u64, u64, u64)> { use standx_sdk::maker::{ cap_desired_exposure, compute_desired_quotes, format_decimals, mark_mid_divergence_bps, @@ -281,6 +283,11 @@ pub(super) async fn maker_cycle( } Action::Place(q) => { if live { + if !order_response_health.is_some_and(|health| health.load(Ordering::Acquire)) { + return Err(anyhow::anyhow!( + "order-response stream is unhealthy; refusing live placement" + )); + } let cl_ord_id = format!("{}{}", MAKER_CL_ORD_ID_PREFIX, uuid::Uuid::new_v4()); match client .create_order(CreateOrderParams { diff --git a/crates/standx-cli/src/commands/maker/mod.rs b/crates/standx-cli/src/commands/maker/mod.rs index dcf788b..36305a1 100644 --- a/crates/standx-cli/src/commands/maker/mod.rs +++ b/crates/standx-cli/src/commands/maker/mod.rs @@ -6,6 +6,7 @@ use standx_sdk::error::Error as StandxError; use standx_sdk::models::{Order, OrderSide}; use standx_sdk::order_response::OrderResponse; use std::collections::HashMap; +use std::sync::atomic::Ordering; use std::time::Duration; use tokio::signal; @@ -73,8 +74,17 @@ fn apply_order_responses( symbol: &str, cycle: u64, price_decimals: u32, -) { - while let Ok(response) = receiver.try_recv() { +) -> Result<()> { + loop { + let response = match receiver.try_recv() { + Ok(response) => response, + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => return Ok(()), + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + return Err(anyhow::anyhow!( + "order-response stream disconnected; refusing further live orders" + )); + } + }; let Some(request_id) = response.request_id.as_deref() else { continue; }; @@ -424,6 +434,7 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm // ---- Live gating & clean start ---- let mut order_responses = None; + let mut order_response_health = None; let mut order_response_handle = None; if args.live { if std::env::var(LIVE_MAKER_ENV).ok().as_deref() != Some("1") { @@ -464,8 +475,9 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm .as_deref() .expect("live maker must have an order session"), )?; - let (responses, handle) = stream.connect().await?; + let (responses, health, handle) = stream.connect().await?; order_responses = Some(responses); + order_response_health = Some(health); order_response_handle = Some(handle); } @@ -591,15 +603,26 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm let mut last_src: Option<&'static str> = None; let exit = 'main: loop { + if args.live + && !order_response_health + .as_ref() + .is_some_and(|health| health.load(Ordering::Acquire)) + { + break MakerExit::FailSafe( + "order-response stream is unhealthy; refusing further live orders".to_string(), + ); + } if let Some(receiver) = order_responses.as_mut() { - apply_order_responses( + if let Err(error) = apply_order_responses( receiver, &mut pending, output_format, &symbol, cycle, cfg.price_decimals, - ); + ) { + break MakerExit::FailSafe(error.to_string()); + } } // Work phase raced against Ctrl+C so a slow API call can be @@ -624,6 +647,7 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm &mut stats, &mut breaker, output_format, + order_response_health.as_deref(), ) .await?; Ok::<_, anyhow::Error>((places, cancels, holds, fills, mark, src, breaker.halted())) @@ -936,7 +960,8 @@ mod tests { "BTC-USD", 2, 2, - ); + ) + .unwrap(); assert_eq!(pending.len(), 1); assert_eq!(pending[0].request_id, "request-2"); @@ -970,8 +995,28 @@ mod tests { "BTC-USD", 2, 2, - ); + ) + .unwrap(); assert_eq!(pending.len(), 1); } + + #[test] + fn disconnected_order_response_stream_is_fail_closed() { + let (sender, mut receiver) = tokio::sync::mpsc::channel(1); + drop(sender); + let mut pending = Vec::new(); + + let error = apply_order_responses( + &mut receiver, + &mut pending, + OutputFormat::Quiet, + "BTC-USD", + 1, + 2, + ) + .unwrap_err(); + + assert!(error.to_string().contains("disconnected")); + } } diff --git a/crates/standx-sdk/src/order_response.rs b/crates/standx-sdk/src/order_response.rs index 257c545..ebe8beb 100644 --- a/crates/standx-sdk/src/order_response.rs +++ b/crates/standx-sdk/src/order_response.rs @@ -4,6 +4,11 @@ use crate::auth::Credentials; use crate::error::{Error, Result}; use futures::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::time::Duration; use tokio::sync::mpsc; use tokio_tungstenite::{connect_async, tungstenite::Message}; @@ -32,6 +37,9 @@ pub struct OrderResponseStream { session_id: String, } +/// Shared liveness flag for an authenticated order-response connection. +pub type OrderResponseHealth = Arc; + impl OrderResponseStream { /// Construct a production stream from the currently-loaded credentials. pub fn new(session_id: impl Into) -> Result { @@ -68,16 +76,63 @@ impl OrderResponseStream { &self.session_id } - /// Connect, authenticate, and return parsed asynchronous order responses. + /// Connect, wait for a successful authentication acknowledgement, and + /// return parsed asynchronous order responses plus a shared liveness flag. pub async fn connect( &self, - ) -> Result<(mpsc::Receiver, tokio::task::JoinHandle<()>)> { + ) -> Result<( + mpsc::Receiver, + OrderResponseHealth, + tokio::task::JoinHandle<()>, + )> { let (stream, _) = connect_async(&self.url).await?; let (mut write, mut read) = stream.split(); - let auth = auth_request(&self.session_id, &self.token); + let auth_request_id = uuid::Uuid::new_v4().to_string(); + let auth = auth_request(&self.session_id, &self.token, &auth_request_id); write.send(Message::Text(auth.to_string().into())).await?; + let auth_response = loop { + let message = tokio::time::timeout(Duration::from_secs(10), read.next()) + .await + .map_err(|_| Error::WebSocket { + message: "timed out waiting for order-response authentication".to_string(), + })? + .ok_or_else(|| Error::WebSocket { + message: "order-response stream closed before authentication".to_string(), + })??; + match message { + Message::Text(text) => { + let response = serde_json::from_str::(&text)?; + if response.request_id.as_deref() != Some(auth_request_id.as_str()) { + return Err(Error::WebSocket { + message: "received an unexpected response before authentication" + .to_string(), + }); + } + break response; + } + Message::Ping(payload) => write.send(Message::Pong(payload)).await?, + Message::Close(_) => { + return Err(Error::WebSocket { + message: "order-response stream closed before authentication".to_string(), + }); + } + _ => {} + } + }; + if !auth_response.accepted() { + return Err(Error::AuthRequired { + message: format!( + "order-response authentication rejected: {}", + auth_response.message + ), + resolution: "Run 'standx auth login' and retry".to_string(), + }); + } + let (tx, rx) = mpsc::channel(256); + let healthy = Arc::new(AtomicBool::new(true)); + let task_health = Arc::clone(&healthy); let handle = tokio::spawn(async move { while let Some(message) = read.next().await { match message { @@ -97,16 +152,17 @@ impl OrderResponseStream { _ => {} } } + task_health.store(false, Ordering::Release); }); - Ok((rx, handle)) + Ok((rx, healthy, handle)) } } -fn auth_request(session_id: &str, token: &str) -> serde_json::Value { +fn auth_request(session_id: &str, token: &str, request_id: &str) -> serde_json::Value { serde_json::json!({ "session_id": session_id, - "request_id": uuid::Uuid::new_v4().to_string(), + "request_id": request_id, "method": "auth:login", "params": serde_json::json!({ "token": token }).to_string(), }) @@ -115,14 +171,16 @@ fn auth_request(session_id: &str, token: &str) -> serde_json::Value { #[cfg(test)] mod tests { use super::*; + use tokio_tungstenite::accept_async; #[test] fn auth_request_uses_stable_session_and_unique_request() { - let first = auth_request("maker-session", "jwt"); - let second = auth_request("maker-session", "jwt"); + let first = auth_request("maker-session", "jwt", "request-1"); + let second = auth_request("maker-session", "jwt", "request-2"); assert_eq!(first["session_id"], "maker-session"); assert_eq!(first["method"], "auth:login"); + assert_eq!(first["request_id"], "request-1"); assert_ne!(first["request_id"], second["request_id"]); assert!(first["params"].as_str().unwrap().contains("jwt")); } @@ -155,4 +213,84 @@ mod tests { ); assert_eq!(stream.session_id(), "maker-session"); } + + #[tokio::test] + async fn authenticated_connection_becomes_unhealthy_after_server_close() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(socket).await.unwrap(); + let auth = websocket + .next() + .await + .unwrap() + .unwrap() + .into_text() + .unwrap(); + let auth: serde_json::Value = serde_json::from_str(&auth).unwrap(); + websocket + .send(Message::Text( + serde_json::json!({ + "code": 0, + "message": "authenticated", + "request_id": auth["request_id"], + }) + .to_string() + .into(), + )) + .await + .unwrap(); + // Drop the socket: the client must flip the liveness flag rather + // than continuing to place orders on a dead response stream. + }); + + let stream = OrderResponseStream::with_url_and_token(url, "jwt", "maker-session"); + let (_responses, health, handle) = stream.connect().await.unwrap(); + assert!(health.load(Ordering::Acquire)); + server.await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while health.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("connection close should mark the response stream unhealthy"); + handle.await.unwrap(); + } + + #[tokio::test] + async fn authentication_rejection_prevents_connection_start() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(socket).await.unwrap(); + let auth = websocket + .next() + .await + .unwrap() + .unwrap() + .into_text() + .unwrap(); + let auth: serde_json::Value = serde_json::from_str(&auth).unwrap(); + websocket + .send(Message::Text( + serde_json::json!({ + "code": 401, + "message": "invalid token", + "request_id": auth["request_id"], + }) + .to_string() + .into(), + )) + .await + .unwrap(); + }); + + let stream = OrderResponseStream::with_url_and_token(url, "bad-jwt", "maker-session"); + let error = stream.connect().await.unwrap_err(); + assert!(matches!(error, Error::AuthRequired { .. })); + server.await.unwrap(); + } } From 95ea598fbf5e4d80023ce1887302abc2a5e23a56 Mon Sep 17 00:00:00 2001 From: BossX Date: Fri, 10 Jul 2026 09:48:48 +0800 Subject: [PATCH 4/6] fix: notify before reporting maker cleanup failure --- crates/standx-cli/src/commands/maker/mod.rs | 27 +++++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/standx-cli/src/commands/maker/mod.rs b/crates/standx-cli/src/commands/maker/mod.rs index 36305a1..a4de586 100644 --- a/crates/standx-cli/src/commands/maker/mod.rs +++ b/crates/standx-cli/src/commands/maker/mod.rs @@ -778,9 +778,15 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm if let Some(handle) = order_response_handle { handle.abort(); } - if args.live { - cancel_maker_orders_with_retry(&client, &symbol, 3).await?; - } + // Do not return early on cleanup failure: operators need the stopped + // lifecycle alert most when residual maker orders may still be live. + let cleanup_error = if args.live { + cancel_maker_orders_with_retry(&client, &symbol, 3) + .await + .err() + } else { + None + }; // Notify stop on every exit path. Await delivery so the message lands // before the process exits. @@ -791,16 +797,20 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm let pnl_str = last_mark .map(|m| format!("{:+.2}", stats.mark_to_market(m))) .unwrap_or_else(|| "n/a".to_string()); + let cleanup_note = cleanup_error.as_ref().map_or_else(String::new, |error| { + format!(" | ⚠️ cleanup failed: {error}") + }); notify_lifecycle( "stopped", &format!( - "🔴 maker stopped ({}) — {} | {} cycles, {} fills, uptime {:.0}%, PnL {}", + "🔴 maker stopped ({}) — {} | {} cycles, {} fills, uptime {:.0}%, PnL {}{}", reason, symbol, cycle, total_fills, stats.uptime_pct(), - pnl_str + pnl_str, + cleanup_note, ), &symbol, output_format, @@ -811,6 +821,13 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm ) .await; + if let Some(error) = cleanup_error { + return Err(anyhow::anyhow!( + "maker stopped but maker-owned order cleanup failed: {}", + error + )); + } + match exit { MakerExit::CtrlC => Ok(()), MakerExit::FailSafe(e) => Err(anyhow::anyhow!( From 559b59362c15c6c843b9406042a1af8f713bbcf4 Mon Sep 17 00:00:00 2001 From: BossX Date: Fri, 10 Jul 2026 09:52:37 +0800 Subject: [PATCH 5/6] feat: ledger exact maker fills from trade history --- README.md | 14 +- crates/standx-cli/src/commands/maker/cycle.rs | 124 +++++++++++++++++- crates/standx-cli/src/commands/maker/mod.rs | 8 +- crates/standx-sdk/src/maker.rs | 36 ++--- docs/13-maker.md | 2 +- 5 files changed, 147 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 7602bba..66896e4 100644 --- a/README.md +++ b/README.md @@ -413,12 +413,14 @@ standx maker run BTC-USD --output json standx maker run BTC-USD --live ``` -In live mode the bot manages **all** orders on the quoted symbol (it starts -with a cancel-all and cancels unknown orders as stale), cancels everything on -exit (with retries and verification), and stops quoting after 3 consecutive -API errors (fail-safe). Paper mode simulates fills when the touch crosses a -quote, so position, inventory skew, and PnL telemetry are observable without -going live. +In live mode the bot manages only orders tagged with its `sxmk-` client-order +ID prefix: manual/API orders are preserved. It cleans up maker-owned orders on +exit (with retries and verification), stops quoting after 3 consecutive API +errors, and fails closed if the asynchronous order-response stream disconnects. +Live fill telemetry is sourced from authenticated, maker-order-correlated trade +history rather than inferred from position changes. Paper mode simulates fills +when the touch crosses a quote, so position, inventory skew, and PnL telemetry +are observable without going live. See **[docs/13-maker.md](docs/13-maker.md)** for the full guide — every flag, the anti-flicker decision table, inventory skew, telemetry, and live safety diff --git a/crates/standx-cli/src/commands/maker/cycle.rs b/crates/standx-cli/src/commands/maker/cycle.rs index 2d460b3..825d01a 100644 --- a/crates/standx-cli/src/commands/maker/cycle.rs +++ b/crates/standx-cli/src/commands/maker/cycle.rs @@ -7,8 +7,8 @@ use crate::cli::*; use anyhow::Result; use standx_sdk::client::order::CreateOrderParams; use standx_sdk::client::StandXClient; -use standx_sdk::models::{OrderSide, OrderType, TimeInForce}; -use std::collections::HashMap; +use standx_sdk::models::{OrderSide, OrderType, TimeInForce, Trade}; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -29,6 +29,9 @@ pub(super) async fn maker_cycle( resting: &mut Vec, adopted: &mut HashMap, pending: &mut Vec, + maker_order_ids: &mut HashSet, + seen_fill_ids: &mut HashSet, + session_started_at: i64, sim_position: &mut f64, stats: &mut standx_sdk::maker::MakerStats, breaker: &mut standx_sdk::maker::VolBreaker, @@ -89,12 +92,52 @@ pub(super) async fn maker_cycle( let position: f64; let mut fills: Vec<(OrderSide, f64, f64)> = Vec::new(); // paper sim only if live { - let (orders, positions) = tokio::join!( + let now = chrono::Utc::now().timestamp(); + let (orders, positions, filled_orders, trades) = tokio::join!( client.get_open_orders(Some(symbol)), - client.get_positions(Some(symbol)) + client.get_positions(Some(symbol)), + client.get_order_history(Some(symbol), Some(100)), + client.get_user_trades(symbol, session_started_at, now, Some(500)), ); let orders = orders?; let positions = positions?; + let filled_orders = filled_orders?; + let trades = trades?; + + // Open maker orders identify partial fills; historical maker orders + // identify a quote that fully filled between two polling cycles. + for order in orders.iter().chain(filled_orders.iter()) { + if is_maker_order(order) { + let order_id = order.id.parse::().map_err(|_| { + anyhow::anyhow!( + "maker-owned order has non-integer exchange ID '{}'", + order.id + ) + })?; + maker_order_ids.insert(order_id); + } + } + + for trade in trades { + let Some(order_id) = trade.order_id else { + continue; + }; + if !maker_order_ids.contains(&order_id) { + continue; + } + if trade.id == 0 { + return Err(anyhow::anyhow!( + "maker fill for order {} has no stable trade ID", + order_id + )); + } + if !seen_fill_ids.insert(trade.id) { + continue; + } + let (side, price, qty) = maker_trade_fill(&trade)?; + stats.record_fill(side, price, qty, mark); + fills.push((side, price, qty)); + } position = positions .iter() @@ -358,7 +401,7 @@ pub(super) async fn maker_cycle( // fill from any position delta; paper already recorded exact fills). let two_sided = resting.iter().any(|r| r.side == OrderSide::Buy) && resting.iter().any(|r| r.side == OrderSide::Sell); - stats.end_cycle(position, mark, two_sided, live); + stats.end_cycle(position, two_sided); // 6. Emit. emit_maker_cycle( @@ -380,6 +423,39 @@ pub(super) async fn maker_cycle( Ok((places, cancels, holds, fills.len() as u64)) } +/// Decode a venue fill strictly enough for accounting. A maker fill with +/// missing side, price, or quantity is not silently guessed from position. +fn maker_trade_fill(trade: &Trade) -> Result<(OrderSide, f64, f64)> { + let side = match trade.side.as_deref() { + Some(side) if side.eq_ignore_ascii_case("buy") => OrderSide::Buy, + Some(side) if side.eq_ignore_ascii_case("sell") => OrderSide::Sell, + _ => { + return Err(anyhow::anyhow!( + "maker trade {} is missing a valid side", + trade.id + )); + } + }; + let price = trade.price.parse::().map_err(|_| { + anyhow::anyhow!( + "maker trade {} has invalid price '{}'", + trade.id, + trade.price + ) + })?; + let qty = trade + .qty + .parse::() + .map_err(|_| anyhow::anyhow!("maker trade {} has invalid qty '{}'", trade.id, trade.qty))?; + if !price.is_finite() || price <= 0.0 || !qty.is_finite() || qty <= 0.0 { + return Err(anyhow::anyhow!( + "maker trade {} has non-positive price/qty", + trade.id + )); + } + Ok((side, price, qty)) +} + /// Cancel maker-owned orders with retries, preserving manual/API orders. pub(super) async fn cancel_maker_orders_with_retry( client: &StandXClient, @@ -457,3 +533,41 @@ pub(super) async fn cancel_maker_orders_with_retry( }, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn trade(side: Option<&str>, price: &str, qty: &str) -> Trade { + Trade { + id: 42, + time: "2026-07-10T00:00:00Z".to_string(), + price: price.to_string(), + qty: qty.to_string(), + side: side.map(str::to_string), + is_buyer_taker: false, + fee_asset: None, + fee_qty: None, + pnl: None, + order_id: Some(7), + symbol: Some("BTC-USD".to_string()), + value: None, + } + } + + #[test] + fn maker_trade_fill_requires_complete_venue_fields() { + assert_eq!( + maker_trade_fill(&trade(Some("buy"), "99.5", "0.02")).unwrap(), + (OrderSide::Buy, 99.5, 0.02) + ); + assert!(maker_trade_fill(&trade(None, "99.5", "0.02")) + .unwrap_err() + .to_string() + .contains("valid side")); + assert!(maker_trade_fill(&trade(Some("sell"), "bad", "0.02")) + .unwrap_err() + .to_string() + .contains("invalid price")); + } +} diff --git a/crates/standx-cli/src/commands/maker/mod.rs b/crates/standx-cli/src/commands/maker/mod.rs index a4de586..c880fdd 100644 --- a/crates/standx-cli/src/commands/maker/mod.rs +++ b/crates/standx-cli/src/commands/maker/mod.rs @@ -5,7 +5,7 @@ use standx_sdk::client::StandXClient; use standx_sdk::error::Error as StandxError; use standx_sdk::models::{Order, OrderSide}; use standx_sdk::order_response::OrderResponse; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; use std::time::Duration; use tokio::signal; @@ -588,6 +588,9 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm let mut resting: Vec = Vec::new(); // paper-mode book let mut adopted: HashMap = HashMap::new(); // id -> (level, ref_mark, cycle) let mut pending: Vec = Vec::new(); + let mut maker_order_ids: HashSet = HashSet::new(); + let mut seen_fill_ids: HashSet = HashSet::new(); + let session_started_at = chrono::Utc::now().timestamp(); let mut consecutive_errors: u32 = 0; let mut total_places: u64 = 0; let mut total_cancels: u64 = 0; @@ -643,6 +646,9 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm &mut resting, &mut adopted, &mut pending, + &mut maker_order_ids, + &mut seen_fill_ids, + session_started_at, &mut sim_position, &mut stats, &mut breaker, diff --git a/crates/standx-sdk/src/maker.rs b/crates/standx-sdk/src/maker.rs index c9cca40..10eb253 100644 --- a/crates/standx-sdk/src/maker.rs +++ b/crates/standx-sdk/src/maker.rs @@ -233,7 +233,7 @@ pub struct MakerStats { spread_bps_sum: f64, spread_bps_n: u64, pub max_abs_position: f64, - /// Last observed position, for inferring live fills from position deltas. + /// Last observed position, used for mark-to-market and inventory telemetry. last_position: f64, } @@ -263,22 +263,9 @@ impl MakerStats { } } - /// Close out a cycle: infer a live fill from any position delta (priced at - /// mark, since the exact fill price isn't known without the fills channel), - /// then update uptime and inventory extent. `two_sided` is whether both a - /// bid and an ask were resting this cycle. - pub fn end_cycle(&mut self, position: f64, mark: f64, two_sided: bool, live: bool) { - if live { - let delta = position - self.last_position; - if delta.abs() > f64::EPSILON { - let side = if delta > 0.0 { - OrderSide::Buy - } else { - OrderSide::Sell - }; - self.record_fill(side, mark, delta.abs(), mark); - } - } + /// Close out a cycle after the caller has recorded exact venue fills. + /// `two_sided` is whether both a bid and an ask were resting this cycle. + pub fn end_cycle(&mut self, position: f64, two_sided: bool) { self.last_position = position; self.max_abs_position = self.max_abs_position.max(position.abs()); self.cycles += 1; @@ -1188,15 +1175,16 @@ mod tests { #[test] fn stats_uptime_and_live_inference() { let mut s = MakerStats::default(); - s.end_cycle(0.0, 100.0, true, false); // two-sided - s.end_cycle(0.0, 100.0, false, false); // one-sided + s.end_cycle(0.0, true); // two-sided + s.end_cycle(0.0, false); // one-sided assert_eq!(s.cycles, 2); assert!((s.uptime_pct() - 50.0).abs() < 1e-9); - // Live: a position jump 0 -> 0.01 infers one buy fill at mark. + // Position movement alone must not fabricate a live fill: exact + // maker fills are supplied by the venue ledger. let mut l = MakerStats::default(); - l.end_cycle(0.01, 100.0, true, true); - assert_eq!(l.fills(), 1); - assert_eq!(l.buy_fills, 1); + l.end_cycle(0.01, true); + assert_eq!(l.fills(), 0); + assert_eq!(l.buy_fills, 0); assert!((l.max_abs_position - 0.01).abs() < 1e-9); } @@ -1456,7 +1444,7 @@ mod tests { let mut m = AlertMonitor::new(0.0, 0.0, 50.0); // floor 50% let mut s = MakerStats::default(); // One one-sided cycle -> uptime 0%, but before warmup: no alert. - s.end_cycle(0.0, 100.0, false, false); + s.end_cycle(0.0, false); assert!(m.evaluate(&s, 0.0, 100.0, 0.05, 5).is_empty()); // After warmup, still 0% < 50% -> fire. let a = m.evaluate(&s, 0.0, 100.0, 0.05, 25); diff --git a/docs/13-maker.md b/docs/13-maker.md index f0910c0..0982abd 100644 --- a/docs/13-maker.md +++ b/docs/13-maker.md @@ -151,7 +151,7 @@ center = mark × (1 − skew_bps × clamp(position / max_position, ±1) / 1e4) | uptime % | 同时挂着买卖单的周期占比 —— SIP-5A 真正奖励的东西 | | fills / max pos | 成交笔数、会话内最大绝对持仓 | -> paper 用精确模拟成交计算;live 从周期间仓位差推断成交(按 mark 计价,是捕获的保守下界,直到接入 fills 频道)。**调参在 paper 里做**:观察 avg capture 与 PnL 的关系来调 `--spread-bps` / `--refresh-bps` / `--skew-bps`。 +> paper 用精确模拟成交计算;live 从认证成交历史读取本次会话内、已关联到 `sxmk-` maker 订单的成交,并按成交 ID 去重。不会把手工/API 订单或单纯仓位变化当作 maker 成交。当前 mark-to-market 仍基于成交现金流与仓位估值;费用币种和交易所已实现盈亏会单列核对。**调参在 paper 里做**:观察 avg capture 与 PnL 的关系来调 `--spread-bps` / `--refresh-bps` / `--skew-bps`。 ### 风险告警 From d98519f589bac8db7f65649b6eaf3c291fe61607 Mon Sep 17 00:00:00 2001 From: BossX Date: Fri, 10 Jul 2026 09:59:23 +0800 Subject: [PATCH 6/6] feat: define maker inventory exit policy --- crates/standx-sdk/src/maker.rs | 70 ++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/standx-sdk/src/maker.rs b/crates/standx-sdk/src/maker.rs index 10eb253..2a1bcc2 100644 --- a/crates/standx-sdk/src/maker.rs +++ b/crates/standx-sdk/src/maker.rs @@ -64,6 +64,55 @@ pub struct DesiredQuote { pub qty: f64, } +/// A deliberate inventory-reducing order. Execution is kept outside this pure +/// strategy module so callers can first cancel conflicting maker quotes and +/// enforce venue-specific reduce-only semantics. +#[derive(Debug, Clone, PartialEq)] +pub struct InventoryExit { + /// Opposite the current position: sell a long, buy a short. + pub side: OrderSide, + /// Never exceeds the current absolute position or the configured chunk. + pub qty: f64, +} + +/// Decide whether inventory has reached an explicit active-exit threshold. +/// +/// A zero threshold or chunk disables active exit. The threshold is expressed +/// as a percentage of `max_position`; values over 100 are invalid/disabled so +/// a typo cannot create a surprising late exit. The result is only a plan — +/// callers must cancel stale quotes and submit a reduce-only order separately. +pub fn inventory_exit_plan( + position: f64, + max_position: f64, + trigger_pct: f64, + chunk_qty: f64, +) -> Option { + if !position.is_finite() + || !max_position.is_finite() + || !trigger_pct.is_finite() + || !chunk_qty.is_finite() + || max_position <= 0.0 + || trigger_pct <= 0.0 + || trigger_pct > 100.0 + || chunk_qty <= 0.0 + { + return None; + } + + let abs_position = position.abs(); + if abs_position + f64::EPSILON < max_position * trigger_pct / 100.0 { + return None; + } + Some(InventoryExit { + side: if position > 0.0 { + OrderSide::Sell + } else { + OrderSide::Buy + }, + qty: abs_position.min(chunk_qty), + }) +} + /// A quote currently resting (a real order in live mode, simulated in paper /// mode). #[derive(Debug, Clone)] @@ -822,6 +871,27 @@ mod tests { .expect("quote missing") } + #[test] + fn inventory_exit_plan_is_explicit_capped_and_reducing() { + assert_eq!( + inventory_exit_plan(0.04, 0.05, 80.0, 0.015), + Some(InventoryExit { + side: OrderSide::Sell, + qty: 0.015, + }) + ); + assert_eq!( + inventory_exit_plan(-0.05, 0.05, 80.0, 0.10), + Some(InventoryExit { + side: OrderSide::Buy, + qty: 0.05, + }) + ); + assert_eq!(inventory_exit_plan(0.039, 0.05, 80.0, 0.01), None); + assert_eq!(inventory_exit_plan(0.05, 0.05, 0.0, 0.01), None); + assert_eq!(inventory_exit_plan(0.05, 0.05, 101.0, 0.01), None); + } + // 1. Basic two-sided quoting. #[test] fn basic_two_sided() {