use pretty_simple_display::{DebugPretty, DisplaySimple};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize, PartialEq, DebugPretty, DisplaySimple)]
pub struct Quote {
pub instrument_name: String,
pub side: String,
pub amount: f64,
pub price: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub quote_set_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time_in_force: Option<String>,
}
#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
pub struct MassQuoteRequest {
pub mmp_group: String,
pub quotes: Vec<Quote>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quote_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detailed: Option<bool>,
}
#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
pub struct MassQuoteResult {
pub success_count: u32,
pub error_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub errors: Option<Vec<QuoteError>>,
}
#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
pub struct QuoteError {
pub instrument_name: String,
pub side: String,
pub error_code: i32,
pub error_message: String,
}
#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
pub struct CancelQuotesRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instrument_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quote_set_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta_range: Option<(f64, f64)>,
}
#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
pub struct CancelQuotesResponse {
pub cancelled_count: u32,
}
#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
pub struct MmpGroupConfig {
pub mmp_group: String,
pub quantity_limit: f64,
pub delta_limit: f64,
pub interval: u64,
pub frozen_time: u64,
pub enabled: bool,
}
#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
pub struct MmpGroupStatus {
pub mmp_group: String,
pub config: MmpGroupConfig,
pub reserved_margin: f64,
pub active_quotes: u32,
pub is_frozen: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub freeze_end_time: Option<u64>,
}
#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
pub struct QuoteInfo {
pub quote_id: String,
pub instrument_name: String,
pub side: String,
pub amount: f64,
pub price: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub quote_set_id: Option<String>,
pub mmp_group: String,
pub creation_timestamp: u64,
pub state: String,
pub filled_amount: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub average_price: Option<f64>,
pub priority: u64,
}
#[derive(Clone, Serialize, Deserialize, DebugPretty, DisplaySimple)]
pub struct MmpTrigger {
pub currency: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mmp_group: Option<String>,
pub timestamp: u64,
pub reason: String,
pub frozen_time: u64,
}
impl Quote {
pub fn buy(instrument_name: String, amount: f64, price: f64) -> Self {
Self {
instrument_name,
side: "buy".to_string(),
amount,
price,
quote_set_id: None,
post_only: None,
time_in_force: None,
}
}
pub fn sell(instrument_name: String, amount: f64, price: f64) -> Self {
Self {
instrument_name,
side: "sell".to_string(),
amount,
price,
quote_set_id: None,
post_only: None,
time_in_force: None,
}
}
pub fn with_quote_set_id(mut self, quote_set_id: String) -> Self {
self.quote_set_id = Some(quote_set_id);
self
}
pub fn with_post_only(mut self, post_only: bool) -> Self {
self.post_only = Some(post_only);
self
}
pub fn with_time_in_force(mut self, time_in_force: String) -> Self {
self.time_in_force = Some(time_in_force);
self
}
}
impl MassQuoteRequest {
pub fn new(mmp_group: String, quotes: Vec<Quote>) -> Self {
Self {
mmp_group,
quotes,
quote_id: None,
detailed: None,
}
}
pub fn with_quote_id(mut self, quote_id: String) -> Self {
self.quote_id = Some(quote_id);
self
}
pub fn with_detailed_errors(mut self) -> Self {
self.detailed = Some(true);
self
}
pub fn validate(&self) -> Result<(), String> {
if self.quotes.is_empty() {
return Err("Mass quote request must contain at least one quote".to_string());
}
if self.quotes.len() > 100 {
return Err("Mass quote request cannot contain more than 100 quotes".to_string());
}
let mut currencies = std::collections::HashSet::new();
for quote in &self.quotes {
let currency = quote
.instrument_name
.split('-')
.next()
.ok_or("Invalid instrument name format")?;
currencies.insert(currency);
}
if currencies.len() > 1 {
return Err(
"All quotes in a mass quote request must be for the same currency".to_string(),
);
}
let mut seen = std::collections::HashSet::new();
for quote in &self.quotes {
let key = ("e.instrument_name, "e.side, quote.price as u64);
if !seen.insert(key) {
return Err(format!(
"Duplicate quote found for {} {} at price {}",
quote.instrument_name, quote.side, quote.price
));
}
}
Ok(())
}
}
impl CancelQuotesRequest {
pub fn all() -> Self {
Self {
currency: None,
kind: None,
instrument_name: None,
quote_set_id: None,
delta_range: None,
}
}
pub fn by_currency(currency: String) -> Self {
Self {
currency: Some(currency),
kind: None,
instrument_name: None,
quote_set_id: None,
delta_range: None,
}
}
pub fn by_instrument(instrument_name: String) -> Self {
Self {
currency: None,
kind: None,
instrument_name: Some(instrument_name),
quote_set_id: None,
delta_range: None,
}
}
pub fn by_quote_set_id(quote_set_id: String) -> Self {
Self {
currency: None,
kind: None,
instrument_name: None,
quote_set_id: Some(quote_set_id),
delta_range: None,
}
}
pub fn by_delta_range(min_delta: f64, max_delta: f64) -> Self {
Self {
currency: None,
kind: None,
instrument_name: None,
quote_set_id: None,
delta_range: Some((min_delta, max_delta)),
}
}
}
impl MmpGroupConfig {
pub fn new(
mmp_group: String,
quantity_limit: f64,
delta_limit: f64,
interval: u64,
frozen_time: u64,
) -> Result<Self, String> {
if delta_limit >= quantity_limit {
return Err("Delta limit must be less than quantity limit".to_string());
}
let currency = mmp_group.split('_').next().unwrap_or("");
let max_limit = match currency.to_uppercase().as_str() {
"BTC" => 500.0,
"ETH" => 5000.0,
_ => 500.0, };
if quantity_limit > max_limit {
return Err(format!(
"Quantity limit {} exceeds maximum allowed {} for {}",
quantity_limit, max_limit, currency
));
}
Ok(Self {
mmp_group,
quantity_limit,
delta_limit,
interval,
frozen_time,
enabled: true,
})
}
pub fn disable(mut self) -> Self {
self.interval = 0;
self.enabled = false;
self
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_quote_creation() {
let quote = Quote::buy("BTC-PERPETUAL".to_string(), 1.0, 50000.0)
.with_quote_set_id("set1".to_string())
.with_post_only(true);
assert_eq!(quote.instrument_name, "BTC-PERPETUAL");
assert_eq!(quote.side, "buy");
assert_eq!(quote.amount, 1.0);
assert_eq!(quote.price, 50000.0);
assert_eq!(quote.quote_set_id, Some("set1".to_string()));
assert_eq!(quote.post_only, Some(true));
}
#[test]
fn test_mass_quote_validation() {
let quotes = vec![
Quote::buy("BTC-PERPETUAL".to_string(), 1.0, 50000.0),
Quote::sell("BTC-PERPETUAL".to_string(), 1.0, 51000.0),
];
let request = MassQuoteRequest::new("btc_group".to_string(), quotes);
assert!(request.validate().is_ok());
}
#[test]
fn test_mass_quote_validation_different_currencies() {
let quotes = vec![
Quote::buy("BTC-PERPETUAL".to_string(), 1.0, 50000.0),
Quote::sell("ETH-PERPETUAL".to_string(), 1.0, 3000.0),
];
let request = MassQuoteRequest::new("mixed_group".to_string(), quotes);
assert!(request.validate().is_err());
}
#[test]
fn test_mass_quote_validation_duplicate_quotes() {
let quotes = vec![
Quote::buy("BTC-PERPETUAL".to_string(), 1.0, 50000.0),
Quote::buy("BTC-PERPETUAL".to_string(), 2.0, 50000.0), ];
let request = MassQuoteRequest::new("btc_group".to_string(), quotes);
assert!(request.validate().is_err());
}
#[test]
fn test_mmp_group_config_validation() {
let config = MmpGroupConfig::new("btc_group".to_string(), 100.0, 50.0, 1000, 5000);
assert!(config.is_ok());
let invalid_config = MmpGroupConfig::new(
"btc_group".to_string(),
50.0,
100.0, 1000,
5000,
);
assert!(invalid_config.is_err());
}
#[test]
fn test_cancel_quotes_builders() {
let cancel_all = CancelQuotesRequest::all();
assert!(cancel_all.currency.is_none());
let cancel_btc = CancelQuotesRequest::by_currency("BTC".to_string());
assert_eq!(cancel_btc.currency, Some("BTC".to_string()));
let cancel_instrument = CancelQuotesRequest::by_instrument("BTC-PERPETUAL".to_string());
assert_eq!(
cancel_instrument.instrument_name,
Some("BTC-PERPETUAL".to_string())
);
let cancel_set = CancelQuotesRequest::by_quote_set_id("set1".to_string());
assert_eq!(cancel_set.quote_set_id, Some("set1".to_string()));
let cancel_delta = CancelQuotesRequest::by_delta_range(0.3, 0.7);
assert_eq!(cancel_delta.delta_range, Some((0.3, 0.7)));
}
}