brokapi_bybit/map/
portfolio.rs1use 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
11pub 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
27pub 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
37pub 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
54pub 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
63pub 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}