Skip to main content

brokapi_finam/map/
instrument.rs

1//! Instrument directory mapping.
2
3use brokapi_core::{
4    Instrument, InstrumentKind, Money, Price, Qty, Schedule, SessionInterval, SessionKind,
5    TradingAvailability, TradingParams, UnixNanos,
6};
7use brokapi_finam_protocol::pb::grpc::tradeapi::v1::assets as pb;
8use std::time::Instant;
9
10use crate::util::{instrument_from_symbol, qty, timestamps, ts_nanos};
11
12/// Finam's `type` strings are not enumerated in the docs; matched by substring.
13pub fn kind_from_type(t: &str) -> InstrumentKind {
14    let t = t.to_ascii_uppercase();
15    let has = |s: &str| t.contains(s);
16    if has("OPTION") {
17        InstrumentKind::Option
18    } else if has("FUTURE") {
19        InstrumentKind::Future
20    } else if has("BOND") {
21        InstrumentKind::Bond
22    } else if has("ETF") || has("FUND") {
23        InstrumentKind::Etf
24    } else if has("EQUIT") || has("STOCK") || has("SHARE") {
25        InstrumentKind::Stock
26    } else if has("CURRENC") {
27        InstrumentKind::Currency
28    } else {
29        InstrumentKind::Other
30    }
31}
32
33/// A directory entry (`Assets`, `AllAssets`) -> core instrument.
34///
35/// The list carries no currency, so `currency` is empty. Use `get` or `trading_params` for it.
36pub fn map_asset(a: &pb::Asset) -> Instrument {
37    let symbol = if a.symbol.is_empty() {
38        format!("{}@{}", a.ticker, a.mic)
39    } else {
40        a.symbol.clone()
41    };
42    Instrument {
43        id: instrument_from_symbol(&symbol),
44        name: a.name.clone(),
45        isin: (!a.isin.is_empty()).then(|| a.isin.clone()),
46        kind: kind_from_type(&a.r#type),
47        // The list does not carry a currency; use `get` / `trading_params`.
48        currency: String::new(),
49        exchange: a.mic.clone(),
50    }
51}
52
53/// A `GetAsset` answer -> core instrument. `symbol` is the `SYMBOL@MIC` that was asked for.
54pub fn map_get_asset(symbol: &str, a: &pb::GetAssetResponse) -> Instrument {
55    let id = instrument_from_symbol(symbol);
56    Instrument {
57        exchange: if a.mic.is_empty() {
58            id.key.mic.clone()
59        } else {
60            a.mic.clone()
61        },
62        id,
63        name: a.name.clone(),
64        isin: (!a.isin.is_empty()).then(|| a.isin.clone()),
65        kind: kind_from_type(&a.r#type),
66        currency: a.quote_currency.clone(),
67    }
68}
69
70/// Price step = `min_step / 10^decimals`. Finam does not give the money value of a step, so
71/// `price_step_value` equals `price_step` (right for stocks, not for futures). `min_qty` is one
72/// lot because orders are in pieces and sized in whole lots.
73pub fn map_trading_params(symbol: &str, a: &pb::GetAssetResponse) -> TradingParams {
74    // min_step counts the smallest units of the price, `decimals` places after the point.
75    let places = u32::try_from(a.decimals.clamp(0, 28)).unwrap_or(0);
76    let step = Price::from_scaled(a.min_step.max(1), places);
77    let lot = qty(&a.lot_size)
78        .filter(|l| l.is_positive())
79        .unwrap_or(Qty::from(1));
80    TradingParams {
81        instrument: instrument_from_symbol(symbol),
82        lot_size: lot,
83        min_qty: lot,
84        price_step: step,
85        price_step_value: Money::new(step.decimal()),
86        currency: a.quote_currency.clone(),
87    }
88}
89
90/// The lot for trading, when `GetAssetParams` gives one: `trade_lot_size` above 0 replaces the lot size of
91/// `GetAsset` (`lot_size` and `min_qty`). 0 or less means "no value" and `params` stays as it is.
92pub fn apply_trade_lot_size(params: &mut TradingParams, trade_lot_size: i64) {
93    if trade_lot_size > 0 {
94        let lot = Qty::from(trade_lot_size);
95        params.lot_size = lot;
96        params.min_qty = lot;
97    }
98}
99
100/// Session type heuristics: the docs do not list the `type` values.
101pub fn session_kind(t: &str) -> SessionKind {
102    let t = t.to_ascii_uppercase();
103    if t.contains("AUCTION") {
104        SessionKind::Auction
105    } else if t.contains("EARLY") || t.contains("PRE") || t.contains("MORNING") {
106        SessionKind::PreMarket
107    } else if t.contains("LATE")
108        || t.contains("POST")
109        || t.contains("EVENING")
110        || t.contains("AFTER")
111    {
112        SessionKind::PostMarket
113    } else if t.contains("CORE")
114        || t.contains("MAIN")
115        || t.contains("NORMAL")
116        || t.contains("REGULAR")
117    {
118        SessionKind::Main
119    } else {
120        SessionKind::Other
121    }
122}
123
124fn is_closed_type(t: &str) -> bool {
125    t.to_ascii_uppercase().contains("CLOSED")
126}
127
128/// A `Schedule` answer -> core schedule for the range `from..to`.
129///
130/// Sessions of the closed type are dropped, and so are sessions that do not overlap the range.
131/// `symbol` is stored as the schedule's `exchange` field, because Finam schedules are per instrument.
132pub fn map_schedule(
133    symbol: &str,
134    resp: &pb::ScheduleResponse,
135    from: UnixNanos,
136    to: UnixNanos,
137    now: Instant,
138) -> Schedule {
139    let sessions = resp
140        .sessions
141        .iter()
142        .filter(|s| !is_closed_type(&s.r#type))
143        .filter_map(|s| {
144            let iv = s.interval.as_ref()?;
145            let start = iv.start_time.as_ref().map(ts_nanos)?;
146            let end = iv.end_time.as_ref().map(ts_nanos)?;
147            // Keep sessions that overlap the requested range.
148            (end > from && start < to).then(|| SessionInterval {
149                start,
150                end,
151                kind: session_kind(&s.r#type),
152            })
153        })
154        .collect();
155    Schedule {
156        exchange: symbol.to_string(),
157        sessions,
158        ts: timestamps(None, now),
159    }
160}
161
162/// Availability from the schedule: inside a non-closed session -> open, otherwise closed.
163pub fn availability_at(
164    resp: &pb::ScheduleResponse,
165    at: UnixNanos,
166) -> (TradingAvailability, Option<String>) {
167    for s in &resp.sessions {
168        let Some(iv) = &s.interval else { continue };
169        let (Some(a), Some(b)) = (iv.start_time.as_ref(), iv.end_time.as_ref()) else {
170            continue;
171        };
172        if ts_nanos(a) <= at && at < ts_nanos(b) {
173            return if is_closed_type(&s.r#type) {
174                (TradingAvailability::Closed, Some(s.r#type.clone()))
175            } else {
176                (TradingAvailability::Open, Some(s.r#type.clone()))
177            };
178        }
179    }
180    if resp.sessions.is_empty() {
181        (TradingAvailability::Unknown, Some("empty schedule".into()))
182    } else {
183        (
184            TradingAvailability::Closed,
185            Some("outside of scheduled sessions".into()),
186        )
187    }
188}