use std::fmt;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::shared::models::{
SATS_PER_BTC,
client_id::ClientId,
cross_leverage::CrossLeverage,
error::MarginValidationError,
leverage::Leverage,
margin::Margin,
price::Price,
quantity::cross::CrossQuantity,
quantity::order::OrderQuantity,
serde_util,
trade::{
TradeExecution, TradeExecutionType, TradeSide, TradeSize, util::est_liquidation_from_margin,
},
};
use super::error::{CrossExposureValidationError, FuturesIsolatedTradeRequestValidationError};
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(in crate::rest::v3) struct FuturesIsolatedTradeRequestBody {
leverage: Leverage,
side: TradeSide,
#[serde(skip_serializing_if = "Option::is_none")]
stoploss: Option<Price>,
#[serde(skip_serializing_if = "Option::is_none")]
takeprofit: Option<Price>,
#[serde(skip_serializing_if = "Option::is_none")]
client_id: Option<ClientId>,
#[serde(flatten)]
size: TradeSize,
#[serde(rename = "type")]
trade_type: TradeExecutionType,
#[serde(skip_serializing_if = "Option::is_none")]
price: Option<Price>,
}
impl FuturesIsolatedTradeRequestBody {
pub fn new(
leverage: Leverage,
stoploss: Option<Price>,
takeprofit: Option<Price>,
side: TradeSide,
client_id: Option<ClientId>,
size: TradeSize,
trade_execution: TradeExecution,
) -> Result<Self, FuturesIsolatedTradeRequestValidationError> {
if let TradeExecution::Limit(price) = trade_execution {
if let TradeSize::Margin(margin) = &size {
let _ = OrderQuantity::try_calculate(*margin, price, leverage)?;
}
if let Some(stoploss) = stoploss
&& stoploss >= price
{
return Err(FuturesIsolatedTradeRequestValidationError::StopLossHigherThanPrice);
}
if let Some(takeprofit) = takeprofit
&& takeprofit <= price
{
return Err(FuturesIsolatedTradeRequestValidationError::TakeProfitLowerThanPrice);
}
}
let (trade_type, price) = match trade_execution {
TradeExecution::Market => (TradeExecutionType::Market, None),
TradeExecution::Limit(price) => (TradeExecutionType::Limit, Some(price)),
};
Ok(FuturesIsolatedTradeRequestBody {
leverage,
stoploss,
takeprofit,
side,
client_id,
size,
trade_type,
price,
})
}
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Trade {
id: Uuid,
#[serde(rename = "type")]
trade_type: TradeExecutionType,
side: TradeSide,
opening_fee: u64,
closing_fee: u64,
maintenance_margin: i64,
quantity: OrderQuantity,
margin: Margin,
leverage: Leverage,
price: Price,
liquidation: Price,
#[serde(with = "serde_util::price_option")]
stoploss: Option<Price>,
#[serde(with = "serde_util::price_option")]
takeprofit: Option<Price>,
#[serde(with = "serde_util::price_option")]
exit_price: Option<Price>,
pl: i64,
created_at: DateTime<Utc>,
filled_at: Option<DateTime<Utc>>,
closed_at: Option<DateTime<Utc>>,
#[serde(with = "serde_util::price_option")]
entry_price: Option<Price>,
entry_margin: Option<Margin>,
open: bool,
running: bool,
canceled: bool,
closed: bool,
sum_funding_fees: i64,
#[serde(with = "serde_util::client_id_option")]
client_id: Option<ClientId>,
}
impl Trade {
pub fn id(&self) -> Uuid {
self.id
}
pub fn trade_type(&self) -> TradeExecutionType {
self.trade_type
}
pub fn side(&self) -> TradeSide {
self.side
}
pub fn opening_fee(&self) -> u64 {
self.opening_fee
}
pub fn closing_fee(&self) -> u64 {
self.closing_fee
}
pub fn maintenance_margin(&self) -> i64 {
self.maintenance_margin
}
pub fn quantity(&self) -> OrderQuantity {
self.quantity
}
pub fn margin(&self) -> Margin {
self.margin
}
pub fn leverage(&self) -> Leverage {
self.leverage
}
pub fn price(&self) -> Price {
self.price
}
pub fn liquidation(&self) -> Price {
self.liquidation
}
pub fn stoploss(&self) -> Option<Price> {
self.stoploss
}
pub fn takeprofit(&self) -> Option<Price> {
self.takeprofit
}
pub fn exit_price(&self) -> Option<Price> {
self.exit_price
}
pub fn pl(&self) -> i64 {
self.pl
}
pub fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
pub fn filled_at(&self) -> Option<DateTime<Utc>> {
self.filled_at
}
pub fn closed_at(&self) -> Option<DateTime<Utc>> {
self.closed_at
}
pub fn entry_price(&self) -> Option<Price> {
self.entry_price
}
pub fn entry_margin(&self) -> Option<Margin> {
self.entry_margin
}
pub fn open(&self) -> bool {
self.open
}
pub fn running(&self) -> bool {
self.running
}
pub fn canceled(&self) -> bool {
self.canceled
}
pub fn closed(&self) -> bool {
self.closed
}
pub fn sum_funding_fees(&self) -> i64 {
self.sum_funding_fees
}
pub fn client_id(&self) -> Option<&ClientId> {
self.client_id.as_ref()
}
pub fn as_data_str(&self) -> String {
let mut data_str = format!(
"id: {}\nside: {}\nopen: {}\nrunning: {}\ncanceled: {}\nclosed: {}\nquantity: {}\nmargin: {}\nleverage: {}\nprice: {}\nliquidation: {}\npl: {}\ncreated_at: {}",
self.id,
self.side,
self.open,
self.running,
self.canceled,
self.closed,
self.quantity,
self.margin,
self.leverage,
self.price,
self.liquidation,
self.pl,
self.created_at.to_rfc3339()
);
if let Some(entry_price) = self.entry_price {
data_str.push_str(&format!("\nentry_price: {entry_price}"));
}
if let Some(exit_price) = self.exit_price {
data_str.push_str(&format!("\nexit_price: {exit_price}"));
}
if let Some(stoploss) = self.stoploss {
data_str.push_str(&format!("\nstoploss: {stoploss}"));
}
if let Some(takeprofit) = self.takeprofit {
data_str.push_str(&format!("\ntakeprofit: {takeprofit}"));
}
if let Some(client_id) = &self.client_id {
data_str.push_str(&format!("\nclient_id: {client_id}"));
}
data_str
}
}
impl fmt::Display for Trade {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Trade:")?;
for line in self.as_data_str().lines() {
write!(f, "\n {line}")?;
}
Ok(())
}
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(in crate::rest::v3) struct FuturesCrossOrderBody {
side: TradeSide,
quantity: OrderQuantity,
#[serde(rename = "type")]
trade_type: TradeExecutionType,
#[serde(skip_serializing_if = "Option::is_none")]
price: Option<Price>,
#[serde(skip_serializing_if = "Option::is_none")]
client_id: Option<ClientId>,
}
impl FuturesCrossOrderBody {
pub fn new(
side: TradeSide,
quantity: OrderQuantity,
execution: TradeExecution,
client_id: Option<ClientId>,
) -> Self {
let (trade_type, price) = match execution {
TradeExecution::Market => (TradeExecutionType::Market, None),
TradeExecution::Limit(price) => (TradeExecutionType::Limit, Some(price)),
};
Self {
side,
quantity,
trade_type,
price,
client_id,
}
}
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CrossOrder {
id: Uuid,
#[serde(rename = "type")]
trade_type: TradeExecutionType,
side: TradeSide,
quantity: OrderQuantity,
price: Price,
trading_fee: u64,
created_at: DateTime<Utc>,
filled_at: Option<DateTime<Utc>>,
canceled_at: Option<DateTime<Utc>>,
open: bool,
filled: bool,
canceled: bool,
#[serde(with = "serde_util::client_id_option")]
client_id: Option<ClientId>,
}
impl CrossOrder {
pub fn id(&self) -> Uuid {
self.id
}
pub fn trade_type(&self) -> TradeExecutionType {
self.trade_type
}
pub fn side(&self) -> TradeSide {
self.side
}
pub fn quantity(&self) -> OrderQuantity {
self.quantity
}
pub fn price(&self) -> Price {
self.price
}
pub fn trading_fee(&self) -> u64 {
self.trading_fee
}
pub fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
pub fn filled_at(&self) -> Option<DateTime<Utc>> {
self.filled_at
}
pub fn canceled_at(&self) -> Option<DateTime<Utc>> {
self.canceled_at
}
pub fn open(&self) -> bool {
self.open
}
pub fn filled(&self) -> bool {
self.filled
}
pub fn canceled(&self) -> bool {
self.canceled
}
pub fn client_id(&self) -> Option<&ClientId> {
self.client_id.as_ref()
}
pub fn as_data_str(&self) -> String {
let mut result = format!(
"id: {}\nside: {}\nopen: {}\nfilled: {}\ncanceled: {}\nquantity: {}\nprice: {}\ntrading_fee: {}\ncreated_at: {}",
self.id,
self.side,
self.open,
self.filled,
self.canceled,
self.quantity,
self.price,
self.trading_fee,
self.created_at.to_rfc3339()
);
if let Some(filled_at) = self.filled_at {
result.push_str(&format!("\nfilled_at: {}", filled_at.to_rfc3339()));
}
if let Some(canceled_at) = self.canceled_at {
result.push_str(&format!("\ncanceled_at: {}", canceled_at.to_rfc3339()));
}
if let Some(client_id) = &self.client_id {
result.push_str(&format!("\nclient_id: {}", client_id));
}
result
}
}
impl fmt::Display for CrossOrder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Cross Order:")?;
for line in self.as_data_str().lines() {
write!(f, "\n {line}")?;
}
Ok(())
}
}
const CROSS_MAINTENANCE_MARGIN_RATE: f64 = 0.0015;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CrossExposureRunning {
side: TradeSide,
quantity: CrossQuantity,
entry_price: Price,
liquidation: Price,
running_margin: Margin,
maintenance_margin: Margin,
}
impl CrossExposureRunning {
fn new<M>(
margin: M,
leverage: CrossLeverage,
side: TradeSide,
quantity: CrossQuantity,
entry_price: Price,
) -> Result<Self, CrossExposureValidationError>
where
M: TryInto<Margin, Error = MarginValidationError>,
{
let margin = margin
.try_into()
.map_err(CrossExposureValidationError::CrossMargin)?;
let max_qtd = CrossQuantity::max(leverage);
if quantity > max_qtd {
return Err(
CrossExposureValidationError::CrossQuantityTooHighForLeverage {
qtd: quantity,
max_qtd,
leverage,
},
);
}
let notional_sats = quantity.as_f64() * SATS_PER_BTC / entry_price.as_f64();
let running_margin = Margin::bounded((notional_sats / leverage.as_f64()).floor());
let maintenance_margin =
Margin::bounded((notional_sats * CROSS_MAINTENANCE_MARGIN_RATE).floor());
let liquidation_margin = margin
.try_sub(maintenance_margin)
.map_err(|_| CrossExposureValidationError::CrossMarginTooLow)?;
if running_margin >= liquidation_margin {
return Err(CrossExposureValidationError::CrossMarginTooLow);
}
let liquidation =
est_liquidation_from_margin(side, quantity, entry_price, liquidation_margin);
Ok(Self {
side,
quantity,
entry_price,
liquidation,
running_margin,
maintenance_margin,
})
}
pub fn side(&self) -> TradeSide {
self.side
}
pub fn quantity(&self) -> CrossQuantity {
self.quantity
}
pub fn entry_price(&self) -> Price {
self.entry_price
}
pub fn liquidation(&self) -> Price {
self.liquidation
}
pub fn running_margin(&self) -> Margin {
self.running_margin
}
pub fn maintenance_margin(&self) -> Margin {
self.maintenance_margin
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CrossExposure {
Neutral,
Running(CrossExposureRunning),
}
impl CrossExposure {
pub fn new(
margin: u64,
leverage: CrossLeverage,
exposure_running: Option<(TradeSide, CrossQuantity, Price)>,
) -> Result<Self, CrossExposureValidationError> {
match exposure_running {
None => Ok(Self::Neutral),
Some((side, quantity, entry_price)) => Ok(Self::Running(CrossExposureRunning::new(
margin,
leverage,
side,
quantity,
entry_price,
)?)),
}
}
pub fn running(
margin: u64,
leverage: CrossLeverage,
side: TradeSide,
quantity: CrossQuantity,
entry_price: Price,
) -> Result<Self, CrossExposureValidationError> {
Ok(Self::Running(CrossExposureRunning::new(
margin,
leverage,
side,
quantity,
entry_price,
)?))
}
pub fn as_running_params(&self) -> Option<(TradeSide, CrossQuantity, Price)> {
match self {
CrossExposure::Neutral => None,
CrossExposure::Running(exposure_running) => Some((
exposure_running.side(),
exposure_running.quantity(),
exposure_running.entry_price(),
)),
}
}
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CrossPosition {
id: Uuid,
margin: u64,
quantity: i64,
leverage: CrossLeverage,
entry_price: Option<Price>,
running_margin: u64,
initial_margin: u64,
maintenance_margin: u64,
liquidation: Option<Price>,
trading_fees: u64,
funding_fees: i64,
total_pl: i64,
delta_pl: i64,
}
impl CrossPosition {
pub fn id(&self) -> Uuid {
self.id
}
pub fn margin(&self) -> u64 {
self.margin
}
pub fn quantity(&self) -> i64 {
self.quantity
}
pub fn exposure(&self) -> Result<CrossExposure, CrossExposureValidationError> {
if self.quantity == 0 {
return Ok(CrossExposure::Neutral);
}
let side = if self.quantity > 0 {
TradeSide::Buy
} else {
TradeSide::Sell
};
let quantity = CrossQuantity::try_from(self.quantity.unsigned_abs())?;
let entry_price = self
.entry_price
.ok_or(CrossExposureValidationError::MissingEntryPrice)?;
CrossExposure::running(self.margin, self.leverage, side, quantity, entry_price)
}
pub fn leverage(&self) -> CrossLeverage {
self.leverage
}
pub fn entry_price(&self) -> Option<Price> {
self.entry_price
}
pub fn running_margin(&self) -> u64 {
self.running_margin
}
pub fn initial_margin(&self) -> u64 {
self.initial_margin
}
pub fn maintenance_margin(&self) -> u64 {
self.maintenance_margin
}
pub fn liquidation(&self) -> Option<Price> {
self.liquidation
}
pub fn trading_fees(&self) -> u64 {
self.trading_fees
}
pub fn funding_fees(&self) -> i64 {
self.funding_fees
}
pub fn total_pl(&self) -> i64 {
self.total_pl
}
pub fn delta_pl(&self) -> i64 {
self.delta_pl
}
pub fn as_data_str(&self) -> String {
let mut data_str = format!(
"id: {}\nmargin: {}\nquantity: {}\nleverage: {}\nrunning_margin: {}\ninitial_margin: {}\nmaintenance_margin: {}\ntrading_fees: {}\nfunding_fees: {}\ntotal_pl: {}\ndelta_pl: {}",
self.id,
self.margin,
self.quantity,
self.leverage,
self.running_margin,
self.initial_margin,
self.maintenance_margin,
self.trading_fees,
self.funding_fees,
self.total_pl,
self.delta_pl
);
if let Some(entry_price) = self.entry_price {
data_str.push_str(&format!("\nentry_price: {entry_price}"));
}
if let Some(liquidation) = self.liquidation {
data_str.push_str(&format!("\nliquidation: {liquidation}"));
}
data_str
}
}
impl fmt::Display for CrossPosition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Cross Position:")?;
for line in self.as_data_str().lines() {
write!(f, "\n {line}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cross_position(quantity: i64, margin: u64, entry_price: Option<Price>) -> CrossPosition {
CrossPosition {
id: Uuid::nil(),
margin,
quantity,
leverage: CrossLeverage::try_from(10_u64).unwrap(),
entry_price,
running_margin: 0,
initial_margin: 0,
maintenance_margin: 0,
liquidation: None,
trading_fees: 0,
funding_fees: 0,
total_pl: 0,
delta_pl: 0,
}
}
#[test]
fn test_cross_exposure_running_calculates_margin_requirements() {
let quantity = CrossQuantity::try_from(1_000).unwrap();
let entry_price = Price::try_from(100_000).unwrap();
let exposure = CrossExposure::running(
500_000,
CrossLeverage::try_from(10_u64).unwrap(),
TradeSide::Buy,
quantity,
entry_price,
)
.unwrap();
let CrossExposure::Running(running) = exposure else {
panic!("expected running exposure");
};
assert_eq!(running.side(), TradeSide::Buy);
assert_eq!(running.quantity(), quantity);
assert_eq!(running.entry_price(), entry_price);
assert_eq!(running.liquidation(), Price::try_from(66_733.5).unwrap());
assert_eq!(running.running_margin(), Margin::try_from(100_000).unwrap());
assert_eq!(
running.maintenance_margin(),
Margin::try_from(1_500).unwrap()
);
assert_eq!(
exposure.as_running_params(),
Some((TradeSide::Buy, quantity, entry_price))
);
}
#[test]
fn test_cross_exposure_running_margin_floors_fractional_notional() {
let exposure = CrossExposure::running(
500_000,
CrossLeverage::try_from(1_u64).unwrap(),
TradeSide::Buy,
CrossQuantity::try_from(2).unwrap(),
Price::try_from(66_834).unwrap(),
)
.unwrap();
let CrossExposure::Running(running) = exposure else {
panic!("expected running exposure");
};
assert_eq!(running.running_margin(), Margin::try_from(2_992).unwrap());
}
#[test]
fn test_cross_exposure_rejects_insufficient_margin() {
let exposure = CrossExposure::running(
101_500,
CrossLeverage::try_from(10_u64).unwrap(),
TradeSide::Buy,
CrossQuantity::try_from(1_000).unwrap(),
Price::try_from(100_000).unwrap(),
);
assert!(matches!(
exposure,
Err(CrossExposureValidationError::CrossMarginTooLow)
));
}
#[test]
fn test_cross_exposure_rejects_invalid_cross_margin() {
let margin = Margin::MAX.as_u64() + 1;
let error = CrossExposure::running(
margin,
CrossLeverage::try_from(10_u64).unwrap(),
TradeSide::Buy,
CrossQuantity::try_from(1_000).unwrap(),
Price::try_from(100_000).unwrap(),
)
.err()
.unwrap();
assert!(matches!(
error,
CrossExposureValidationError::CrossMargin(MarginValidationError::TooHigh { value })
if value == margin as u128
));
}
#[test]
fn test_cross_position_exposure_returns_neutral_for_zero_quantity() {
let position = cross_position(0, 0, None);
assert_eq!(position.exposure().unwrap(), CrossExposure::Neutral);
}
#[test]
fn test_cross_position_exposure_rejects_quantity_above_leverage_max() {
let mut position =
cross_position(10_000_005, 500_000, Some(Price::try_from(100_000).unwrap()));
position.leverage = CrossLeverage::try_from(100_u64).unwrap();
let error = position.exposure().unwrap_err();
assert!(matches!(
error,
CrossExposureValidationError::CrossQuantityTooHighForLeverage {
qtd,
max_qtd,
leverage,
} if qtd == CrossQuantity::try_from(10_000_005).unwrap()
&& max_qtd == CrossQuantity::try_from(10_000_000).unwrap()
&& leverage == CrossLeverage::try_from(100_u64).unwrap()
));
}
#[test]
fn test_cross_position_exposure_derives_running_short() {
let entry_price = Price::try_from(100_000).unwrap();
let position = cross_position(-1_000, 500_000, Some(entry_price));
let exposure = position.exposure().unwrap();
let CrossExposure::Running(running) = exposure else {
panic!("expected running exposure");
};
assert_eq!(running.side(), TradeSide::Sell);
assert_eq!(running.quantity(), CrossQuantity::try_from(1_000).unwrap());
assert_eq!(running.entry_price(), entry_price);
assert_eq!(running.liquidation(), Price::try_from(199_402).unwrap());
}
#[test]
fn test_cross_order_deserializes_liquidation_type() {
let json = r#"{
"id": "be4f36fe-55ea-4f77-838d-d1df26f216e1",
"type": "liquidation",
"side": "buy",
"quantity": 10,
"price": 77055,
"tradingFee": 12,
"createdAt": "2026-04-22T11:07:19.867Z",
"filledAt": "2026-04-22T11:07:19.867Z",
"canceledAt": null,
"open": false,
"filled": true,
"canceled": false,
"clientId": null
}"#;
let order: CrossOrder = serde_json::from_str(json).expect("must deserialize");
assert_eq!(order.trade_type(), TradeExecutionType::Liquidation);
}
}