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}