Skip to main content

brokapi_finam/
config.rs

1//! Connector configuration: the credentials and endpoint shared by both roles ([`FinamApiConfig`]) and
2//! the settings of each role ([`MarketDataConfig`], [`UserDataConfig`]).
3//!
4//! # Examples
5//!
6//! ```
7//! use brokapi_finam::{FinamApiConfig, MarketDataConfig, UserDataConfig};
8//!
9//! // Secret from your own storage. The default endpoint is the real broker.
10//! let api = FinamApiConfig::new("my-api-secret");
11//! let md = MarketDataConfig::new(api.clone(), "finam-md");
12//! let ud = UserDataConfig::new(api, "A12345", "finam-ud-A12345");
13//! assert_eq!(ud.account_id, "A12345");
14//! assert_eq!(&*md.source, "finam-md");
15//! // `{:?}` never prints the secret.
16//! assert!(!format!("{ud:?}").contains("my-api-secret"));
17//! ```
18
19use std::fmt;
20use std::sync::Arc;
21use std::time::Duration;
22
23use brokapi_core::{ConfigError, Error};
24use brokapi_finam_protocol::{ApiEndpoint, FinamError, TokenManager};
25use brokapi_runtime::PriceGrid;
26
27/// Connection and credentials, shared in shape by both connector roles.
28///
29/// Holds the API secret, so it is not `Display`, and its `Debug` output hides the secret.
30///
31/// # Examples
32///
33/// Two accounts of one secret share one JWT. The token manager is started once and put into both
34/// configs. Here the broker is the in-process imitation:
35///
36/// ```
37/// use brokapi_finam::{FinamApiConfig, UserDataConfig};
38/// # use brokapi_mocks::finam::{MockServer, SECRET};
39///
40/// # #[tokio::main(flavor = "current_thread")]
41/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
42/// # let server = MockServer::start().await;
43/// # let cfg = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
44/// // `cfg` is your config: secret plus endpoint.
45/// let tokens = cfg.shared_tokens().await?;
46/// let shared = cfg.with_tokens(tokens);
47/// let first = UserDataConfig::new(shared.clone(), "ACC1", "finam-ud-ACC1");
48/// let second = UserDataConfig::new(shared, "ACC2", "finam-ud-ACC2");
49/// assert_ne!(first.account_id, second.account_id);
50/// # Ok(())
51/// # }
52/// ```
53#[derive(Clone)]
54pub struct FinamApiConfig {
55    /// Address and timeouts of the channel. Default: the production endpoint.
56    pub endpoint: ApiEndpoint,
57    /// API secret. Ignored when `tokens` is set.
58    pub secret: String,
59    /// Optional application id sent with `Auth`.
60    pub source_app_id: String,
61    /// Already running token manager to reuse (several accounts / roles on one secret).
62    pub tokens: Option<Arc<TokenManager>>,
63    /// Send `authorization: Bearer <jwt>` instead of the bare JWT (official SDKs send it bare).
64    pub bearer: bool,
65    /// Deadline of a unary call. Default 10 seconds.
66    ///
67    /// When it passes with no answer, a read fails with a transport error, and a command (place, cancel)
68    /// ends as `Error::Unknown`, because the broker may still have applied it.
69    pub request_timeout: Duration,
70}
71
72impl FinamApiConfig {
73    /// Config for `secret` with the production endpoint and default settings.
74    ///
75    /// Nothing is checked or sent here. An empty or wrong secret shows up when a connector starts
76    /// (`Error::Config` for an empty one, `Error::Rejected` for one the broker refuses).
77    pub fn new(secret: impl Into<String>) -> Self {
78        Self {
79            endpoint: ApiEndpoint::default(),
80            secret: secret.into(),
81            source_app_id: String::new(),
82            tokens: None,
83            bearer: false,
84            request_timeout: Duration::from_secs(10),
85        }
86    }
87
88    /// Reuse a token manager (see [`FinamApiConfig::shared_tokens`]).
89    ///
90    /// When set, the connector does not log in itself and `secret` is not used.
91    pub fn with_tokens(mut self, tokens: Arc<TokenManager>) -> Self {
92        self.tokens = Some(tokens);
93        self
94    }
95
96    /// Points the config at another gRPC server, with the default timeouts.
97    ///
98    /// `url` is `https://host:port` (TLS) or `http://host:port` (plain, for a proxy or the test
99    /// imitation). It replaces the whole [`ApiEndpoint`], so set custom timeouts on
100    /// [`FinamApiConfig::endpoint`] after this call.
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use brokapi_finam::FinamApiConfig;
106    ///
107    /// let cfg = FinamApiConfig::new("secret").with_endpoint("http://127.0.0.1:50051");
108    /// assert_eq!(cfg.endpoint.url, "http://127.0.0.1:50051");
109    /// ```
110    pub fn with_endpoint(mut self, url: impl Into<String>) -> Self {
111        self.endpoint = ApiEndpoint::new(url);
112        self
113    }
114
115    /// Authenticates once and returns the manager to put into several connector configs, so that
116    /// all accounts of one secret share a single JWT.
117    ///
118    /// The manager renews the token in the background until the last `Arc` is dropped.
119    ///
120    /// # Errors
121    ///
122    /// - [`Error::Config`]: the secret is empty or the endpoint URL is invalid.
123    /// - [`Error::Config`] also: the broker refused the secret (a wrong token is a configuration problem at every broker).
124    /// - [`Error::RateLimited`]: the broker answered `RESOURCE_EXHAUSTED`.
125    /// - [`Error::Transport`]: the broker could not be reached or the call timed out.
126    pub async fn shared_tokens(&self) -> Result<Arc<TokenManager>, Error> {
127        brokapi_finam_protocol::start_token_manager(
128            &self.endpoint,
129            &self.secret,
130            &self.source_app_id,
131        )
132        .await
133        .map_err(|e| crate::error::map_protocol_err(&e))
134    }
135
136    pub(crate) async fn resolve_tokens(&self) -> Result<Arc<TokenManager>, Error> {
137        match &self.tokens {
138            Some(t) => Ok(Arc::clone(t)),
139            None => self.shared_tokens().await,
140        }
141    }
142}
143
144impl fmt::Debug for FinamApiConfig {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        f.debug_struct("FinamApiConfig")
147            .field("endpoint", &self.endpoint.url)
148            .field("secret", &"<redacted>")
149            .field("shared_tokens", &self.tokens.is_some())
150            .field("bearer", &self.bearer)
151            .finish()
152    }
153}
154
155/// Market data connector config. Not bound to an account.
156///
157/// Used with [`MarketDataConnector::start`](crate::MarketDataConnector::start).
158#[derive(Debug, Clone)]
159pub struct MarketDataConfig {
160    /// Credentials and endpoint.
161    pub api: FinamApiConfig,
162    /// Envelope source label, e.g. `"finam-md"`.
163    pub source: Arc<str>,
164    /// How long `subscribe_*` waits for the stream to open (default 3 seconds).
165    ///
166    /// The call returns as soon as the broker has opened the stream: what happens after that is delivered. A
167    /// refusal (unknown symbol, no permission) is returned as `Rejected`. **A stream that has not opened in time
168    /// leaves the subscription connecting and the call returns `Ok`**, unlike the other brokers: Finam is gRPC,
169    /// and a gRPC server may send the headers of a quiet stream only together with its first message, so a
170    /// healthy stream of orders can look "not open" until the first order. Until the stream is open, events of
171    /// the broker are not delivered: check
172    /// [`MarketDataConnector::sub_status`](crate::MarketDataConnector::sub_status). Not verified against the live
173    /// server; if it sends the headers at once, this will become an error like at the other brokers.
174    pub subscribe_ack_timeout: Duration,
175}
176
177impl MarketDataConfig {
178    /// Config with default settings. `source` labels every envelope the connector emits.
179    pub fn new(api: FinamApiConfig, source: impl Into<Arc<str>>) -> Self {
180        Self {
181            api,
182            source: source.into(),
183            subscribe_ack_timeout: Duration::from_secs(3),
184        }
185    }
186}
187
188/// User data connector config: one connector = one account.
189///
190/// Used with [`UserDataConnector::start`](crate::UserDataConnector::start).
191#[derive(Debug, Clone)]
192pub struct UserDataConfig {
193    /// Credentials and endpoint.
194    pub api: FinamApiConfig,
195    /// Account all orders, executions and portfolio belong to.
196    pub account_id: String,
197    /// Envelope source label, e.g. `"finam-ud-A12345"`.
198    pub source: Arc<str>,
199    /// Lossless buffer capacity for order-state and execution subscriptions. Default 1024.
200    ///
201    /// When it fills up, the subscription reports an overflow and the connector turns `Degraded`:
202    /// trading commands are refused until `reconcile` succeeds. Events are never dropped silently.
203    pub order_state_buffer: usize,
204    /// `limit` sent with the `Trades` history request. Default 1000.
205    ///
206    /// `executions(range)` returns at most this many trades.
207    pub executions_limit: i32,
208    /// How long `subscribe_*` waits for the stream to open (default 3 seconds). Same meaning as in
209    /// [`MarketDataConfig`].
210    pub subscribe_ack_timeout: Duration,
211    /// Price steps the connector checks order prices against (default: empty, nothing is checked).
212    /// Fill it with [`PriceGrid::learn`] from the trading parameters of the instruments you trade. A
213    /// price off the step is then refused with `Rejected` (`price_off_step`) before anything is sent.
214    pub price_grid: PriceGrid,
215}
216
217impl UserDataConfig {
218    /// Config for one `account_id` with default settings. `source` labels every envelope.
219    ///
220    /// The account is checked when the connector starts: it must not be empty and, when the token
221    /// lists its accounts, it must be one of them.
222    pub fn new(
223        api: FinamApiConfig,
224        account_id: impl Into<String>,
225        source: impl Into<Arc<str>>,
226    ) -> Self {
227        Self {
228            api,
229            account_id: account_id.into(),
230            source: source.into(),
231            order_state_buffer: 1024,
232            executions_limit: 1000,
233            subscribe_ack_timeout: Duration::from_secs(3),
234            price_grid: PriceGrid::default(),
235        }
236    }
237
238    pub(crate) fn check(&self) -> Result<(), Error> {
239        if self.account_id.trim().is_empty() {
240            return Err(Error::Config(ConfigError::Message(
241                "empty account_id".into(),
242            )));
243        }
244        Ok(())
245    }
246}
247
248/// Reads the config from environment variables. Meant for examples and live tests.
249///
250/// Variables:
251///
252/// - `FINAM_SECRET` (required): the API secret.
253/// - `FINAM_API_URL` (optional): another endpoint instead of the production one. It must start with
254///   `https://`: the address of a local imitation is set in code with `FinamApiConfig::with_endpoint`.
255/// - `FINAM_ACCOUNT_ID` (optional): returned as the second value, for user data connectors.
256///
257/// # Errors
258///
259/// [`FinamError::Config`] when `FINAM_SECRET` is not set, or `FINAM_API_URL` is set and is not an
260/// `https://` address. Nothing is sent to the network here.
261///
262/// # Examples
263///
264/// ```
265/// use brokapi_finam::api_config_from_env;
266///
267/// // Without FINAM_SECRET the call fails and says which variable is missing.
268/// if std::env::var_os("FINAM_SECRET").is_none() {
269///     let err = api_config_from_env().unwrap_err();
270///     assert!(err.to_string().contains("FINAM_SECRET"));
271/// }
272/// ```
273pub fn api_config_from_env() -> Result<(FinamApiConfig, Option<String>), FinamError> {
274    let secret = std::env::var("FINAM_SECRET")
275        .map_err(|_| FinamError::Config("FINAM_SECRET is not set".into()))?;
276    let mut cfg = FinamApiConfig::new(secret);
277    if let Ok(url) = std::env::var("FINAM_API_URL") {
278        // A secret must not go over plain text because of a stray variable. A local imitation is set in
279        // code with `with_endpoint`.
280        if !url.trim().starts_with("https://") {
281            return Err(FinamError::Config(
282                "FINAM_API_URL must start with https://".into(),
283            ));
284        }
285        cfg = cfg.with_endpoint(url.trim().to_string());
286    }
287    Ok((cfg, std::env::var("FINAM_ACCOUNT_ID").ok()))
288}