Skip to main content

polyester/codecs/decode/
balances.rs

1//! Ledger balance / hold / transfer decoders.
2
3use crate::codecs::scalars::{format_uint64_id, u128_to_str};
4use crate::models::{
5    AssetBalance, BalanceHistory, BalanceHistorySeries, BalancesList, Hold, HoldsList,
6    LedgerTransfer, TransfersList,
7};
8use crate::proto::ledger::read::v1::{
9    AssetBalance as ProtoAssetBalance, BalanceRange, GetBalanceHistoryResponse,
10    GetBalancesResponse, HoldRow, ListHoldsResponse, ListTransfersResponse, TransferRow,
11};
12use crate::proto::polyester::r#type::v1::U128;
13
14fn u128_field(msg: Option<&U128>) -> String {
15    match msg {
16        Some(u) => u128_to_str(u.hi, u.lo),
17        None => "0".to_owned(),
18    }
19}
20
21fn balance_range_label(range: buffa::EnumValue<BalanceRange>) -> String {
22    match range.as_known() {
23        Some(BalanceRange::Day1) => "1d".to_owned(),
24        Some(BalanceRange::Day7) => "7d".to_owned(),
25        Some(BalanceRange::Day30) => "30d".to_owned(),
26        Some(BalanceRange::Day90) => "90d".to_owned(),
27        Some(BalanceRange::Day180) => "180d".to_owned(),
28        Some(BalanceRange::Day365) => "365d".to_owned(),
29        Some(_) => String::new(),
30        None => format!("UNKNOWN({})", range.to_i32()),
31    }
32}
33
34pub fn asset_balance_from_proto(msg: &ProtoAssetBalance) -> AssetBalance {
35    AssetBalance {
36        asset_id: msg.asset_id,
37        trading: u128_field(msg.trading.as_option()),
38        funding: u128_field(msg.funding.as_option()),
39        reserved: u128_field(msg.reserved.as_option()),
40        available: u128_field(msg.available.as_option()),
41        trading_revision: msg.trading_revision,
42        funding_revision: msg.funding_revision,
43    }
44}
45
46pub fn balances_list_from_proto(msg: &GetBalancesResponse) -> BalancesList {
47    BalancesList {
48        balances: msg.balances.iter().map(asset_balance_from_proto).collect(),
49    }
50}
51
52pub fn balance_history_from_proto(msg: &GetBalanceHistoryResponse) -> BalanceHistory {
53    BalanceHistory {
54        range: balance_range_label(msg.range),
55        bucket: msg.bucket.clone(),
56        start_ts_sec: msg.start_ts_sec as i64,
57        end_ts_sec: msg.end_ts_sec as i64,
58        points: msg.points,
59        series: msg
60            .series
61            .iter()
62            .map(|s| BalanceHistorySeries {
63                asset_id: s.asset_id,
64                account_code: s.account_code.to_i32(),
65                balance_q: s.balance_q.clone(),
66            })
67            .collect(),
68    }
69}
70
71pub fn equity_history_from_proto(
72    msg: &crate::proto::ledger::read::v1::GetEquityHistorySeriesResponse,
73) -> crate::models::EquityHistory {
74    use crate::models::{EquityHistory, EquityHistorySeries};
75    use crate::proto::ledger::read::v1::__buffa::oneof::equity_series::Grouping;
76    EquityHistory {
77        range: balance_range_label(msg.range),
78        bucket: msg.bucket.clone(),
79        start_ts_sec: msg.start_ts_sec as i64,
80        end_ts_sec: msg.end_ts_sec as i64,
81        quote_asset: msg.quote_asset.clone(),
82        points: msg.points,
83        series: msg
84            .series
85            .iter()
86            .map(|s| {
87                let mut row = EquityHistorySeries {
88                    account_code: 0,
89                    account_name: String::new(),
90                    asset_id: 0,
91                    asset_symbol: String::new(),
92                    equity_q: s.equity_q.clone(),
93                };
94                match s.grouping.as_ref() {
95                    Some(Grouping::Account(a)) => {
96                        row.account_code = a.account_code;
97                        row.account_name = a.name.clone();
98                    }
99                    Some(Grouping::Asset(a)) => {
100                        row.asset_id = a.id;
101                        row.asset_symbol = a.symbol.clone();
102                    }
103                    None => {}
104                }
105                row
106            })
107            .collect(),
108    }
109}
110
111pub fn hold_from_proto(msg: &HoldRow) -> Hold {
112    Hold {
113        hold_id: format_uint64_id(msg.hold_id),
114        asset_id: msg.asset_id,
115        amount_reserved: u128_field(msg.amount_reserved_e18.as_option()),
116        expires_at_ns: if msg.expires_at_ns == 0 {
117            String::new()
118        } else {
119            msg.expires_at_ns.to_string()
120        },
121    }
122}
123
124pub fn holds_list_from_proto(msg: &ListHoldsResponse) -> HoldsList {
125    HoldsList {
126        holds: msg.holds.iter().map(hold_from_proto).collect(),
127    }
128}
129
130pub fn transfer_row_from_proto(msg: &TransferRow) -> LedgerTransfer {
131    LedgerTransfer {
132        asset_id: msg.asset_id,
133        amount: u128_field(msg.amount_e18.as_option()),
134        transfer_type: msg.transfer_code.to_i32(),
135        account_code: msg.account_code.to_i32(),
136        timestamp: msg.ts_us as i64,
137        tx_id: msg.flow_id.clone(),
138        is_debit: msg.is_debit,
139    }
140}
141
142pub fn transfers_list_from_proto(msg: &ListTransfersResponse) -> TransfersList {
143    let next_cursor = if msg.next_page_token.is_empty() {
144        None
145    } else {
146        msg.next_page_token.parse::<i64>().ok().filter(|v| *v != 0)
147    };
148    TransfersList {
149        transfers: msg.transfers.iter().map(transfer_row_from_proto).collect(),
150        next_cursor,
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::proto::ledger::read::v1::__buffa::oneof::equity_series::Grouping;
158    use crate::proto::ledger::read::v1::{
159        AccountGrouping, BalanceSeries, GetBalanceHistoryResponse, GetEquityHistorySeriesResponse,
160        ListHoldsResponse, ListTransfersResponse,
161    };
162
163    #[test]
164    fn balances_list_maps_u128() {
165        let msg = GetBalancesResponse {
166            balances: vec![ProtoAssetBalance {
167                asset_id: 7,
168                trading: U128 {
169                    hi: 0,
170                    lo: 500,
171                    ..Default::default()
172                }
173                .into(),
174                trading_revision: u64::MAX - 2,
175                funding_revision: u64::MAX - 1,
176                ..Default::default()
177            }],
178            ..Default::default()
179        };
180        let list = balances_list_from_proto(&msg);
181        assert_eq!(list.balances.len(), 1);
182        assert_eq!(list.balances[0].asset_id, 7);
183        assert_eq!(list.balances[0].trading, "500");
184        assert_eq!(list.balances[0].trading_revision, u64::MAX - 2);
185        assert_eq!(list.balances[0].funding_revision, u64::MAX - 1);
186
187        // M5: 1e18 ledger integer must stay as the scaled string (not re-scaled to "1").
188        let one_e18 = GetBalancesResponse {
189            balances: vec![ProtoAssetBalance {
190                asset_id: 1,
191                trading: U128 {
192                    hi: 0,
193                    lo: 1_000_000_000_000_000_000,
194                    ..Default::default()
195                }
196                .into(),
197                ..Default::default()
198            }],
199            ..Default::default()
200        };
201        let list = balances_list_from_proto(&one_e18);
202        assert_eq!(list.balances[0].trading, "1000000000000000000");
203    }
204
205    #[test]
206    fn balance_history_maps_range_and_series() {
207        let msg = GetBalanceHistoryResponse {
208            range: BalanceRange::Day7.into(),
209            bucket: "1h".into(),
210            start_ts_sec: 100,
211            end_ts_sec: 200,
212            points: 2,
213            series: vec![BalanceSeries {
214                asset_id: 1,
215                balance_q: vec![100, 200],
216                ..Default::default()
217            }],
218            ..Default::default()
219        };
220        let result = balance_history_from_proto(&msg);
221        assert_eq!(result.range, "7d");
222        assert_eq!(result.bucket, "1h");
223        assert_eq!(result.series.len(), 1);
224        assert_eq!(result.series[0].balance_q, vec![100, 200]);
225    }
226
227    #[test]
228    fn balance_history_preserves_full_u64_range() {
229        use crate::proto::ledger::read::v1::BalanceSeries;
230
231        let msg = GetBalanceHistoryResponse {
232            series: vec![BalanceSeries {
233                asset_id: 1,
234                account_code: buffa::EnumValue::from(-7),
235                balance_q: vec![i64::MAX as u64 + 1, u64::MAX],
236                ..Default::default()
237            }],
238            ..Default::default()
239        };
240
241        let result = balance_history_from_proto(&msg);
242        assert_eq!(
243            result.series[0].balance_q,
244            vec![i64::MAX as u64 + 1, u64::MAX]
245        );
246        assert_eq!(result.series[0].account_code, -7);
247    }
248
249    #[test]
250    fn equity_history_maps_account_grouping() {
251        let msg = GetEquityHistorySeriesResponse {
252            range: BalanceRange::Day30.into(),
253            bucket: "1d".into(),
254            start_ts_sec: 1,
255            end_ts_sec: 2,
256            quote_asset: "USD".into(),
257            points: 1,
258            series: vec![crate::proto::ledger::read::v1::EquitySeries {
259                equity_q: vec![999],
260                grouping: Some(Grouping::Account(Box::new(AccountGrouping {
261                    account_code: 5,
262                    name: "Trading".into(),
263                    ..Default::default()
264                }))),
265                ..Default::default()
266            }],
267            ..Default::default()
268        };
269        let result = equity_history_from_proto(&msg);
270        assert_eq!(result.range, "30d");
271        assert_eq!(result.quote_asset, "USD");
272        assert_eq!(result.series[0].account_code, 5);
273        assert_eq!(result.series[0].account_name, "Trading");
274        assert_eq!(result.series[0].equity_q, vec![999]);
275    }
276
277    #[test]
278    fn history_point_counts_preserve_full_u32_range() {
279        let balance = balance_history_from_proto(&GetBalanceHistoryResponse {
280            points: u32::MAX,
281            ..Default::default()
282        });
283        let equity = equity_history_from_proto(&GetEquityHistorySeriesResponse {
284            points: u32::MAX,
285            ..Default::default()
286        });
287        assert_eq!(balance.points, u32::MAX);
288        assert_eq!(equity.points, u32::MAX);
289    }
290
291    #[test]
292    fn hold_formats_id_and_amount() {
293        let msg = HoldRow {
294            hold_id: 42,
295            asset_id: 1,
296            amount_reserved_e18: U128 {
297                hi: 0,
298                lo: 99,
299                ..Default::default()
300            }
301            .into(),
302            expires_at_ns: 123,
303            ..Default::default()
304        };
305        let hold = hold_from_proto(&msg);
306        assert_eq!(hold.hold_id, format_uint64_id(42));
307        assert_eq!(hold.amount_reserved, "99");
308        assert_eq!(hold.expires_at_ns, "123");
309    }
310
311    #[test]
312    fn holds_list_maps_rows() {
313        let msg = ListHoldsResponse {
314            holds: vec![HoldRow {
315                hold_id: 42,
316                asset_id: 1,
317                amount_reserved_e18: U128 {
318                    hi: 0,
319                    lo: 500,
320                    ..Default::default()
321                }
322                .into(),
323                expires_at_ns: 1_700_000_000_000,
324                ..Default::default()
325            }],
326            ..Default::default()
327        };
328        let result = holds_list_from_proto(&msg);
329        assert_eq!(result.holds.len(), 1);
330        assert_eq!(result.holds[0].hold_id, format_uint64_id(42));
331        assert_eq!(result.holds[0].amount_reserved, "500");
332    }
333
334    #[test]
335    fn transfer_row_and_list_cursor() {
336        let row = TransferRow {
337            asset_id: 2,
338            amount_e18: U128 {
339                hi: 0,
340                lo: 1000,
341                ..Default::default()
342            }
343            .into(),
344            transfer_code: buffa::EnumValue::from(5),
345            account_code: buffa::EnumValue::from(1),
346            ts_us: 999,
347            is_debit: true,
348            flow_id: "flow-abc".into(),
349            ..Default::default()
350        };
351        let transfer = transfer_row_from_proto(&row);
352        assert_eq!(transfer.amount, "1000");
353        assert_eq!(transfer.transfer_type, 5);
354        assert_eq!(transfer.tx_id, "flow-abc");
355        assert!(transfer.is_debit);
356
357        let list = transfers_list_from_proto(&ListTransfersResponse {
358            transfers: vec![TransferRow {
359                asset_id: 1,
360                ..Default::default()
361            }],
362            next_page_token: "12345".into(),
363            ..Default::default()
364        });
365        assert_eq!(list.transfers.len(), 1);
366        assert_eq!(list.next_cursor, Some(12345));
367    }
368}