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("ation("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}