Skip to main content

brokapi_finam/
util.rs

1//! Shared helpers: instrument ids, timestamps, decimals.
2
3use std::str::FromStr;
4use std::time::{Instant, SystemTime, UNIX_EPOCH};
5
6use brokapi_core::{
7    ConfigError, Error, InstrumentId, InstrumentKey, NativeId, Timestamps, UnixNanos,
8};
9use brokapi_core::{Decimal as Dec, Money as BrokerMoney, Price, Qty};
10use brokapi_finam_protocol::pb::google::r#type::{Decimal, Money};
11
12/// `SBER@MISX` for the Finam API. Needs `NativeId::SymbolMic`, no network.
13pub fn symbol_of(id: &InstrumentId) -> Result<String, Error> {
14    match &id.native {
15        NativeId::SymbolMic { symbol, mic } => Ok(format!("{symbol}@{mic}")),
16        _ => Err(Error::Config(ConfigError::Message(
17            "instrument native id must be SymbolMic for Finam".into(),
18        ))),
19    }
20}
21
22/// Instrument id from a Finam symbol (`SBER@MISX`). A symbol without `@` gets an empty MIC.
23pub fn instrument_from_symbol(symbol: &str) -> InstrumentId {
24    let (sym, mic) = symbol.rsplit_once('@').unwrap_or((symbol, ""));
25    InstrumentId::new(
26        InstrumentKey::new(sym, mic),
27        NativeId::SymbolMic {
28            symbol: sym.to_string(),
29            mic: mic.to_string(),
30        },
31    )
32}
33
34/// Placeholder instrument for subscriptions that are not about one instrument (orders, account).
35pub fn no_instrument() -> InstrumentId {
36    instrument_from_symbol("")
37}
38
39/// Wall clock now, in nanoseconds since the Unix epoch. Gives 0 when the clock is before the epoch.
40///
41/// # Examples
42///
43/// ```
44/// let now = brokapi_finam::util::now_nanos();
45/// assert!(now.as_nanos() > 1_700_000_000_000_000_000);
46/// ```
47pub fn now_nanos() -> UnixNanos {
48    UnixNanos::from_nanos(
49        SystemTime::now()
50            .duration_since(UNIX_EPOCH)
51            .map(|d| d.as_nanos() as i64)
52            .unwrap_or(0),
53    )
54}
55
56/// Protobuf timestamp -> Unix nanoseconds. Saturates instead of overflowing on absurd values.
57pub fn ts_nanos(ts: &prost_types::Timestamp) -> UnixNanos {
58    UnixNanos::from_nanos(
59        ts.seconds
60            .saturating_mul(1_000_000_000)
61            .saturating_add(ts.nanos as i64),
62    )
63}
64
65/// Receive timestamps with the broker time attached when present.
66pub fn timestamps(broker: Option<&prost_types::Timestamp>, rx_mono: Instant) -> Timestamps {
67    let t = Timestamps::new(now_nanos(), rx_mono);
68    match broker {
69        Some(b) if b.seconds != 0 || b.nanos != 0 => t.with_broker_ts(ts_nanos(b)),
70        _ => t,
71    }
72}
73
74/// Unix nanoseconds -> protobuf timestamp. Negative values give `seconds < 0` and `nanos >= 0`, the
75/// protobuf way.
76///
77/// # Examples
78///
79/// ```
80/// use brokapi_core::UnixNanos;
81/// use brokapi_finam::util::{to_timestamp, ts_nanos};
82///
83/// let t = to_timestamp(UnixNanos::from_nanos(1_700_000_000_123_456_789));
84/// assert_eq!((t.seconds, t.nanos), (1_700_000_000, 123_456_789));
85/// assert_eq!(ts_nanos(&t).as_nanos(), 1_700_000_000_123_456_789);
86/// ```
87pub fn to_timestamp(n: UnixNanos) -> prost_types::Timestamp {
88    let n = n.as_nanos();
89    prost_types::Timestamp {
90        seconds: n.div_euclid(1_000_000_000),
91        nanos: n.rem_euclid(1_000_000_000) as i32,
92    }
93}
94
95/// `google.type.Decimal` -> [`Dec`], exactly: the text is parsed as it is, no `f64` on the way.
96/// Empty, absent or unparseable -> `None`. The grammar of the message allows a sign, a leading or
97/// trailing point and an exponent.
98pub fn dec(d: &Option<Decimal>) -> Option<Dec> {
99    let s = d.as_ref()?.value.trim();
100    if s.is_empty() {
101        return None;
102    }
103    let s = s.strip_prefix('+').unwrap_or(s);
104    Dec::from_str(s).or_else(|_| Dec::from_scientific(s)).ok()
105}
106
107/// Like [`dec`] but 0 for absent.
108pub fn dec0(d: &Option<Decimal>) -> Dec {
109    dec(d).unwrap_or(Dec::ZERO)
110}
111
112/// [`dec`] as a [`Price`].
113pub fn price(d: &Option<Decimal>) -> Option<Price> {
114    dec(d).map(Price::new)
115}
116
117/// [`dec0`] as a [`Price`].
118pub fn price0(d: &Option<Decimal>) -> Price {
119    Price::new(dec0(d))
120}
121
122/// [`dec`] as a [`Qty`].
123pub fn qty(d: &Option<Decimal>) -> Option<Qty> {
124    dec(d).map(Qty::new)
125}
126
127/// [`dec0`] as a [`Qty`].
128pub fn qty0(d: &Option<Decimal>) -> Qty {
129    Qty::new(dec0(d))
130}
131
132/// A number -> `google.type.Decimal`, exactly. The text is plain (Rust never writes an exponent for
133/// a decimal), the scale is kept: `244.30` is sent as `"244.30"`.
134pub fn to_decimal(v: Dec) -> Decimal {
135    Decimal {
136        value: v.to_string(),
137    }
138}
139
140/// `google.type.Money` -> (currency, amount), exactly. `units` and `nanos` carry the sign of the value.
141pub fn money(m: &Money) -> (String, BrokerMoney) {
142    (
143        m.currency_code.clone(),
144        BrokerMoney::new((Dec::from(m.units) + Dec::new(i64::from(m.nanos), 9)).normalize()),
145    )
146}
147
148/// (currency, amount) -> `google.type.Money`, exactly. The reverse of [`money`].
149///
150/// `None` when the amount has more than 9 decimal places (`nanos` counts billionths) or its integer
151/// part does not fit in an `i64`: the amount is never rounded in silence. `units` and `nanos` carry the
152/// sign of the amount.
153pub fn to_money(currency: &str, amount: BrokerMoney) -> Option<Money> {
154    let v = amount.decimal().normalize();
155    if v.scale() > 9 {
156        return None;
157    }
158    let units = i64::try_from(v.trunc()).ok()?;
159    let mut frac = v.fract();
160    frac.rescale(9);
161    let nanos = i32::try_from(frac.mantissa()).ok()?;
162    Some(Money {
163        currency_code: currency.to_string(),
164        units,
165        nanos,
166    })
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use proptest::prelude::*;
173
174    proptest! {
175        // broker -> type -> broker: the text of a Decimal comes back as it was.
176        #[test]
177        fn decimal_text_round_trips(m in -1_000_000_000_000_000_000_i64..1_000_000_000_000_000_000, scale in 0u32..=12) {
178            let text = Dec::new(m, scale).to_string();
179            let back = to_decimal(dec(&Some(Decimal { value: text.clone() })).unwrap());
180            prop_assert_eq!(back.value, text);
181        }
182
183        // broker -> type -> broker for Money: units and nanos come back as they were.
184        #[test]
185        fn money_round_trips(units in -1_000_000_000_000_i64..1_000_000_000_000, nanos in 0i32..1_000_000_000, neg in any::<bool>()) {
186            let (units, nanos) = if neg { (-units.abs(), -nanos) } else { (units.abs(), nanos) };
187            let wire = Money { currency_code: "RUB".into(), units, nanos };
188            let (ccy, amount) = money(&wire);
189            prop_assert_eq!(to_money(&ccy, amount), Some(wire));
190        }
191    }
192
193    #[test]
194    fn money_with_a_tenth_of_a_nano_is_refused() {
195        assert_eq!(to_money("RUB", BrokerMoney::new(Dec::new(1, 10))), None);
196    }
197
198    #[test]
199    fn decimal_grammar() {
200        let d = |s: &str| Some(Decimal { value: s.into() });
201        let n = |s: &str| Some(s.parse::<Dec>().unwrap());
202        assert_eq!(dec(&d("123.45")), n("123.45"));
203        assert_eq!(dec(&d("+2.5")), n("2.5"));
204        assert_eq!(dec(&d("-0.001")), n("-0.001"));
205        assert_eq!(dec(&d(".5")), n("0.5"));
206        assert_eq!(dec(&d("2.5e3")), n("2500"));
207        assert_eq!(dec(&d("1E-2")), n("0.01"));
208        assert_eq!(dec(&d("")), None);
209        assert_eq!(dec(&d("abc")), None);
210        assert_eq!(dec(&d("NaN")), None);
211        assert_eq!(dec(&None), None);
212    }
213
214    #[test]
215    fn decimal_keeps_the_written_form() {
216        let d = |s: &str| Some(Decimal { value: s.into() });
217        assert_eq!(dec(&d("244.30")).unwrap().to_string(), "244.30");
218        assert_eq!(dec(&d("0.0001")).unwrap().to_string(), "0.0001");
219        assert_eq!(to_decimal(dec(&d("244.30")).unwrap()).value, "244.30");
220    }
221
222    #[test]
223    fn decimal_text_has_no_exponent() {
224        for s in [
225            "0",
226            "1",
227            "150.5",
228            "0.0000001",
229            "1000000000000000",
230            "123456.789",
231            "-4.2",
232        ] {
233            let v: Dec = s.parse().unwrap();
234            let out = to_decimal(v).value;
235            assert!(!out.contains('e') && !out.contains('E'), "{out}");
236            assert_eq!(out, s);
237        }
238    }
239
240    #[test]
241    fn money_units_and_nanos() {
242        let m = Money {
243            currency_code: "RUB".into(),
244            units: 1000,
245            nanos: 500_000_000,
246        };
247        assert_eq!(money(&m), ("RUB".into(), "1000.5".parse().unwrap()));
248        let neg = Money {
249            currency_code: "RUB".into(),
250            units: -3,
251            nanos: -10_000_000,
252        };
253        assert_eq!(money(&neg).1, "-3.01".parse().unwrap());
254    }
255
256    #[test]
257    fn symbol_roundtrip_without_network() {
258        let id = instrument_from_symbol("SBER@MISX");
259        assert_eq!(id.key.display(), "SBER@MISX");
260        assert_eq!(symbol_of(&id).unwrap(), "SBER@MISX");
261    }
262
263    #[test]
264    fn non_finam_native_id_is_a_config_error() {
265        let id = InstrumentId::new(
266            InstrumentKey::new("SBER", "TQBR"),
267            NativeId::Figi("X".into()),
268        );
269        assert!(matches!(symbol_of(&id), Err(Error::Config(_))));
270    }
271
272    #[test]
273    fn timestamp_conversions() {
274        let n = UnixNanos::from_nanos(1_700_000_000_123_456_789);
275        let t = to_timestamp(n);
276        assert_eq!(ts_nanos(&t), n);
277    }
278}