Skip to main content

brokapi_bybit/map/
portfolio.rs

1//! The portfolio: positions and the balances of the wallet.
2
3use std::time::Instant;
4
5use brokapi_bybit_protocol::http::models::{CoinBalance, PositionRecord, WalletRecord};
6use brokapi_core::{Balance, Decimal, Money, Portfolio, Position, Price, Qty};
7
8use super::{broker_ts, timestamps};
9use crate::util::instrument_id;
10
11/// The balance of one coin.
12///
13/// `blocked` is the margin the open orders and positions hold plus what open spot orders lock. `available` is the
14/// equity (the wallet balance plus the unrealised profit) less `blocked`, never below zero: what can still open a
15/// position. The exchange shows an available figure only for the whole account (in USD), so this per coin figure is a
16/// reading of the connector, not the exchange's own.
17pub fn balance(c: &CoinBalance, now: Instant) -> Balance {
18    let blocked = c.locked + c.total_order_im + c.total_position_im;
19    Balance {
20        currency: c.coin.clone(),
21        available: Money::new((c.equity - blocked).max(Decimal::ZERO)),
22        blocked: Money::new(blocked),
23        ts: timestamps(now, None),
24    }
25}
26
27/// The balances of the coins that hold anything.
28pub fn balances(wallets: &[WalletRecord], now: Instant) -> Vec<Balance> {
29    wallets
30        .iter()
31        .flat_map(|w| w.coin.iter())
32        .filter(|c| !(c.equity.is_zero() && c.wallet_balance.is_zero()))
33        .map(|c| balance(c, now))
34        .collect()
35}
36
37/// A position. The quantity is signed: a short position is negative.
38pub fn position(r: &PositionRecord, category: &str, now: Instant) -> Position {
39    let size = if r.side == "Sell" { -r.size } else { r.size };
40    let category = if r.category.is_empty() {
41        category
42    } else {
43        &r.category
44    };
45    Position {
46        instrument: instrument_id(&r.symbol, category),
47        qty: Qty::new(size),
48        avg_price: r.avg_price.filter(|p| !p.is_zero()).map(Price::new),
49        current_price: r.mark_price.filter(|p| !p.is_zero()).map(Price::new),
50        ts: timestamps(now, broker_ts(r.updated_time)),
51    }
52}
53
54/// The open positions (a size of zero is no position).
55pub fn positions(records: &[PositionRecord], category: &str, now: Instant) -> Vec<Position> {
56    records
57        .iter()
58        .filter(|r| !r.size.is_zero())
59        .map(|r| position(r, category, now))
60        .collect()
61}
62
63/// The whole portfolio.
64pub fn portfolio(
65    wallets: &[WalletRecord],
66    records: &[PositionRecord],
67    category: &str,
68    now: Instant,
69) -> Portfolio {
70    Portfolio {
71        balances: balances(wallets, now),
72        positions: positions(records, category, now),
73        ts: timestamps(now, None),
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn a_short_position_is_negative_and_a_closed_one_is_not_listed() {
83        let recs: Vec<PositionRecord> = serde_json::from_str(
84            r#"[{"symbol":"BTCUSDT","side":"Sell","size":"0.3","avgPrice":"81000.5","markPrice":"80000"},
85                {"symbol":"ETHUSDT","side":"Buy","size":"2"},
86                {"symbol":"SOLUSDT","side":"","size":"0"}]"#,
87        )
88        .unwrap();
89        let p = positions(&recs, "linear", Instant::now());
90        assert_eq!(p.len(), 2);
91        assert_eq!(p[0].qty.to_string(), "-0.3");
92        assert_eq!(p[0].avg_price.unwrap().to_string(), "81000.5");
93        assert_eq!(p[0].instrument.key.display(), "BTCUSDT@linear");
94        assert_eq!(p[1].qty.to_string(), "2");
95        assert!(p[1].avg_price.is_none());
96    }
97
98    #[test]
99    fn the_free_money_is_the_equity_less_what_orders_and_positions_hold() {
100        let w: Vec<WalletRecord> = serde_json::from_str(
101            r#"[{"accountType":"UNIFIED","coin":[
102                {"coin":"USDT","equity":"1000.5","walletBalance":"1000","totalOrderIM":"10","totalPositionIM":"90.25","unrealisedPnl":"0.5","locked":"0"},
103                {"coin":"BTC","equity":"0","walletBalance":"0"}]}]"#,
104        )
105        .unwrap();
106        let b = balances(&w, Instant::now());
107        assert_eq!(b.len(), 1, "an empty coin is not listed");
108        assert_eq!(b[0].currency, "USDT");
109        assert_eq!(b[0].blocked.to_string(), "100.25");
110        assert_eq!(b[0].available.to_string(), "900.25");
111    }
112}