use chrono::{DateTime, Duration, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
use crate::error::{CoreError, Result};
use crate::models::Trade;
use crate::trading::order_book::{LimitOrder, OrderSide};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchAuctionConfig {
pub batch_duration: Duration,
pub min_orders: usize,
pub max_price_deviation_pct: Decimal,
pub allow_partial_fills: bool,
}
impl Default for BatchAuctionConfig {
fn default() -> Self {
Self {
batch_duration: Duration::seconds(10),
min_orders: 2,
max_price_deviation_pct: dec!(20.0), allow_partial_fills: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BatchStatus {
Collecting,
Computing,
Executing,
Completed,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchAuction {
pub id: Uuid,
pub token_id: Uuid,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub status: BatchStatus,
pub buy_orders: Vec<LimitOrder>,
pub sell_orders: Vec<LimitOrder>,
pub clearing_price: Option<Decimal>,
pub matched_volume: Decimal,
pub config: BatchAuctionConfig,
}
impl BatchAuction {
pub fn new(token_id: Uuid, config: BatchAuctionConfig) -> Self {
let start_time = Utc::now();
let end_time = start_time + config.batch_duration;
Self {
id: Uuid::new_v4(),
token_id,
start_time,
end_time,
status: BatchStatus::Collecting,
buy_orders: Vec::new(),
sell_orders: Vec::new(),
clearing_price: None,
matched_volume: dec!(0),
config,
}
}
pub fn is_ended(&self) -> bool {
Utc::now() >= self.end_time
}
pub fn remaining_time(&self) -> Duration {
if self.is_ended() {
Duration::zero()
} else {
self.end_time - Utc::now()
}
}
pub fn add_order(&mut self, order: LimitOrder) -> Result<()> {
if self.status != BatchStatus::Collecting {
return Err(CoreError::InvalidState(format!(
"Batch is not collecting orders (status: {:?})",
self.status
)));
}
if order.token_id != self.token_id {
return Err(CoreError::Validation(format!(
"Order token {} does not match batch token {}",
order.token_id, self.token_id
)));
}
match order.side {
OrderSide::Buy => self.buy_orders.push(order),
OrderSide::Sell => self.sell_orders.push(order),
}
Ok(())
}
fn buy_volume_at_price(&self, price: Decimal) -> Decimal {
self.buy_orders
.iter()
.filter(|order| order.price >= price)
.map(|order| order.amount)
.sum()
}
fn sell_volume_at_price(&self, price: Decimal) -> Decimal {
self.sell_orders
.iter()
.filter(|order| order.price <= price)
.map(|order| order.amount)
.sum()
}
pub fn compute_clearing_price(&mut self, reference_price: Option<Decimal>) -> Result<Decimal> {
if self.status != BatchStatus::Collecting {
return Err(CoreError::InvalidState(
"Batch must be in Collecting state".to_string(),
));
}
let total_orders = self.buy_orders.len() + self.sell_orders.len();
if total_orders < self.config.min_orders {
self.status = BatchStatus::Cancelled;
return Err(CoreError::InsufficientLiquidity(format!(
"Insufficient orders: {} < {}",
total_orders, self.config.min_orders
)));
}
self.status = BatchStatus::Computing;
let mut prices: Vec<Decimal> = self
.buy_orders
.iter()
.chain(self.sell_orders.iter())
.map(|order| order.price)
.collect();
prices.sort();
prices.dedup();
if let Some(ref_price) = reference_price {
let max_deviation = (self.config.max_price_deviation_pct / dec!(100)) * ref_price;
let min_price = ref_price - max_deviation;
let max_price = ref_price + max_deviation;
prices.retain(|&p| p >= min_price && p <= max_price);
if prices.is_empty() {
self.status = BatchStatus::Cancelled;
return Err(CoreError::Validation(
"No prices within acceptable deviation".to_string(),
));
}
}
let mut best_price = dec!(0);
let mut max_volume = dec!(0);
for &price in &prices {
let buy_vol = self.buy_volume_at_price(price);
let sell_vol = self.sell_volume_at_price(price);
let matched_vol = buy_vol.min(sell_vol);
if matched_vol > max_volume {
max_volume = matched_vol;
best_price = price;
}
}
if max_volume.is_zero() {
self.status = BatchStatus::Cancelled;
return Err(CoreError::InsufficientLiquidity(
"No overlapping buy/sell orders".to_string(),
));
}
self.clearing_price = Some(best_price);
self.matched_volume = max_volume;
Ok(best_price)
}
pub fn execute(&mut self) -> Result<Vec<BatchMatch>> {
let clearing_price = self
.clearing_price
.ok_or_else(|| CoreError::InvalidState("Clearing price not computed".to_string()))?;
if self.status != BatchStatus::Computing {
return Err(CoreError::InvalidState(
"Batch must be in Computing state".to_string(),
));
}
self.status = BatchStatus::Executing;
let mut eligible_buys: Vec<&LimitOrder> = self
.buy_orders
.iter()
.filter(|order| order.price >= clearing_price)
.collect();
let mut eligible_sells: Vec<&LimitOrder> = self
.sell_orders
.iter()
.filter(|order| order.price <= clearing_price)
.collect();
eligible_buys.sort_by(|a, b| {
b.price
.cmp(&a.price)
.then_with(|| a.timestamp.cmp(&b.timestamp))
});
eligible_sells.sort_by(|a, b| {
a.price
.cmp(&b.price)
.then_with(|| a.timestamp.cmp(&b.timestamp))
});
let mut matches = Vec::new();
let mut buy_idx = 0;
let mut sell_idx = 0;
let mut buy_filled = dec!(0);
let mut sell_filled = dec!(0);
while buy_idx < eligible_buys.len() && sell_idx < eligible_sells.len() {
let buy_order = eligible_buys[buy_idx];
let sell_order = eligible_sells[sell_idx];
let buy_remaining = buy_order.amount - buy_filled;
let sell_remaining = sell_order.amount - sell_filled;
if buy_remaining.is_zero() {
buy_idx += 1;
buy_filled = dec!(0);
continue;
}
if sell_remaining.is_zero() {
sell_idx += 1;
sell_filled = dec!(0);
continue;
}
let match_amount = buy_remaining.min(sell_remaining);
matches.push(BatchMatch {
buy_order_id: buy_order.order_id,
sell_order_id: sell_order.order_id,
buyer_id: buy_order.user_id,
seller_id: sell_order.user_id,
amount: match_amount,
price: clearing_price,
timestamp: Utc::now(),
});
buy_filled += match_amount;
sell_filled += match_amount;
if buy_filled >= buy_order.amount {
buy_idx += 1;
buy_filled = dec!(0);
}
if sell_filled >= sell_order.amount {
sell_idx += 1;
sell_filled = dec!(0);
}
}
self.status = BatchStatus::Completed;
Ok(matches)
}
pub fn stats(&self) -> BatchStats {
BatchStats {
batch_id: self.id,
token_id: self.token_id,
total_buy_orders: self.buy_orders.len(),
total_sell_orders: self.sell_orders.len(),
total_buy_volume: self.buy_orders.iter().map(|o| o.amount).sum(),
total_sell_volume: self.sell_orders.iter().map(|o| o.amount).sum(),
clearing_price: self.clearing_price,
matched_volume: self.matched_volume,
status: self.status,
start_time: self.start_time,
end_time: self.end_time,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchMatch {
pub buy_order_id: Uuid,
pub sell_order_id: Uuid,
pub buyer_id: Uuid,
pub seller_id: Uuid,
pub amount: Decimal,
pub price: Decimal,
pub timestamp: DateTime<Utc>,
}
impl BatchMatch {
pub fn to_trade(&self, token_id: Uuid) -> Trade {
Trade {
trade_id: Uuid::new_v4(),
buyer_user_id: self.buyer_id,
seller_user_id: Some(self.seller_id),
token_id,
amount: self.amount,
price_btc: self.price,
total_btc: self.amount * self.price,
platform_fee_btc: dec!(0), issuer_royalty_btc: dec!(0),
executed_at: self.timestamp,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchStats {
pub batch_id: Uuid,
pub token_id: Uuid,
pub total_buy_orders: usize,
pub total_sell_orders: usize,
pub total_buy_volume: Decimal,
pub total_sell_volume: Decimal,
pub clearing_price: Option<Decimal>,
pub matched_volume: Decimal,
pub status: BatchStatus,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
}
impl BatchStats {
pub fn match_rate(&self) -> Decimal {
let total_volume = self.total_buy_volume.min(self.total_sell_volume);
if total_volume.is_zero() {
dec!(0)
} else {
(self.matched_volume / total_volume) * dec!(100)
}
}
pub fn is_successful(&self) -> bool {
self.status == BatchStatus::Completed && !self.matched_volume.is_zero()
}
}
pub struct BatchAuctionManager {
active_batches: HashMap<Uuid, BatchAuction>,
completed_batches: Vec<BatchAuction>,
default_config: BatchAuctionConfig,
}
impl BatchAuctionManager {
pub fn new(config: BatchAuctionConfig) -> Self {
Self {
active_batches: HashMap::new(),
completed_batches: Vec::new(),
default_config: config,
}
}
pub fn start_batch(&mut self, token_id: Uuid) -> Result<Uuid> {
if self.active_batches.contains_key(&token_id) {
return Err(CoreError::AlreadyExists(format!(
"Batch already active for token {}",
token_id
)));
}
let batch = BatchAuction::new(token_id, self.default_config.clone());
let batch_id = batch.id;
self.active_batches.insert(token_id, batch);
Ok(batch_id)
}
pub fn add_order(&mut self, order: LimitOrder) -> Result<()> {
let batch = self
.active_batches
.get_mut(&order.token_id)
.ok_or_else(|| {
CoreError::NotFound(format!("No active batch for token {}", order.token_id))
})?;
batch.add_order(order)
}
pub fn execute_batch(
&mut self,
token_id: Uuid,
reference_price: Option<Decimal>,
) -> Result<Vec<BatchMatch>> {
let mut batch = self.active_batches.remove(&token_id).ok_or_else(|| {
CoreError::NotFound(format!("No active batch for token {}", token_id))
})?;
batch.compute_clearing_price(reference_price)?;
let matches = batch.execute()?;
self.completed_batches.push(batch);
Ok(matches)
}
pub fn get_active_batch(&self, token_id: &Uuid) -> Option<&BatchAuction> {
self.active_batches.get(token_id)
}
pub fn get_completed_stats(&self) -> Vec<BatchStats> {
self.completed_batches.iter().map(|b| b.stats()).collect()
}
pub fn cleanup_old_batches(&mut self, keep_last: usize) {
if self.completed_batches.len() > keep_last {
self.completed_batches
.drain(0..self.completed_batches.len() - keep_last);
}
}
}
impl Default for BatchAuctionManager {
fn default() -> Self {
Self::new(BatchAuctionConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_order(
token_id: Uuid,
user_id: Uuid,
side: OrderSide,
price: Decimal,
amount: Decimal,
) -> LimitOrder {
LimitOrder {
order_id: Uuid::new_v4(),
token_id,
user_id,
side,
price,
amount,
filled_amount: dec!(0),
timestamp: Utc::now().timestamp_millis(),
}
}
#[test]
fn test_batch_auction_creation() {
let token_id = Uuid::new_v4();
let config = BatchAuctionConfig::default();
let batch = BatchAuction::new(token_id, config);
assert_eq!(batch.token_id, token_id);
assert_eq!(batch.status, BatchStatus::Collecting);
assert!(batch.buy_orders.is_empty());
assert!(batch.sell_orders.is_empty());
}
#[test]
fn test_add_order() {
let token_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let mut batch = BatchAuction::new(token_id, BatchAuctionConfig::default());
let buy_order = create_test_order(token_id, user_id, OrderSide::Buy, dec!(100), dec!(10));
let sell_order = create_test_order(token_id, user_id, OrderSide::Sell, dec!(105), dec!(5));
batch.add_order(buy_order).unwrap();
batch.add_order(sell_order).unwrap();
assert_eq!(batch.buy_orders.len(), 1);
assert_eq!(batch.sell_orders.len(), 1);
}
#[test]
fn test_clearing_price_computation() {
let token_id = Uuid::new_v4();
let mut batch = BatchAuction::new(token_id, BatchAuctionConfig::default());
batch
.add_order(create_test_order(
token_id,
Uuid::new_v4(),
OrderSide::Buy,
dec!(105),
dec!(10),
))
.unwrap();
batch
.add_order(create_test_order(
token_id,
Uuid::new_v4(),
OrderSide::Buy,
dec!(100),
dec!(15),
))
.unwrap();
batch
.add_order(create_test_order(
token_id,
Uuid::new_v4(),
OrderSide::Sell,
dec!(95),
dec!(8),
))
.unwrap();
batch
.add_order(create_test_order(
token_id,
Uuid::new_v4(),
OrderSide::Sell,
dec!(102),
dec!(12),
))
.unwrap();
let clearing_price = batch.compute_clearing_price(None).unwrap();
assert!(clearing_price >= dec!(95) && clearing_price <= dec!(105));
assert!(batch.matched_volume > dec!(0));
}
#[test]
fn test_batch_execution() {
let token_id = Uuid::new_v4();
let mut batch = BatchAuction::new(token_id, BatchAuctionConfig::default());
batch
.add_order(create_test_order(
token_id,
Uuid::new_v4(),
OrderSide::Buy,
dec!(100),
dec!(10),
))
.unwrap();
batch
.add_order(create_test_order(
token_id,
Uuid::new_v4(),
OrderSide::Sell,
dec!(100),
dec!(10),
))
.unwrap();
batch.compute_clearing_price(None).unwrap();
let matches = batch.execute().unwrap();
assert!(!matches.is_empty());
assert_eq!(batch.status, BatchStatus::Completed);
}
#[test]
fn test_batch_manager() {
let mut manager = BatchAuctionManager::default();
let token_id = Uuid::new_v4();
let batch_id = manager.start_batch(token_id).unwrap();
assert!(batch_id != Uuid::nil());
let order = create_test_order(
token_id,
Uuid::new_v4(),
OrderSide::Buy,
dec!(100),
dec!(10),
);
manager.add_order(order).unwrap();
assert!(manager.get_active_batch(&token_id).is_some());
}
#[test]
fn test_insufficient_orders() {
let token_id = Uuid::new_v4();
let mut batch = BatchAuction::new(token_id, BatchAuctionConfig::default());
batch
.add_order(create_test_order(
token_id,
Uuid::new_v4(),
OrderSide::Buy,
dec!(100),
dec!(10),
))
.unwrap();
let result = batch.compute_clearing_price(None);
assert!(result.is_err());
assert_eq!(batch.status, BatchStatus::Cancelled);
}
#[test]
fn test_batch_stats() {
let token_id = Uuid::new_v4();
let mut batch = BatchAuction::new(token_id, BatchAuctionConfig::default());
batch
.add_order(create_test_order(
token_id,
Uuid::new_v4(),
OrderSide::Buy,
dec!(100),
dec!(10),
))
.unwrap();
batch
.add_order(create_test_order(
token_id,
Uuid::new_v4(),
OrderSide::Sell,
dec!(100),
dec!(5),
))
.unwrap();
let stats = batch.stats();
assert_eq!(stats.total_buy_orders, 1);
assert_eq!(stats.total_sell_orders, 1);
assert_eq!(stats.total_buy_volume, dec!(10));
assert_eq!(stats.total_sell_volume, dec!(5));
}
}