Skip to main content

brokapi_tbank/map/
portfolio.rs

1//! Portfolio, positions, cash balances.
2
3use std::collections::BTreeMap;
4use std::time::Instant;
5
6use brokapi_core::{Balance, InstrumentId, Money, Portfolio, Position, Qty};
7use brokapi_tbank_protocol::v1 as pb;
8
9use crate::ids::{id_from_parts, money_of, price_of_money, qty_of_quotation, stamps};
10
11/// Cash lives in `GetPositions.money` / `blocked`; currency instruments in `GetPortfolio` repeat it
12/// and are left out of `positions`.
13pub fn balances(
14    money: &[pb::MoneyValue],
15    blocked: &[pb::MoneyValue],
16    now: Instant,
17) -> Vec<Balance> {
18    let mut by_ccy: BTreeMap<String, (Money, Money)> = BTreeMap::new();
19    for m in money {
20        by_ccy
21            .entry(m.currency.to_uppercase())
22            .or_insert((Money::ZERO, Money::ZERO))
23            .0 += money_of(m);
24    }
25    for b in blocked {
26        by_ccy
27            .entry(b.currency.to_uppercase())
28            .or_insert((Money::ZERO, Money::ZERO))
29            .1 += money_of(b);
30    }
31    by_ccy
32        .into_iter()
33        .map(|(currency, (available, blocked))| Balance {
34            currency,
35            available,
36            blocked,
37            ts: stamps(now, None),
38        })
39        .collect()
40}
41
42/// Cash of a `GetPositions` answer: money and blocked money by currency.
43pub fn balances_from_positions(p: &pb::PositionsResponse, now: Instant) -> Vec<Balance> {
44    balances(&p.money, &p.blocked, now)
45}
46
47/// Cash of a `PositionsStream` message: available and blocked money by currency.
48pub fn balances_from_stream(money: &[pb::PositionsMoney], now: Instant) -> Vec<Balance> {
49    let avail: Vec<pb::MoneyValue> = money
50        .iter()
51        .filter_map(|m| m.available_value.clone())
52        .collect();
53    let blocked: Vec<pb::MoneyValue> = money
54        .iter()
55        .filter_map(|m| m.blocked_value.clone())
56        .collect();
57    balances(&avail, &blocked, now)
58}
59
60/// `quantity` is in pieces (the portfolio is not in lots).
61pub fn positions_from_portfolio(p: &pb::PortfolioResponse, now: Instant) -> Vec<Position> {
62    p.positions
63        .iter()
64        .filter(|x| x.instrument_type != "currency")
65        .map(|x| Position {
66            instrument: id_from_parts(&x.instrument_uid, &x.figi, &x.ticker, &x.class_code),
67            qty: x
68                .quantity
69                .as_ref()
70                .map(qty_of_quotation)
71                .unwrap_or(Qty::ZERO),
72            avg_price: x.average_position_price.as_ref().map(price_of_money),
73            current_price: x.current_price.as_ref().map(price_of_money),
74            ts: stamps(now, None),
75        })
76        .collect()
77}
78
79/// Securities of a positions message (balance + blocked, pieces). No prices in this message.
80pub fn positions_from_securities(
81    securities: &[pb::PositionsSecurities],
82    instruments: Vec<InstrumentId>,
83    now: Instant,
84) -> Vec<Position> {
85    securities
86        .iter()
87        .zip(instruments)
88        .map(|(s, instrument)| Position {
89            instrument,
90            qty: Qty::from(s.balance + s.blocked),
91            avg_price: None,
92            current_price: None,
93            ts: stamps(now, None),
94        })
95        .collect()
96}
97
98/// A [`Portfolio`] from a `GetPortfolio` answer and the cash you already have.
99///
100/// Positions come from the answer, with currency holdings left out.
101pub fn portfolio(p: &pb::PortfolioResponse, cash: Vec<Balance>, now: Instant) -> Portfolio {
102    Portfolio {
103        balances: cash,
104        positions: positions_from_portfolio(p, now),
105        ts: stamps(now, None),
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use brokapi_core::{Decimal, dec};
113    use brokapi_tbank_protocol::money::{money, quotation};
114
115    #[test]
116    fn cash_by_currency_available_and_blocked() {
117        let b = balances(
118            &[money("rub", "1000.5"), money("usd", "10.0")],
119            &[money("rub", "200.0")],
120            Instant::now(),
121        );
122        assert_eq!(b.len(), 2);
123        let rub = b.iter().find(|x| x.currency == "RUB").unwrap();
124        assert_eq!(
125            (rub.available.decimal(), rub.blocked.decimal()),
126            (dec!(1000.5), dec!(200.0))
127        );
128    }
129
130    #[test]
131    fn currencies_are_not_positions() {
132        let p = pb::PortfolioResponse {
133            positions: vec![
134                pb::PortfolioPosition {
135                    instrument_type: "share".into(),
136                    ticker: "SBER".into(),
137                    class_code: "TQBR".into(),
138                    instrument_uid: "u".into(),
139                    quantity: Some(quotation("30.0")),
140                    average_position_price: Some(money("rub", "250.0")),
141                    current_price: Some(money("rub", "260.0")),
142                    ..Default::default()
143                },
144                pb::PortfolioPosition {
145                    instrument_type: "currency".into(),
146                    ..Default::default()
147                },
148            ],
149            ..Default::default()
150        };
151        let pos = positions_from_portfolio(&p, Instant::now());
152        assert_eq!(pos.len(), 1);
153        assert_eq!(pos[0].qty.decimal(), dec!(30.0));
154        assert_eq!(pos[0].avg_price.unwrap().decimal(), dec!(250.0));
155        assert_eq!(pos[0].instrument.key.symbol, "SBER");
156    }
157}