Skip to main content

bot_engine/
inventory.rs

1//! Inventory ledger: tracks balances and reservations.
2
3use bot_core::{AssetId, Balance, ClientOrderId, Fee, OrderSide, Price, Qty, StrategyId};
4use rust_decimal::Decimal;
5use std::collections::HashMap;
6
7/// Reservation for an order
8#[derive(Debug, Clone)]
9pub struct Reservation {
10    /// Reserved asset.
11    pub asset: AssetId,
12    /// Reserved amount.
13    pub amount: Decimal,
14}
15
16/// Inventory ledger managing balances and reservations.
17pub struct InventoryLedger {
18    /// Account-level balances per asset
19    balances: HashMap<AssetId, AccountBalance>,
20
21    /// Reservations by order
22    order_reservations: HashMap<ClientOrderId, Vec<Reservation>>,
23
24    /// Per-strategy allocations (optional budgeting)
25    strategy_allocations: HashMap<StrategyId, StrategyAllocation>,
26
27    /// Per-strategy reserved amounts
28    strategy_reserved: HashMap<(StrategyId, AssetId), Decimal>,
29}
30
31#[derive(Debug, Clone, Default)]
32struct AccountBalance {
33    total: Decimal,
34    reserved: Decimal,
35}
36
37impl AccountBalance {
38    fn available(&self) -> Decimal {
39        self.total - self.reserved
40    }
41}
42
43/// Per-strategy allocation/budget
44#[derive(Debug, Clone)]
45pub struct StrategyAllocation {
46    /// Strategy that owns the allocation.
47    pub strategy_id: StrategyId,
48    /// Budgeted amount by asset.
49    pub budgets: HashMap<AssetId, Decimal>,
50}
51
52impl InventoryLedger {
53    /// Create an empty inventory ledger.
54    pub fn new() -> Self {
55        Self {
56            balances: HashMap::new(),
57            order_reservations: HashMap::new(),
58            strategy_allocations: HashMap::new(),
59            strategy_reserved: HashMap::new(),
60        }
61    }
62
63    /// Set the total balance for an asset (from exchange polling)
64    pub fn set_balance(&mut self, asset: &AssetId, total: Decimal) {
65        let entry = self.balances.entry(asset.clone()).or_default();
66        entry.total = total;
67    }
68
69    /// Get balance for an asset
70    pub fn balance(&self, asset: &AssetId) -> Balance {
71        if let Some(b) = self.balances.get(asset) {
72            Balance {
73                total: b.total,
74                available: b.available(),
75                reserved: b.reserved,
76            }
77        } else {
78            Balance::zero()
79        }
80    }
81
82    /// Reserve funds for an order
83    /// Returns true if reservation succeeded, false if insufficient funds
84    pub fn reserve(&mut self, order_id: &ClientOrderId, asset: &AssetId, amount: Decimal) -> bool {
85        let entry = self.balances.entry(asset.clone()).or_default();
86
87        if entry.available() < amount {
88            return false;
89        }
90
91        entry.reserved += amount;
92
93        let reservations = self.order_reservations.entry(order_id.clone()).or_default();
94        reservations.push(Reservation {
95            asset: asset.clone(),
96            amount,
97        });
98
99        true
100    }
101
102    /// Release all reservations for an order
103    pub fn release_order(&mut self, order_id: &ClientOrderId) {
104        if let Some(reservations) = self.order_reservations.remove(order_id) {
105            for res in reservations {
106                if let Some(balance) = self.balances.get_mut(&res.asset) {
107                    balance.reserved = (balance.reserved - res.amount).max(Decimal::ZERO);
108                }
109            }
110        }
111    }
112
113    /// Apply a fill to inventory (spot-style accounting)
114    pub fn apply_fill(
115        &mut self,
116        side: OrderSide,
117        base_asset: &AssetId,
118        quote_asset: &AssetId,
119        qty: Qty,
120        price: Price,
121        fee: &Fee,
122    ) {
123        let notional = qty.0 * price.0;
124
125        match side {
126            OrderSide::Buy => {
127                // BUY: base increases, quote decreases
128                let base_entry = self.balances.entry(base_asset.clone()).or_default();
129                let base_increase = if fee.asset == *base_asset {
130                    qty.0 - fee.amount
131                } else {
132                    qty.0
133                };
134                base_entry.total += base_increase;
135
136                let quote_entry = self.balances.entry(quote_asset.clone()).or_default();
137                let quote_decrease = if fee.asset == *quote_asset {
138                    notional + fee.amount
139                } else {
140                    notional
141                };
142                quote_entry.total = (quote_entry.total - quote_decrease).max(Decimal::ZERO);
143            }
144            OrderSide::Sell => {
145                // SELL: base decreases, quote increases
146                let base_entry = self.balances.entry(base_asset.clone()).or_default();
147                let base_decrease = if fee.asset == *base_asset {
148                    qty.0 + fee.amount
149                } else {
150                    qty.0
151                };
152                base_entry.total = (base_entry.total - base_decrease).max(Decimal::ZERO);
153
154                let quote_entry = self.balances.entry(quote_asset.clone()).or_default();
155                let quote_increase = if fee.asset == *quote_asset {
156                    notional - fee.amount
157                } else {
158                    notional
159                };
160                quote_entry.total += quote_increase.max(Decimal::ZERO);
161            }
162        }
163    }
164
165    /// Partially release reservation proportional to a fill
166    pub fn partial_release(
167        &mut self,
168        order_id: &ClientOrderId,
169        asset: &AssetId,
170        filled_fraction: Decimal,
171    ) {
172        if let Some(reservations) = self.order_reservations.get_mut(order_id) {
173            for res in reservations.iter_mut() {
174                if res.asset == *asset {
175                    let release_amount = res.amount * filled_fraction;
176                    res.amount -= release_amount;
177                    if let Some(balance) = self.balances.get_mut(asset) {
178                        balance.reserved = (balance.reserved - release_amount).max(Decimal::ZERO);
179                    }
180                }
181            }
182        }
183    }
184
185    /// Register a strategy allocation
186    pub fn set_allocation(&mut self, allocation: StrategyAllocation) {
187        self.strategy_allocations
188            .insert(allocation.strategy_id.clone(), allocation);
189    }
190
191    /// Check if a strategy has budget for an amount
192    pub fn check_strategy_budget(
193        &self,
194        strategy_id: &StrategyId,
195        asset: &AssetId,
196        amount: Decimal,
197    ) -> bool {
198        if let Some(allocation) = self.strategy_allocations.get(strategy_id) {
199            if let Some(&budget) = allocation.budgets.get(asset) {
200                let reserved = self
201                    .strategy_reserved
202                    .get(&(strategy_id.clone(), asset.clone()))
203                    .copied()
204                    .unwrap_or_default();
205                return reserved + amount <= budget;
206            }
207        }
208        // No allocation = no limit
209        true
210    }
211}
212
213impl Default for InventoryLedger {
214    fn default() -> Self {
215        Self::new()
216    }
217}