Skip to main content

brokapi_alor/map/
portfolio.rs

1//! Portfolio mapping: positions and money of ALOR to `brokapi-core` positions and balances.
2
3use std::time::Instant;
4
5use brokapi_alor_protocol::http::models::PositionRecord;
6use brokapi_core::{Balance, Money, Portfolio, Position, Price, Qty};
7
8use super::timestamps;
9use crate::util::instrument_id;
10
11/// A money position (`isCurrency`) is a balance, not a position.
12pub fn is_money(r: &PositionRecord) -> bool {
13    r.is_currency == Some(true)
14}
15
16/// The balance of a money position: the amount of the currency named by the symbol. The broker reports the
17/// amount in the quantity; it does not split a part blocked by orders, so `blocked` is zero.
18pub fn balance(r: &PositionRecord, now: Instant) -> Balance {
19    Balance {
20        currency: r.symbol.clone(),
21        available: Money::new(r.qty_units.unwrap_or_default()),
22        blocked: Money::ZERO,
23        ts: timestamps(now, None),
24    }
25}
26
27/// A position in an instrument. `board` is the trading mode of the instrument (the broker does not name it in a
28/// position, the connector looks it up). The quantity is in pieces and signed. The average price is `None` when
29/// the broker gives none or zero (futures).
30pub fn position(r: &PositionRecord, board: &str, now: Instant) -> Position {
31    Position {
32        instrument: instrument_id(&r.symbol, board),
33        qty: Qty::new(r.qty_units.unwrap_or_default()),
34        avg_price: r.avg_price.filter(|p| !p.is_zero()).map(Price::new),
35        current_price: None,
36        ts: timestamps(now, None),
37    }
38}
39
40/// The portfolio from the positions of the broker; `board_of` gives the board of a ticker.
41pub fn portfolio(
42    records: &[PositionRecord],
43    board_of: impl Fn(&str) -> String,
44    now: Instant,
45) -> Portfolio {
46    let mut balances = Vec::new();
47    let mut positions = Vec::new();
48    for r in records {
49        if is_money(r) {
50            balances.push(balance(r, now));
51        } else if r.qty_units.is_some_and(|q| !q.is_zero()) {
52            positions.push(position(r, &board_of(&r.symbol), now));
53        }
54    }
55    Portfolio {
56        balances,
57        positions,
58        ts: timestamps(now, None),
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    #[test]
67    fn money_is_a_balance_and_instruments_are_positions() {
68        let recs: Vec<PositionRecord> = serde_json::from_str(
69            r#"[{"symbol":"RUB","qtyUnits":100000.55,"isCurrency":true},
70                {"symbol":"SBER","qtyUnits":30,"avgPrice":250.1,"isCurrency":false},
71                {"symbol":"GAZP","qtyUnits":0,"isCurrency":false}]"#,
72        )
73        .unwrap();
74        let p = portfolio(&recs, |_| "TQBR".to_string(), Instant::now());
75        assert_eq!(p.balances.len(), 1);
76        assert_eq!(p.balances[0].currency, "RUB");
77        assert_eq!(p.balances[0].available.to_string(), "100000.55");
78        // A closed position (zero) is not listed.
79        assert_eq!(p.positions.len(), 1);
80        assert_eq!(p.positions[0].instrument.key.display(), "SBER@TQBR");
81        assert_eq!(p.positions[0].qty, Qty::from(30));
82    }
83}