use bot_core::{InstrumentId, OrderSide, PositionSnapshot, Price};
use rust_decimal::Decimal;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct IsolatedPosition {
pub qty: Decimal,
pub avg_entry_px: Decimal,
pub leverage: Decimal,
pub isolated_margin_reserved: Decimal,
pub maintenance_margin_rate: Decimal,
pub realized_pnl: Decimal,
pub fee_paid: Decimal,
}
impl Default for IsolatedPosition {
fn default() -> Self {
Self {
qty: Decimal::ZERO,
avg_entry_px: Decimal::ZERO,
leverage: Decimal::ONE,
isolated_margin_reserved: Decimal::ZERO,
maintenance_margin_rate: Decimal::ONE, realized_pnl: Decimal::ZERO,
fee_paid: Decimal::ZERO,
}
}
}
impl IsolatedPosition {
pub fn unrealized_pnl(&self, mark_price: Decimal) -> Decimal {
if self.qty.is_zero() {
return Decimal::ZERO;
}
self.qty * (mark_price - self.avg_entry_px)
}
pub fn liquidation_price(&self) -> Option<Decimal> {
if self.qty.is_zero() {
return None;
}
let side_val = if self.qty > Decimal::ZERO {
Decimal::ONE
} else {
-Decimal::ONE
};
let mmr = self.maintenance_margin_rate;
let denominator = Decimal::ONE - mmr * side_val;
if denominator.is_zero() {
return None;
}
let margin_available =
self.isolated_margin_reserved - self.qty.abs() * self.avg_entry_px * mmr;
Some(self.avg_entry_px - side_val * margin_available / self.qty.abs() / denominator)
}
pub fn required_margin_for_order(
notional: Decimal,
leverage: Decimal,
fee_rate: Decimal,
) -> Decimal {
let margin = notional / leverage;
let fee_buffer = notional * fee_rate * Decimal::TWO; margin + fee_buffer
}
pub fn is_liquidated(&self, mark_price: Decimal) -> bool {
if self.qty.is_zero() {
return false;
}
if let Some(liq_price) = self.liquidation_price() {
if self.qty > Decimal::ZERO {
mark_price <= liq_price
} else {
mark_price >= liq_price
}
} else {
false
}
}
pub fn is_order_reducing(&self, order_side: OrderSide) -> bool {
if self.qty.is_zero() {
return false;
}
match order_side {
OrderSide::Buy => self.qty < Decimal::ZERO, OrderSide::Sell => self.qty > Decimal::ZERO, }
}
}
#[derive(Debug)]
pub struct MarginLedger {
free_usdc: Decimal,
positions: HashMap<InstrumentId, IsolatedPosition>,
leverage_settings: HashMap<InstrumentId, (Decimal, Decimal)>,
default_leverage: Decimal,
fee_rate: Decimal,
}
impl MarginLedger {
pub fn new(starting_balance: Decimal, fee_rate: Decimal) -> Self {
Self {
free_usdc: starting_balance,
positions: HashMap::new(),
leverage_settings: HashMap::new(),
default_leverage: Decimal::ONE, fee_rate,
}
}
pub fn free_usdc(&self) -> Decimal {
self.free_usdc
}
pub fn set_free_usdc(&mut self, amount: Decimal) {
self.free_usdc = amount;
}
pub fn adjust_free_usdc(&mut self, delta: Decimal) {
self.free_usdc += delta;
}
pub fn fee_rate(&self) -> Decimal {
self.fee_rate
}
pub fn set_fee_rate(&mut self, fee_rate: Decimal) {
self.fee_rate = fee_rate;
}
pub fn position(&self, instrument: &InstrumentId) -> Option<&IsolatedPosition> {
self.positions.get(instrument)
}
pub fn position_mut(&mut self, instrument: &InstrumentId) -> Option<&mut IsolatedPosition> {
self.positions.get_mut(instrument)
}
fn position_or_default(&mut self, instrument: &InstrumentId) -> &mut IsolatedPosition {
self.positions.entry(instrument.clone()).or_default()
}
pub fn leverage_for(&self, instrument: &InstrumentId) -> Decimal {
self.leverage_settings
.get(instrument)
.map(|(lev, _)| *lev)
.unwrap_or(self.default_leverage)
}
pub fn total_reserved_margin(&self) -> Decimal {
self.positions
.values()
.map(|p| p.isolated_margin_reserved)
.sum()
}
pub fn total_unrealized_pnl(&self, marks: &HashMap<InstrumentId, Decimal>) -> Decimal {
self.positions
.iter()
.map(|(inst, pos)| {
marks
.get(inst)
.map(|mark| pos.unrealized_pnl(*mark))
.unwrap_or(Decimal::ZERO)
})
.sum()
}
pub fn equity(&self, marks: &HashMap<InstrumentId, Decimal>) -> Decimal {
self.free_usdc + self.total_reserved_margin() + self.total_unrealized_pnl(marks)
}
pub fn position_qty(&self, instrument: &InstrumentId) -> Decimal {
self.positions
.get(instrument)
.map(|p| p.qty)
.unwrap_or(Decimal::ZERO)
}
pub fn set_leverage(
&mut self,
instrument: &InstrumentId,
leverage: Decimal,
max_leverage: Decimal,
) {
self.leverage_settings
.insert(instrument.clone(), (leverage, max_leverage));
if let Some(pos) = self.positions.get_mut(instrument) {
pos.leverage = leverage;
if max_leverage > Decimal::ZERO {
pos.maintenance_margin_rate = Decimal::ONE / max_leverage;
}
}
}
pub fn set_default_leverage(&mut self, leverage: Decimal) {
self.default_leverage = leverage;
}
pub fn check_margin_for_perp_order(
&self,
instrument: &InstrumentId,
side: OrderSide,
price: Decimal,
qty: Decimal,
reduce_only: bool,
) -> Result<(), String> {
let existing_pos = self.positions.get(instrument);
if reduce_only {
return Ok(());
}
let is_reducing = existing_pos.map_or(false, |pos| pos.is_order_reducing(side));
if is_reducing {
return Ok(());
}
let notional = price * qty;
let leverage = self.leverage_for(instrument);
let required =
IsolatedPosition::required_margin_for_order(notional, leverage, self.fee_rate);
if required > self.free_usdc {
return Err(format!(
"Insufficient margin: need {} USDC (notional={}, leverage={}x, fee_rate={}), have {} free",
required, notional, leverage, self.fee_rate, self.free_usdc
));
}
Ok(())
}
pub fn apply_perp_fill(
&mut self,
instrument: &InstrumentId,
side: OrderSide,
fill_price: Decimal,
fill_qty: Decimal,
fee_amount: Decimal,
) -> Decimal {
let leverage = self.leverage_for(instrument);
let max_leverage = self
.leverage_settings
.get(instrument)
.map(|(_, ml)| *ml)
.unwrap_or(leverage);
let mmr = if max_leverage > Decimal::ZERO {
Decimal::ONE / max_leverage
} else {
Decimal::ONE
};
let signed_fill_qty = match side {
OrderSide::Buy => fill_qty,
OrderSide::Sell => -fill_qty,
};
let pos = self.position_or_default(instrument);
pos.leverage = leverage;
pos.maintenance_margin_rate = mmr;
self.free_usdc -= fee_amount;
if let Some(pos) = self.positions.get_mut(instrument) {
pos.fee_paid += fee_amount;
}
let old_qty = self
.positions
.get(instrument)
.map(|p| p.qty)
.unwrap_or(Decimal::ZERO);
let is_same_direction = (old_qty >= Decimal::ZERO && signed_fill_qty > Decimal::ZERO)
|| (old_qty <= Decimal::ZERO && signed_fill_qty < Decimal::ZERO);
let is_flat = old_qty.is_zero();
if is_flat || is_same_direction {
self.apply_increase(instrument, signed_fill_qty, fill_price, leverage)
} else {
let closing_qty = signed_fill_qty.abs().min(old_qty.abs());
let remaining_qty = signed_fill_qty.abs() - closing_qty;
let realized = self.apply_reduce(instrument, closing_qty, fill_price);
if remaining_qty > Decimal::ZERO {
let new_signed = if signed_fill_qty > Decimal::ZERO {
remaining_qty
} else {
-remaining_qty
};
self.apply_increase(instrument, new_signed, fill_price, leverage);
}
realized
}
}
fn apply_increase(
&mut self,
instrument: &InstrumentId,
signed_fill_qty: Decimal,
fill_price: Decimal,
leverage: Decimal,
) -> Decimal {
let new_margin = (fill_price * signed_fill_qty.abs()) / leverage;
self.free_usdc -= new_margin;
let pos = self.position_or_default(instrument);
let old_notional = pos.qty.abs() * pos.avg_entry_px;
let new_notional = signed_fill_qty.abs() * fill_price;
let total_qty = pos.qty.abs() + signed_fill_qty.abs();
if total_qty > Decimal::ZERO {
pos.avg_entry_px = (old_notional + new_notional) / total_qty;
}
pos.qty += signed_fill_qty;
pos.isolated_margin_reserved += new_margin;
Decimal::ZERO }
fn apply_reduce(
&mut self,
instrument: &InstrumentId,
closing_qty: Decimal,
fill_price: Decimal,
) -> Decimal {
let pos = match self.positions.get_mut(instrument) {
Some(p) => p,
None => return Decimal::ZERO,
};
if pos.qty.is_zero() {
return Decimal::ZERO;
}
let old_abs_qty = pos.qty.abs();
let close_fraction = closing_qty / old_abs_qty;
let realized = if pos.qty > Decimal::ZERO {
(fill_price - pos.avg_entry_px) * closing_qty
} else {
(pos.avg_entry_px - fill_price) * closing_qty
};
let released_margin = pos.isolated_margin_reserved * close_fraction;
pos.isolated_margin_reserved -= released_margin;
if pos.qty > Decimal::ZERO {
pos.qty -= closing_qty;
} else {
pos.qty += closing_qty;
}
self.free_usdc += released_margin + realized;
pos.realized_pnl += realized;
if pos.qty.is_zero() {
pos.avg_entry_px = Decimal::ZERO;
pos.isolated_margin_reserved = Decimal::ZERO;
}
realized
}
pub fn check_liquidations(&self, marks: &HashMap<InstrumentId, Decimal>) -> Vec<InstrumentId> {
let mut liquidated = Vec::new();
for (instrument, pos) in &self.positions {
if pos.qty.is_zero() {
continue;
}
if let Some(mark) = marks.get(instrument) {
if pos.is_liquidated(*mark) {
liquidated.push(instrument.clone());
}
}
}
liquidated
}
pub fn liquidate(&mut self, instrument: &InstrumentId, mark_price: Decimal) {
let pos = match self.positions.get(instrument) {
Some(p) if !p.qty.is_zero() => p,
_ => return,
};
let qty = pos.qty;
let closing_qty = qty.abs();
let close_side = if qty > Decimal::ZERO {
OrderSide::Sell } else {
OrderSide::Buy };
tracing::warn!(
instrument = %instrument,
qty = %qty,
entry = %pos.avg_entry_px,
mark = %mark_price,
"LIQUIDATION: force-closing position"
);
self.apply_perp_fill(
instrument,
close_side,
mark_price,
closing_qty,
Decimal::ZERO,
);
}
pub fn position_snapshots(
&self,
marks: &HashMap<InstrumentId, Decimal>,
) -> Vec<PositionSnapshot> {
self.positions
.iter()
.filter(|(_, pos)| !pos.qty.is_zero())
.map(|(instrument, pos)| {
let unrealized = marks.get(instrument).map(|mark| pos.unrealized_pnl(*mark));
PositionSnapshot {
instrument: instrument.clone(),
qty: pos.qty,
avg_entry_px: if pos.avg_entry_px.is_zero() {
None
} else {
Some(Price::new(pos.avg_entry_px))
},
unrealized_pnl: unrealized,
liquidation_px: pos.liquidation_price(),
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn usdc(n: i64) -> Decimal {
Decimal::new(n, 0)
}
fn btc_perp() -> InstrumentId {
InstrumentId::new("BTC-PERP")
}
fn eth_perp() -> InstrumentId {
InstrumentId::new("ETH-PERP")
}
#[test]
fn test_unrealized_pnl_long() {
let pos = IsolatedPosition {
qty: Decimal::ONE,
avg_entry_px: usdc(50000),
..Default::default()
};
assert_eq!(pos.unrealized_pnl(usdc(51000)), usdc(1000));
assert_eq!(pos.unrealized_pnl(usdc(49000)), -usdc(1000));
}
#[test]
fn test_unrealized_pnl_short() {
let pos = IsolatedPosition {
qty: -Decimal::ONE,
avg_entry_px: usdc(50000),
..Default::default()
};
assert_eq!(pos.unrealized_pnl(usdc(49000)), usdc(1000));
assert_eq!(pos.unrealized_pnl(usdc(51000)), -usdc(1000));
}
#[test]
fn test_unrealized_pnl_flat() {
let pos = IsolatedPosition::default();
assert_eq!(pos.unrealized_pnl(usdc(99999)), Decimal::ZERO);
}
#[test]
fn test_required_margin() {
let margin = IsolatedPosition::required_margin_for_order(
usdc(10000), Decimal::new(10, 0), Decimal::new(2, 4), );
assert_eq!(margin, Decimal::new(1004, 0));
}
#[test]
fn test_is_order_reducing() {
let long_pos = IsolatedPosition {
qty: Decimal::ONE,
..Default::default()
};
assert!(long_pos.is_order_reducing(OrderSide::Sell));
assert!(!long_pos.is_order_reducing(OrderSide::Buy));
let short_pos = IsolatedPosition {
qty: -Decimal::ONE,
..Default::default()
};
assert!(short_pos.is_order_reducing(OrderSide::Buy));
assert!(!short_pos.is_order_reducing(OrderSide::Sell));
}
#[test]
fn test_margin_admission_rejects_insufficient() {
let ledger = MarginLedger::new(usdc(1000), Decimal::new(2, 4));
let result = ledger.check_margin_for_perp_order(
&btc_perp(),
OrderSide::Buy,
usdc(50000), Decimal::ONE, false,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Insufficient margin"));
}
#[test]
fn test_margin_admission_accepts_leveraged() {
let mut ledger = MarginLedger::new(usdc(1000), Decimal::new(2, 4));
ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
let result = ledger.check_margin_for_perp_order(
&btc_perp(),
OrderSide::Buy,
usdc(50000),
Decimal::new(1, 1), false,
);
assert!(result.is_ok(), "Should accept: {:?}", result);
}
#[test]
fn test_margin_admission_allows_reducing() {
let mut ledger = MarginLedger::new(usdc(1000), Decimal::new(2, 4));
ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
ledger.apply_perp_fill(
&btc_perp(),
OrderSide::Buy,
usdc(50000),
Decimal::new(1, 1),
Decimal::ZERO,
);
let result = ledger.check_margin_for_perp_order(
&btc_perp(),
OrderSide::Sell,
usdc(50000),
Decimal::new(1, 1),
false,
);
assert!(result.is_ok(), "Reducing order should always pass");
}
#[test]
fn test_fill_open_long() {
let mut ledger = MarginLedger::new(usdc(10000), Decimal::new(2, 4));
ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
let realized = ledger.apply_perp_fill(
&btc_perp(),
OrderSide::Buy,
usdc(50000),
Decimal::new(1, 1), usdc(1), );
assert_eq!(realized, Decimal::ZERO, "No realized PnL on open");
let pos = ledger.position(&btc_perp()).unwrap();
assert_eq!(pos.qty, Decimal::new(1, 1)); assert_eq!(pos.avg_entry_px, usdc(50000));
assert_eq!(pos.isolated_margin_reserved, usdc(500));
assert_eq!(ledger.free_usdc(), usdc(9499));
}
#[test]
fn test_fill_close_long_with_profit() {
let mut ledger = MarginLedger::new(usdc(10000), Decimal::new(2, 4));
ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
ledger.apply_perp_fill(
&btc_perp(),
OrderSide::Buy,
usdc(50000),
Decimal::new(1, 1),
Decimal::ZERO,
);
let free_after_open = ledger.free_usdc();
let realized = ledger.apply_perp_fill(
&btc_perp(),
OrderSide::Sell,
usdc(51000),
Decimal::new(1, 1),
Decimal::ZERO,
);
assert_eq!(realized, usdc(100), "Realized PnL should be $100");
let pos = ledger.position(&btc_perp()).unwrap();
assert!(pos.qty.is_zero(), "Position should be flat");
assert_eq!(pos.isolated_margin_reserved, Decimal::ZERO);
assert_eq!(ledger.free_usdc(), free_after_open + usdc(500) + usdc(100));
}
#[test]
fn test_fill_close_long_with_loss() {
let mut ledger = MarginLedger::new(usdc(10000), Decimal::ZERO);
ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
ledger.apply_perp_fill(
&btc_perp(),
OrderSide::Buy,
usdc(50000),
Decimal::new(1, 1),
Decimal::ZERO,
);
let realized = ledger.apply_perp_fill(
&btc_perp(),
OrderSide::Sell,
usdc(49000),
Decimal::new(1, 1),
Decimal::ZERO,
);
assert_eq!(realized, -usdc(100), "Realized PnL should be -$100");
assert_eq!(ledger.free_usdc(), usdc(9900));
}
#[test]
fn test_fill_short_and_close() {
let mut ledger = MarginLedger::new(usdc(10000), Decimal::ZERO);
ledger.set_leverage(ð_perp(), Decimal::new(5, 0), Decimal::new(25, 0));
ledger.apply_perp_fill(
ð_perp(),
OrderSide::Sell,
usdc(3000),
Decimal::ONE,
Decimal::ZERO,
);
let pos = ledger.position(ð_perp()).unwrap();
assert_eq!(pos.qty, -Decimal::ONE); assert_eq!(pos.isolated_margin_reserved, usdc(600));
let realized = ledger.apply_perp_fill(
ð_perp(),
OrderSide::Buy,
usdc(2800),
Decimal::ONE,
Decimal::ZERO,
);
assert_eq!(realized, usdc(200), "Short profit should be $200");
}
#[test]
fn test_equity_with_unrealized() {
let mut ledger = MarginLedger::new(usdc(10000), Decimal::ZERO);
ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
ledger.apply_perp_fill(
&btc_perp(),
OrderSide::Buy,
usdc(50000),
Decimal::new(1, 1),
Decimal::ZERO,
);
let mut marks = HashMap::new();
marks.insert(btc_perp(), usdc(51000));
let equity = ledger.equity(&marks);
assert_eq!(equity, usdc(10100));
}
#[test]
fn test_position_snapshots() {
let mut ledger = MarginLedger::new(usdc(10000), Decimal::ZERO);
ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
ledger.apply_perp_fill(
&btc_perp(),
OrderSide::Buy,
usdc(50000),
Decimal::new(1, 1),
Decimal::ZERO,
);
let mut marks = HashMap::new();
marks.insert(btc_perp(), usdc(51000));
let snapshots = ledger.position_snapshots(&marks);
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].instrument, btc_perp());
assert_eq!(snapshots[0].qty, Decimal::new(1, 1));
assert_eq!(snapshots[0].unrealized_pnl, Some(usdc(100)));
}
#[test]
fn test_liquidation_detection() {
let mut ledger = MarginLedger::new(usdc(1000), Decimal::ZERO);
ledger.set_leverage(&btc_perp(), Decimal::new(10, 0), Decimal::new(50, 0));
ledger.apply_perp_fill(
&btc_perp(),
OrderSide::Buy,
usdc(50000),
Decimal::new(2, 3), Decimal::ZERO,
);
let pos = ledger.position(&btc_perp()).unwrap();
let liq_price = pos.liquidation_price();
assert!(liq_price.is_some(), "Should have a liquidation price");
let mut marks = HashMap::new();
marks.insert(btc_perp(), usdc(50000));
assert!(ledger.check_liquidations(&marks).is_empty());
marks.insert(btc_perp(), usdc(1000));
let liquidated = ledger.check_liquidations(&marks);
assert!(!liquidated.is_empty(), "Should detect liquidation at $1000");
}
}