Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
228 changes: 203 additions & 25 deletions crates/standx-cli/src/commands/maker/cycle.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
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;
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;

/// One reconcile cycle over an already-acquired market snapshot.
Expand All @@ -25,14 +29,18 @@ pub(super) async fn maker_cycle(
resting: &mut Vec<standx_sdk::maker::RestingQuote>,
adopted: &mut HashMap<String, (u32, f64, u64)>,
pending: &mut Vec<PendingPlace>,
maker_order_ids: &mut HashSet<u64>,
seen_fill_ids: &mut HashSet<u64>,
session_started_at: i64,
sim_position: &mut f64,
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::{
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
Expand Down Expand Up @@ -84,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::<u64>().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()
Expand All @@ -106,6 +154,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);
Expand Down Expand Up @@ -135,9 +184,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);
Expand Down Expand Up @@ -187,11 +237,40 @@ 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::<Vec<_>>();
cap_desired_exposure(cfg, position, &raw, &pending_slots)
};
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<Action> = 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).
Expand Down Expand Up @@ -247,7 +326,12 @@ pub(super) async fn maker_cycle(
}
Action::Place(q) => {
if live {
let cl_ord_id = format!("sxmk-{}", uuid::Uuid::new_v4());
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 {
symbol: symbol.to_string(),
Expand Down Expand Up @@ -317,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(
Expand All @@ -339,57 +423,151 @@ 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(
/// 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::<f64>().map_err(|_| {
anyhow::anyhow!(
"maker trade {} has invalid price '{}'",
trade.id,
trade.price
)
})?;
let qty = trade
.qty
.parse::<f64>()
.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,
symbol: &str,
attempts: u32,
) -> Result<()> {
let mut last_err: Option<anyhow::Error> = 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::<i64>().map_err(|_| {
anyhow::anyhow!(
"maker-owned order has non-integer exchange ID '{}'",
order.id
)
})
})
.collect::<Result<Vec<_>>>()?;
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;
}
}
}
}

// 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
))
}
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
)),
},
}
}

#[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"));
}
}
Loading
Loading