Skip to main content

brokapi_finam/
cid.rs

1//! Client order id codec
2//!
3//! Finam's `client_order_id` holds at most 20 characters (alphabet not documented) and a UUID is
4//! 128 bits, so a UUID cannot be carried there losslessly. The order `comment` ("order label",
5//! up to 128 characters) can, and Finam echoes the whole `Order` in order state and the same label
6//! in `AccountTrade.comment`. So:
7//!
8//! - the lossless, reversible form (this codec) goes into `comment`: `bc1:<32 hex>`;
9//! - `client_order_id` carries a 20-hex-char tag derived from the UUID (80 bits). It is not
10//!   reversible, it is a secondary key used when the comment is missing in an echo.
11//!
12//! That the comment is echoed back unchanged is not verified against the live API.
13//!
14//! # Examples
15//!
16//! ```
17//! use brokapi_core::{CidCodec, ClientOrderId};
18//! use brokapi_finam::FinamCidCodec;
19//!
20//! let codec = FinamCidCodec;
21//! let cid = ClientOrderId::new_v4();
22//!
23//! // The comment carries the whole id and decodes back to it.
24//! let comment = codec.encode(cid).unwrap();
25//! assert!(comment.as_str().starts_with("bc1:"));
26//! assert_eq!(codec.decode(&comment), Some(cid));
27//!
28//! // The `client_order_id` tag is short and cannot be decoded.
29//! assert_eq!(codec.client_order_id(cid).len(), 20);
30//! ```
31
32use brokapi_core::{
33    Charset, CidCaps, CidCodec, CidError, ClientOrderId, ConfigError, Error, NativeCid,
34};
35use uuid::Uuid;
36
37/// Marks a comment written by this library: `bc1:` followed by 32 hex characters.
38///
39/// A comment without the prefix (an order placed by hand, or by another program) does not decode.
40pub const COMMENT_PREFIX: &str = "bc1:";
41/// Documented limit of `Order.client_order_id`.
42pub const CLIENT_ORDER_ID_MAX: usize = 20;
43/// Documented limit of `Order.comment`.
44pub const COMMENT_MAX: usize = 128;
45
46/// Codec between the library's client order id (a UUID) and the fields Finam stores on an order.
47///
48/// Implements [`CidCodec`]: [`encode`](CidCodec::encode) makes the lossless text for the order
49/// `comment`, [`decode`](CidCodec::decode) reads it back. [`FinamCidCodec::client_order_id`] makes the
50/// short, one-way tag for the `client_order_id` field.
51///
52/// The connector uses it on its own. You need it to read the cid out of an order you fetched through
53/// `raw()`, or to find your order at the broker by its comment.
54///
55/// See the [module docs](self) for the reason for two fields.
56#[derive(Debug, Clone, Copy, Default)]
57pub struct FinamCidCodec;
58
59impl FinamCidCodec {
60    /// The 20 character tag for the `client_order_id` field of an order.
61    ///
62    /// 20 lowercase hex chars from 10 fully random bytes of the UUID (v4 version/variant skipped).
63    /// It is one-way: 80 bits of 128 cannot rebuild the cid. It is only a second key for the case
64    /// when the comment is missing in an echo.
65    pub fn client_order_id(&self, cid: ClientOrderId) -> String {
66        const PICK: [usize; 10] = [0, 1, 2, 3, 4, 5, 7, 9, 10, 11];
67        let b = cid.as_bytes();
68        PICK.iter().map(|&i| format!("{:02x}", b[i])).collect()
69    }
70
71    /// Startup check: the formats must fit the broker limits, otherwise it is a
72    /// configuration error, not a runtime degradation.
73    ///
74    /// Encodes a sample cid, checks the length of the comment and of the tag, and decodes the
75    /// comment back. [`UserDataConnector::start`](crate::UserDataConnector::start) calls it.
76    ///
77    /// # Errors
78    ///
79    /// [`brokapi_core::Error::Config`] when a form does not fit the documented limits
80    /// ([`COMMENT_MAX`], [`CLIENT_ORDER_ID_MAX`]) or does not decode back to the same cid.
81    ///
82    /// # Examples
83    ///
84    /// ```
85    /// use brokapi_finam::FinamCidCodec;
86    ///
87    /// FinamCidCodec.validate().expect("the codec fits the documented Finam limits");
88    /// ```
89    pub fn validate(&self) -> Result<(), Error> {
90        let sample = Uuid::from_bytes([0xff; 16]);
91        let comment = self.encode(sample).map_err(cfg)?;
92        if comment.as_str().len() > COMMENT_MAX {
93            return Err(cfg(CidError::TooLong {
94                len: comment.as_str().len(),
95                max: COMMENT_MAX,
96            }));
97        }
98        let tag = self.client_order_id(sample);
99        if tag.len() > CLIENT_ORDER_ID_MAX {
100            return Err(cfg(CidError::TooLong {
101                len: tag.len(),
102                max: CLIENT_ORDER_ID_MAX,
103            }));
104        }
105        match self.decode(&comment) {
106            Some(back) if back == sample => Ok(()),
107            _ => Err(cfg(CidError::NotLossless)),
108        }
109    }
110}
111
112fn cfg(e: CidError) -> Error {
113    Error::Config(ConfigError::Message(format!("finam cid codec: {e}")))
114}
115
116impl CidCodec for FinamCidCodec {
117    fn caps(&self) -> CidCaps {
118        CidCaps {
119            // Capacity of the carrying field (`comment`), not of `client_order_id`.
120            max_len: COMMENT_MAX,
121            charset: Charset::Ascii,
122            native_bits: 128,
123            lossless: true,
124        }
125    }
126
127    fn encode(&self, cid: ClientOrderId) -> Result<NativeCid, CidError> {
128        Ok(NativeCid::new(format!(
129            "{COMMENT_PREFIX}{}",
130            cid.as_simple()
131        )))
132    }
133
134    fn decode(&self, native: &NativeCid) -> Option<ClientOrderId> {
135        let rest = native.as_str().strip_prefix(COMMENT_PREFIX)?;
136        Uuid::parse_str(rest).ok()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use proptest::prelude::*;
143
144    use super::*;
145
146    proptest! {
147        #[test]
148        fn comment_roundtrip(bytes in any::<[u8; 16]>()) {
149            let codec = FinamCidCodec;
150            let cid = ClientOrderId::from_bytes(bytes);
151            let native = codec.encode(cid).unwrap();
152            prop_assert!(native.as_str().len() <= COMMENT_MAX);
153            prop_assert_eq!(codec.decode(&native), Some(cid));
154        }
155
156        #[test]
157        fn tag_fits_the_broker_field(bytes in any::<[u8; 16]>()) {
158            let tag = FinamCidCodec.client_order_id(ClientOrderId::from_bytes(bytes));
159            prop_assert_eq!(tag.len(), CLIENT_ORDER_ID_MAX);
160            prop_assert!(tag.bytes().all(|c| c.is_ascii_hexdigit()));
161        }
162    }
163
164    #[test]
165    fn foreign_comments_do_not_decode() {
166        let c = FinamCidCodec;
167        assert_eq!(c.decode(&NativeCid::new("my own label")), None);
168        assert_eq!(c.decode(&NativeCid::new("bc1:not-a-uuid")), None);
169    }
170
171    #[test]
172    fn config_check_passes() {
173        FinamCidCodec.validate().unwrap();
174    }
175}