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
27 changes: 20 additions & 7 deletions crates/standx-cli/src/commands/maker/cycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,21 @@ pub(super) async fn maker_cycle(
// Try to adopt from a recent place by side + price,
// tolerating a shrunk qty from a partial fill (see
// open_qty_adopts).
let matched = pending.iter().position(|p| {
p.side == o.side
&& (p.price - price).abs() < tick / 2.0
&& open_qty_adopts(qty, p.qty)
});
let matched = o
.cl_ord_id
.as_ref()
.and_then(|cl_ord_id| {
pending.iter().position(|p| p.cl_ord_id == *cl_ord_id)
})
.or_else(|| {
// Backward-compatible fallback for orders
// created before client IDs were enabled.
pending.iter().position(|p| {
p.side == o.side
&& (p.price - price).abs() < tick / 2.0
&& open_qty_adopts(qty, p.qty)
})
});
let meta = match matched {
Some(idx) => {
let p = pending.remove(idx);
Expand Down Expand Up @@ -237,10 +247,11 @@ pub(super) async fn maker_cycle(
}
Action::Place(q) => {
if live {
let cl_ord_id = format!("sxmk-{}", uuid::Uuid::new_v4());
match client
.create_order(CreateOrderParams {
symbol: symbol.to_string(),
cl_ord_id: None,
cl_ord_id: Some(cl_ord_id.clone()),
side: q.side,
order_type: OrderType::Limit,
quantity: format_decimals(q.qty, cfg.qty_decimals),
Expand All @@ -255,8 +266,10 @@ pub(super) async fn maker_cycle(
})
.await
{
Ok(_) => {
Ok(submission) => {
pending.push(PendingPlace {
request_id: submission.id,
cl_ord_id,
side: q.side,
price: q.price,
qty: q.qty,
Expand Down
149 changes: 145 additions & 4 deletions crates/standx-cli/src/commands/maker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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::order_response::OrderResponse;
use std::collections::HashMap;
use std::time::Duration;
use tokio::signal;
Expand All @@ -30,10 +31,13 @@ enum MakerExit {
FailSafe(String),
}

/// Pending place awaiting order-id adoption (live mode): create_order only
/// returns a request id, so new open orders are matched back to recent
/// places by (side, price, qty) on the next cycle.
/// Pending place awaiting order-id adoption in live mode. The HTTP request is
/// correlated to its asynchronous response by request ID, then to the visible
/// open order by client order ID. Side, price, and quantity remain only as a
/// compatibility fallback for orders submitted before client IDs were added.
struct PendingPlace {
request_id: String,
cl_ord_id: String,
side: OrderSide,
price: f64,
qty: f64,
Expand All @@ -42,6 +46,46 @@ struct PendingPlace {
cycle: u64,
}

fn apply_order_responses(
receiver: &mut tokio::sync::mpsc::Receiver<OrderResponse>,
pending: &mut Vec<PendingPlace>,
output_format: OutputFormat,
symbol: &str,
cycle: u64,
price_decimals: u32,
) {
while let Ok(response) = receiver.try_recv() {
let Some(request_id) = response.request_id.as_deref() else {
continue;
};
let Some(index) = pending
.iter()
.position(|place| place.request_id == request_id)
else {
// Authentication acknowledgements and cancellation responses do
// not correspond to a pending maker place.
continue;
};

if response.accepted() {
continue;
}

let rejected = pending.remove(index);
output::log_maker_event(
output_format,
symbol,
cycle,
"place_rejected_async",
rejected.side,
rejected.level,
rejected.price,
price_decimals,
&response.message,
);
}
}

/// A business rejection from the venue: the exchange responded with a
/// definite "no" (post-only would-cross, order not found, insufficient
/// margin, …). These are expected during normal quoting and must NOT trip
Expand Down Expand Up @@ -274,8 +318,13 @@ struct MakerRunArgs {

async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputFormat) -> Result<()> {
use standx_sdk::maker::{self, MakerConfig, RestingQuote};
use standx_sdk::order_response::OrderResponseStream;

let client = StandXClient::new()?;
let order_session_id = args.live.then(|| uuid::Uuid::new_v4().to_string());
let client = match order_session_id.as_deref() {
Some(session_id) => StandXClient::new()?.with_session_id(session_id),
None => StandXClient::new()?,
};

// ---- Startup: symbol metadata + invariants (fail fast) ----
let infos = client.get_symbol_info().await?;
Expand Down Expand Up @@ -354,6 +403,8 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm
}

// ---- Live gating & clean start ----
let mut order_responses = None;
let mut order_response_handle = None;
if args.live {
if std::env::var(LIVE_MAKER_ENV).ok().as_deref() != Some("1") {
return Err(anyhow::anyhow!(
Expand All @@ -372,6 +423,14 @@ 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 stream = OrderResponseStream::new(
order_session_id
.as_deref()
.expect("live maker must have an order session"),
)?;
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.
Expand Down Expand Up @@ -500,6 +559,17 @@ async fn run_maker(symbol: String, args: MakerRunArgs, output_format: OutputForm
let mut last_src: Option<&'static str> = None;

let exit = 'main: loop {
if let Some(receiver) = order_responses.as_mut() {
apply_order_responses(
receiver,
&mut pending,
output_format,
&symbol,
cycle,
cfg.price_decimals,
);
}

// Work phase raced against Ctrl+C so a slow API call can be
// interrupted (mirrors run_watch_loop).
let work = async {
Expand Down Expand Up @@ -652,6 +722,9 @@ 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();
}

// Notify stop on every exit path. Await delivery so the message lands
// before the process exits.
Expand Down Expand Up @@ -762,4 +835,72 @@ mod tests {
// Float slop just under the placed qty is tolerated.
assert!(open_qty_adopts(0.01 + 1e-9, 0.01));
}

#[test]
fn async_rejection_removes_only_matching_pending_place() {
let (sender, mut receiver) = tokio::sync::mpsc::channel(4);
let pending_place = |request_id: &str| PendingPlace {
request_id: request_id.to_string(),
cl_ord_id: format!("client-{request_id}"),
side: OrderSide::Buy,
price: 100.0,
qty: 0.01,
level: 0,
ref_center: 100.0,
cycle: 1,
};
let mut pending = vec![pending_place("request-1"), pending_place("request-2")];
sender
.try_send(OrderResponse {
code: 400,
message: "alo order rejected".to_string(),
request_id: Some("request-1".to_string()),
})
.unwrap();

apply_order_responses(
&mut receiver,
&mut pending,
OutputFormat::Quiet,
"BTC-USD",
2,
2,
);

assert_eq!(pending.len(), 1);
assert_eq!(pending[0].request_id, "request-2");
}

#[test]
fn async_acceptance_keeps_pending_until_exchange_order_is_visible() {
let (sender, mut receiver) = tokio::sync::mpsc::channel(2);
let mut pending = vec![PendingPlace {
request_id: "request-1".to_string(),
cl_ord_id: "client-1".to_string(),
side: OrderSide::Sell,
price: 101.0,
qty: 0.01,
level: 0,
ref_center: 100.0,
cycle: 1,
}];
sender
.try_send(OrderResponse {
code: 0,
message: "accepted".to_string(),
request_id: Some("request-1".to_string()),
})
.unwrap();

apply_order_responses(
&mut receiver,
&mut pending,
OutputFormat::Quiet,
"BTC-USD",
2,
2,
);

assert_eq!(pending.len(), 1);
}
}
37 changes: 36 additions & 1 deletion crates/standx-sdk/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const DEFAULT_BASE_URL: &str = "https://perps.standx.com";
pub struct StandXClient {
client: Client,
base_url: String,
session_id: Option<String>,
}

impl StandXClient {
Expand All @@ -32,6 +33,7 @@ impl StandXClient {
Ok(Self {
client,
base_url: DEFAULT_BASE_URL.to_string(),
session_id: None,
})
}

Expand All @@ -42,14 +44,29 @@ impl StandXClient {
.connect_timeout(Duration::from_secs(10))
.build()?;

Ok(Self { client, base_url })
Ok(Self {
client,
base_url,
session_id: None,
})
}

/// Get the base URL
pub fn base_url(&self) -> &str {
&self.base_url
}

/// Attach a stable order-response session ID to signed order requests.
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}

/// Current order-response session ID, if configured.
pub fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}

/// Build authenticated headers with optional request signing
pub async fn build_auth_headers(&self, payload: Option<&str>) -> Result<HeaderMap> {
let creds = Credentials::load()?;
Expand Down Expand Up @@ -78,6 +95,16 @@ impl StandXClient {

headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));

if let Some(session_id) = &self.session_id {
headers.insert(
"x-session-id",
HeaderValue::from_str(session_id).map_err(|e| Error::Validation {
field: "session_id".to_string(),
message: e.to_string(),
})?,
);
}

// Add request signature if private key is available
if !creds.private_key.is_empty() {
if let Ok(signer) = StandXSigner::from_base58(&creds.private_key) {
Expand Down Expand Up @@ -413,6 +440,14 @@ mod tests {
use super::*;
use mockito::Server;

#[test]
fn session_id_builder_is_stable() {
let client = StandXClient::with_base_url("https://example.invalid".to_string())
.unwrap()
.with_session_id("maker-session");
assert_eq!(client.session_id(), Some("maker-session"));
}

#[tokio::test]
async fn test_get_symbol_info() {
let mut server = Server::new_async().await;
Expand Down
1 change: 1 addition & 0 deletions crates/standx-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub mod client;
pub mod error;
pub mod maker;
pub mod models;
pub mod order_response;
pub mod websocket;

pub use error::{Error, Result};
Loading
Loading