Skip to main content

brokapi_tbank/
ids.rs

1//! Instrument identifiers and clocks. Conversions never touch the network.
2//!
3//! # Examples
4//!
5//! ```
6//! use brokapi_core::{InstrumentId, InstrumentKey, NativeId};
7//! use brokapi_tbank::ids::{id_from_parts, instrument_ref};
8//!
9//! // A broker message names an instrument by uid, figi or ticker and class code.
10//! let id = id_from_parts("uid-sber", "BBG004730N88", "SBER", "TQBR");
11//! assert_eq!(id.key, InstrumentKey::new("SBER", "TQBR"));
12//! // The uid wins: it does not change when a ticker is renamed.
13//! assert_eq!(id.native, NativeId::Uid("uid-sber".into()));
14//! // The same id as the string a request carries in `instrument_id`.
15//! assert_eq!(instrument_ref(&id), "uid-sber");
16//! ```
17
18use std::time::Instant;
19
20use brokapi_core::{
21    Error, InstrumentId, InstrumentKey, Money, NativeId, Price, Qty, Timestamps, UnixNanos,
22};
23use brokapi_tbank_protocol::money::{decimal_to_quotation, money_to_decimal, quotation_to_decimal};
24use brokapi_tbank_protocol::v1::{self as pb, MoneyValue, Quotation};
25use prost_types::Timestamp;
26
27/// Value for the `instrument_id` request fields: `figi`, `instrument_uid` or `ticker_classcode`.
28///
29/// A `Uid` or `Figi` native id is passed as is. A `TickerClass` (or `SymbolMic`) becomes
30/// `TICKER_CLASSCODE`, for example `SBER_TQBR`. No network call is made and the id is not checked
31/// against the directory.
32///
33/// # Examples
34///
35/// ```
36/// use brokapi_core::{InstrumentId, InstrumentKey, NativeId};
37/// use brokapi_tbank::ids::instrument_ref;
38///
39/// let sber = InstrumentId::new(
40///     InstrumentKey::new("SBER", "TQBR"),
41///     NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() },
42/// );
43/// assert_eq!(instrument_ref(&sber), "SBER_TQBR");
44/// ```
45pub fn instrument_ref(id: &InstrumentId) -> String {
46    match &id.native {
47        NativeId::Uid(u) => u.clone(),
48        NativeId::Figi(f) => f.clone(),
49        NativeId::TickerClass { ticker, class_code } => format!("{ticker}_{class_code}"),
50        NativeId::SymbolMic { symbol, mic } => format!("{symbol}_{mic}"),
51        // A kind of id of a later version: the key still names ticker and class.
52        _ => format!("{}_{}", id.key.symbol, id.key.mic),
53    }
54}
55
56/// `true` when a broker message (identified by any of uid/figi/ticker+class) is about `id`.
57///
58/// A message matches when its ticker and class code equal the key of `id`, or when its uid (for a
59/// `Uid` id) or figi (for a `Figi` id) equals the native id. An empty uid or figi in the message never matches.
60///
61/// # Examples
62///
63/// ```
64/// use brokapi_core::{InstrumentId, InstrumentKey, NativeId};
65/// use brokapi_tbank::ids::matches;
66///
67/// let id = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::Uid("uid-sber".into()));
68/// assert!(matches(&id, "uid-sber", "", "", ""));
69/// assert!(matches(&id, "", "", "SBER", "TQBR"));
70/// assert!(!matches(&id, "uid-other", "", "GAZP", "TQBR"));
71/// ```
72pub fn matches(id: &InstrumentId, uid: &str, figi: &str, ticker: &str, class_code: &str) -> bool {
73    if !ticker.is_empty() && id.key.symbol == ticker && id.key.mic == class_code {
74        return true;
75    }
76    match &id.native {
77        NativeId::Uid(u) => !uid.is_empty() && u == uid,
78        NativeId::Figi(f) => !figi.is_empty() && f == figi,
79        NativeId::TickerClass {
80            ticker: t,
81            class_code: c,
82        } => t == ticker && c == class_code,
83        NativeId::SymbolMic { symbol, mic } => symbol == ticker && mic == class_code,
84        _ => false,
85    }
86}
87
88/// Canonical id from the fields a broker message carries. Prefers the uid (stable across tickers).
89///
90/// The native id is the uid, else the figi, else ticker and class code. When the message has no
91/// ticker, the key holds the uid or figi in place of the symbol, until a message with a ticker
92/// arrives (the user data core learns the mapping).
93///
94/// # Examples
95///
96/// ```
97/// use brokapi_core::NativeId;
98/// use brokapi_tbank::ids::id_from_parts;
99///
100/// let id = id_from_parts("", "", "GAZP", "TQBR");
101/// assert!(matches!(id.native, NativeId::TickerClass { .. }));
102/// ```
103pub fn id_from_parts(uid: &str, figi: &str, ticker: &str, class_code: &str) -> InstrumentId {
104    let native = if !uid.is_empty() {
105        NativeId::Uid(uid.to_string())
106    } else if !figi.is_empty() {
107        NativeId::Figi(figi.to_string())
108    } else {
109        NativeId::TickerClass {
110            ticker: ticker.to_string(),
111            class_code: class_code.to_string(),
112        }
113    };
114    let key = if ticker.is_empty() {
115        // Unknown ticker: keep the broker id readable in the key until a message with the ticker arrives.
116        InstrumentKey::new(if uid.is_empty() { figi } else { uid }, class_code)
117    } else {
118        InstrumentKey::new(ticker, class_code)
119    };
120    InstrumentId::new(key, native)
121}
122
123/// Current wall-clock time in nanoseconds since the Unix epoch.
124///
125/// Returns zero if the system clock is before 1970. It is the local clock: nothing guarantees it
126/// matches the broker's clock.
127///
128/// # Examples
129///
130/// ```
131/// assert!(brokapi_tbank::ids::wall_nanos().as_nanos() > 0);
132/// ```
133pub fn wall_nanos() -> UnixNanos {
134    UnixNanos::from_nanos(
135        std::time::SystemTime::now()
136            .duration_since(std::time::UNIX_EPOCH)
137            .map(|d| d.as_nanos() as i64)
138            .unwrap_or(0),
139    )
140}
141
142/// Converts a protobuf `Timestamp` to nanoseconds since the Unix epoch. Overflow saturates.
143///
144/// # Examples
145///
146/// ```
147/// use brokapi_tbank::ids::proto_to_nanos;
148/// use prost_types::Timestamp;
149///
150/// let ts = Timestamp { seconds: 1, nanos: 500 };
151/// assert_eq!(proto_to_nanos(&ts).as_nanos(), 1_000_000_500);
152/// ```
153pub fn proto_to_nanos(ts: &Timestamp) -> UnixNanos {
154    UnixNanos::from_nanos(
155        ts.seconds
156            .saturating_mul(1_000_000_000)
157            .saturating_add(i64::from(ts.nanos)),
158    )
159}
160
161/// Converts nanoseconds since the Unix epoch to a protobuf `Timestamp`. Negative times keep a
162/// non-negative `nanos` part, as protobuf requires.
163///
164/// # Examples
165///
166/// ```
167/// use brokapi_core::UnixNanos;
168/// use brokapi_tbank::ids::nanos_to_proto;
169///
170/// let ts = nanos_to_proto(UnixNanos::from_nanos(2_000_000_007));
171/// assert_eq!((ts.seconds, ts.nanos), (2, 7));
172/// ```
173pub fn nanos_to_proto(n: UnixNanos) -> Timestamp {
174    let n = n.as_nanos();
175    Timestamp {
176        seconds: n.div_euclid(1_000_000_000),
177        nanos: n.rem_euclid(1_000_000_000) as i32,
178    }
179}
180
181/// Reception timestamps with the broker time attached when the message has one.
182///
183/// `now` is the instant the message was received. A missing broker time, or one that is exactly
184/// zero, is left out.
185///
186/// # Examples
187///
188/// ```
189/// use std::time::Instant;
190/// use brokapi_tbank::ids::stamps;
191/// use prost_types::Timestamp;
192///
193/// assert!(stamps(Instant::now(), None).broker_ts.is_none());
194/// let t = stamps(Instant::now(), Some(&Timestamp { seconds: 5, nanos: 0 }));
195/// assert!(t.broker_ts.is_some());
196/// ```
197pub fn stamps(now: Instant, broker: Option<&Timestamp>) -> Timestamps {
198    let t = Timestamps::new(wall_nanos(), now);
199    match broker {
200        Some(b) if b.seconds != 0 || b.nanos != 0 => t.with_broker_ts(proto_to_nanos(b)),
201        _ => t,
202    }
203}
204
205/// A [`Quotation`] as a [`Price`], exactly (units and nano, no `f64`).
206///
207/// # Examples
208///
209/// ```
210/// use brokapi_core::Price;
211/// use brokapi_tbank::ids::price_of_quotation;
212/// use brokapi_tbank_protocol::money::quotation;
213///
214/// assert_eq!(price_of_quotation(&quotation("244.30")), "244.3".parse::<Price>()?);
215/// # Ok::<(), brokapi_core::NumberError>(())
216/// ```
217pub fn price_of_quotation(q: &Quotation) -> Price {
218    Price::new(quotation_to_decimal(q))
219}
220
221/// A [`MoneyValue`] as a [`Price`], exactly. The currency is ignored.
222pub fn price_of_money(m: &MoneyValue) -> Price {
223    Price::new(money_to_decimal(m))
224}
225
226/// A [`MoneyValue`] as [`Money`], exactly. The currency is ignored: read it from the message.
227pub fn money_of(m: &MoneyValue) -> Money {
228    Money::new(money_to_decimal(m))
229}
230
231/// A [`Quotation`] as a [`Qty`], exactly.
232pub fn qty_of_quotation(q: &Quotation) -> Qty {
233    Qty::new(quotation_to_decimal(q))
234}
235
236/// A [`Price`] as the [`Quotation`] a request carries, exactly.
237///
238/// # Errors
239///
240/// [`Error::Rejected`] with code `invalid_price` when the price has more than 9 decimal places (T-Bank
241/// counts in billionths) or does not fit. The price is never rounded in silence.
242///
243/// # Examples
244///
245/// ```
246/// use brokapi_core::Price;
247/// use brokapi_tbank::ids::price_to_quotation;
248///
249/// let q = price_to_quotation("244.30".parse::<Price>()?)?;
250/// assert_eq!((q.units, q.nano), (244, 300_000_000));
251/// assert!(price_to_quotation("0.0000000001".parse::<Price>()?).is_err());
252/// # Ok::<(), Box<dyn std::error::Error>>(())
253/// ```
254pub fn price_to_quotation(price: Price) -> Result<Quotation, Error> {
255    decimal_to_quotation(price.decimal()).map_err(|e| Error::Rejected {
256        code: "invalid_price".into(),
257        message: format!("price {price} cannot be sent: {e}"),
258    })
259}
260
261/// Refuses an [`InstrumentId`] whose [`NativeId`] is of another broker's kind (`SymbolMic` is the Finam
262/// form), before anything is sent.
263///
264/// # Errors
265///
266/// [`Error::Config`]: T-Bank takes a uid, a figi, or a ticker with a class code.
267///
268/// # Examples
269///
270/// ```
271/// use brokapi_core::{Error, InstrumentId, InstrumentKey, NativeId};
272/// use brokapi_tbank::ids::require_native;
273///
274/// let finam = InstrumentId::new(
275///     InstrumentKey::new("SBER", "MISX"),
276///     NativeId::SymbolMic { symbol: "SBER".into(), mic: "MISX".into() },
277/// );
278/// assert!(matches!(require_native(&finam), Err(Error::Config(_))));
279/// ```
280pub fn require_native(id: &InstrumentId) -> Result<(), Error> {
281    match id.native {
282        NativeId::SymbolMic { .. } => {
283            Err(Error::Config(brokapi_core::ConfigError::Message(format!(
284                "instrument {} has an id of another broker (symbol and mic): T-Bank takes a uid, a figi, or a ticker with a class code",
285                id.key.display()
286            ))))
287        }
288        _ => Ok(()),
289    }
290}
291
292/// The directory request for an [`InstrumentId`]: by uid, figi, or ticker with class code.
293pub fn instrument_request(id: &InstrumentId) -> pb::InstrumentRequest {
294    match &id.native {
295        NativeId::Uid(u) => pb::InstrumentRequest {
296            id_type: pb::InstrumentIdType::Uid as i32,
297            id: u.clone(),
298            class_code: None,
299        },
300        NativeId::Figi(f) => pb::InstrumentRequest {
301            id_type: pb::InstrumentIdType::Figi as i32,
302            id: f.clone(),
303            class_code: None,
304        },
305        NativeId::TickerClass { ticker, class_code } => pb::InstrumentRequest {
306            id_type: pb::InstrumentIdType::Ticker as i32,
307            id: ticker.clone(),
308            class_code: Some(class_code.clone()),
309        },
310        NativeId::SymbolMic { symbol, mic } => pb::InstrumentRequest {
311            id_type: pb::InstrumentIdType::Ticker as i32,
312            id: symbol.clone(),
313            class_code: Some(mic.clone()),
314        },
315        _ => pb::InstrumentRequest {
316            id_type: pb::InstrumentIdType::Ticker as i32,
317            id: id.key.symbol.clone(),
318            class_code: Some(id.key.mic.clone()),
319        },
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn sber_uid() -> InstrumentId {
328        InstrumentId::new(
329            InstrumentKey::new("SBER", "TQBR"),
330            NativeId::Uid("e6123145".into()),
331        )
332    }
333
334    #[test]
335    fn ref_per_native_form() {
336        assert_eq!(instrument_ref(&sber_uid()), "e6123145");
337        let tc = InstrumentId::new(
338            InstrumentKey::new("SBER", "TQBR"),
339            NativeId::TickerClass {
340                ticker: "SBER".into(),
341                class_code: "TQBR".into(),
342            },
343        );
344        assert_eq!(instrument_ref(&tc), "SBER_TQBR");
345        let f = InstrumentId::new(
346            InstrumentKey::new("SBER", "TQBR"),
347            NativeId::Figi("BBG004730N88".into()),
348        );
349        assert_eq!(instrument_ref(&f), "BBG004730N88");
350    }
351
352    #[test]
353    fn matching_by_uid_figi_or_ticker() {
354        let id = sber_uid();
355        assert!(matches(&id, "e6123145", "", "", ""));
356        assert!(matches(&id, "", "", "SBER", "TQBR"));
357        assert!(!matches(&id, "other", "BBG", "GAZP", "TQBR"));
358    }
359
360    #[test]
361    fn an_id_of_another_broker_is_a_config_error_before_the_network() {
362        assert!(require_native(&sber_uid()).is_ok());
363        let finam = InstrumentId::new(
364            InstrumentKey::new("SBER", "MISX"),
365            NativeId::SymbolMic {
366                symbol: "SBER".into(),
367                mic: "MISX".into(),
368            },
369        );
370        assert!(matches!(require_native(&finam), Err(Error::Config(_))));
371    }
372
373    #[test]
374    fn instrument_requests_per_native_form() {
375        assert_eq!(
376            instrument_request(&sber_uid()).id_type,
377            pb::InstrumentIdType::Uid as i32
378        );
379        let f = InstrumentId::new(InstrumentKey::new("S", "T"), NativeId::Figi("F".into()));
380        assert_eq!(
381            instrument_request(&f).id_type,
382            pb::InstrumentIdType::Figi as i32
383        );
384        let tc = InstrumentId::new(
385            InstrumentKey::new("SBER", "TQBR"),
386            NativeId::TickerClass {
387                ticker: "SBER".into(),
388                class_code: "TQBR".into(),
389            },
390        );
391        let r = instrument_request(&tc);
392        assert_eq!(
393            (r.id.as_str(), r.class_code.as_deref()),
394            ("SBER", Some("TQBR"))
395        );
396        let sm = InstrumentId::new(
397            InstrumentKey::new("SBER", "TQBR"),
398            NativeId::SymbolMic {
399                symbol: "SBER".into(),
400                mic: "TQBR".into(),
401            },
402        );
403        assert_eq!(
404            instrument_request(&sm).id_type,
405            pb::InstrumentIdType::Ticker as i32
406        );
407    }
408
409    #[test]
410    fn proto_time_roundtrip() {
411        let n = UnixNanos::from_nanos(1_700_000_000_123_456_789);
412        assert_eq!(proto_to_nanos(&nanos_to_proto(n)), n);
413    }
414}