Skip to main content

brokapi_bcs/map/
portfolio.rs

1//! Portfolio mapping.
2
3use brokapi_core::Decimal;
4use std::time::Instant;
5
6use brokapi_bcs_protocol::http::models::PortfolioPosition as RestPortfolioPosition;
7use brokapi_bcs_protocol::ws::models::WsPortfolioPosition;
8use brokapi_core::{Balance, Event, Money, Portfolio, Position, Price, Qty};
9
10use super::{instrument_id_ticker, timestamps};
11
12/// Maps the REST portfolio to a [`Portfolio`].
13///
14/// A position of type `moneyLimit` is a balance (`available` is the quantity, `blocked` is the locked
15/// part, both `0` when missing). A position with a ticker and a board is a holding. A record with
16/// neither is dropped. Average price and current price are `None` when the broker leaves them out.
17pub fn map_rest_portfolio(positions: &[RestPortfolioPosition], now: Instant) -> Portfolio {
18    map_portfolio_positions(positions.iter().map(rest_pos), now)
19}
20
21/// Maps a portfolio WebSocket message to an `Event::Portfolio`. The rules are those of
22/// [`map_rest_portfolio`]. Every message is a full portfolio, not a change.
23pub fn map_ws_portfolio(positions: &[WsPortfolioPosition], now: Instant) -> Event {
24    Event::Portfolio(map_portfolio_positions(positions.iter().map(ws_pos), now))
25}
26
27struct PosView<'a> {
28    position_type: &'a str,
29    ticker: Option<&'a str>,
30    board: Option<&'a str>,
31    currency: Option<&'a str>,
32    quantity: Option<Decimal>,
33    locked: Option<Decimal>,
34    balance_price: Option<Decimal>,
35    current_price: Option<Decimal>,
36}
37
38fn rest_pos(p: &RestPortfolioPosition) -> PosView<'_> {
39    PosView {
40        position_type: &p.position_type,
41        ticker: p.ticker.as_deref(),
42        board: p.board.as_deref(),
43        currency: p.currency.as_deref(),
44        quantity: p.quantity,
45        locked: p.locked,
46        balance_price: p.balance_price,
47        current_price: p.current_price,
48    }
49}
50
51fn ws_pos(p: &WsPortfolioPosition) -> PosView<'_> {
52    PosView {
53        position_type: &p.position_type,
54        ticker: p.ticker.as_deref(),
55        board: p.board.as_deref(),
56        currency: p.currency.as_deref(),
57        quantity: p.quantity,
58        locked: p.locked,
59        balance_price: p.balance_price,
60        current_price: p.current_price,
61    }
62}
63
64fn map_portfolio_positions<'a>(
65    positions: impl Iterator<Item = PosView<'a>>,
66    now: Instant,
67) -> Portfolio {
68    let ts = timestamps(now, None);
69    let mut balances = Vec::new();
70    let mut holdings = Vec::new();
71
72    for pos in positions {
73        if pos.position_type.eq_ignore_ascii_case("moneyLimit") {
74            if let Some(currency) = pos.currency {
75                balances.push(Balance {
76                    currency: currency.to_string(),
77                    available: Money::new(pos.quantity.unwrap_or(Decimal::ZERO)),
78                    blocked: Money::new(pos.locked.unwrap_or(Decimal::ZERO)),
79                    ts,
80                });
81            }
82        } else if let (Some(ticker), Some(board)) = (pos.ticker, pos.board) {
83            holdings.push(Position {
84                instrument: instrument_id_ticker(ticker, board),
85                qty: Qty::new(pos.quantity.unwrap_or(Decimal::ZERO)),
86                avg_price: pos.balance_price.map(Price::new),
87                current_price: pos.current_price.map(Price::new),
88                ts,
89            });
90        }
91    }
92
93    Portfolio {
94        balances,
95        positions: holdings,
96        ts,
97    }
98}
99
100/// Backward-compatible helper used by UD core tests.
101pub fn map_portfolio(positions: &[WsPortfolioPosition], now: Instant) -> Portfolio {
102    map_portfolio_positions(positions.iter().map(ws_pos), now)
103}