Skip to main content

trader_bot/domain/
user.rs

1//! User and account domain models.
2//!
3//! This module provides the core domain models for multi-user, multi-account
4//! trading configurations following the Links Notation structure.
5//!
6//! # Terminology
7//!
8//! - **User**: A connection to an exchange/broker (has API credentials)
9//! - **Account**: A trading account within that broker (can have multiple per user)
10//! - **Allocation**: The target portfolio allocation for an account
11//!
12//! # Links Notation Configuration
13//!
14//! ```text
15//! users
16//!   (
17//!     broker tbank
18//!     token TBANK_API_TOKEN
19//!     accounts
20//!       main
21//!         allocation
22//!           SBER 30
23//!           LKOH 30
24//!           GAZP 40
25//!       trading
26//!         allocation
27//!           AAPL 50
28//!           GOOGL 50
29//!   )
30//!   (
31//!     exchange binance
32//!     "api key" BINANCE_API_KEY
33//!     accounts
34//!       spot_main
35//!         allocation
36//!           BTC 50
37//!           ETH 50
38//!   )
39//! ```
40
41use super::Allocation;
42use rust_decimal::Decimal;
43use serde::{Deserialize, Serialize};
44use std::collections::HashMap;
45
46/// The type of broker/exchange connection.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
48#[serde(rename_all = "snake_case")]
49pub enum BrokerType {
50    /// T-Bank (formerly Tinkoff Investments).
51    #[default]
52    TBank,
53    /// Binance cryptocurrency exchange.
54    Binance,
55    /// Interactive Brokers.
56    InteractiveBrokers,
57    /// Simulated exchange (for testing).
58    Simulator,
59    /// Custom broker with a name.
60    Custom(String),
61}
62
63impl BrokerType {
64    /// Creates a broker type from a string name.
65    #[must_use]
66    pub fn from_name(name: &str) -> Self {
67        match name.to_lowercase().as_str() {
68            "tbank" | "t-bank" | "tinkoff" => Self::TBank,
69            "binance" => Self::Binance,
70            "ib" | "ibkr" | "interactive_brokers" | "interactivebrokers" => {
71                Self::InteractiveBrokers
72            }
73            "sim" | "simulator" | "simulated" | "demo" => Self::Simulator,
74            _ => Self::Custom(name.to_string()),
75        }
76    }
77
78    /// Returns the broker name as a string.
79    #[must_use]
80    pub fn as_str(&self) -> &str {
81        match self {
82            Self::TBank => "tbank",
83            Self::Binance => "binance",
84            Self::InteractiveBrokers => "interactive_brokers",
85            Self::Simulator => "simulator",
86            Self::Custom(name) => name,
87        }
88    }
89}
90
91/// Configuration for a trading account.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Account {
94    /// Unique account identifier within the user.
95    pub id: String,
96
97    /// Human-readable account name.
98    #[serde(default)]
99    pub name: String,
100
101    /// Exchange account ID (from broker's system).
102    #[serde(default)]
103    pub exchange_account_id: String,
104
105    /// Target portfolio allocation for this account.
106    pub allocation: Allocation,
107
108    /// Rebalancing interval in seconds.
109    #[serde(default = "default_balance_interval")]
110    pub balance_interval_secs: u64,
111
112    /// Delay between orders in milliseconds.
113    #[serde(default = "default_order_delay")]
114    pub order_delay_ms: u64,
115
116    /// Minimum trade percentage threshold.
117    #[serde(default = "default_min_trade_percent")]
118    pub min_trade_percent: Decimal,
119
120    /// Tolerance for considering allocation balanced.
121    #[serde(default = "default_tolerance")]
122    pub tolerance_percent: Decimal,
123}
124
125fn default_balance_interval() -> u64 {
126    3600 // 1 hour
127}
128
129fn default_order_delay() -> u64 {
130    1000 // 1 second
131}
132
133fn default_min_trade_percent() -> Decimal {
134    Decimal::new(1, 1) // 0.1%
135}
136
137fn default_tolerance() -> Decimal {
138    Decimal::new(5, 1) // 0.5%
139}
140
141impl Account {
142    /// Creates a new account with the given ID and allocation.
143    #[must_use]
144    pub fn new(id: impl Into<String>, allocation: Allocation) -> Self {
145        let id = id.into();
146        Self {
147            name: id.clone(),
148            id,
149            exchange_account_id: String::new(),
150            allocation,
151            balance_interval_secs: default_balance_interval(),
152            order_delay_ms: default_order_delay(),
153            min_trade_percent: default_min_trade_percent(),
154            tolerance_percent: default_tolerance(),
155        }
156    }
157
158    /// Sets the account name.
159    #[must_use]
160    pub fn with_name(mut self, name: impl Into<String>) -> Self {
161        self.name = name.into();
162        self
163    }
164
165    /// Sets the exchange account ID.
166    #[must_use]
167    pub fn with_exchange_id(mut self, id: impl Into<String>) -> Self {
168        self.exchange_account_id = id.into();
169        self
170    }
171
172    /// Sets the balance interval.
173    #[must_use]
174    pub fn with_balance_interval(mut self, secs: u64) -> Self {
175        self.balance_interval_secs = secs;
176        self
177    }
178}
179
180/// A user representing a connection to an exchange/broker.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct User {
183    /// Unique user identifier (auto-generated if not provided).
184    #[serde(default)]
185    pub id: String,
186
187    /// Human-readable user name.
188    #[serde(default)]
189    pub name: String,
190
191    /// The broker/exchange type.
192    pub broker: BrokerType,
193
194    /// Environment variable name for the API token.
195    pub token_env_var: String,
196
197    /// Additional API credentials (e.g., API secret for Binance).
198    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
199    pub credentials: HashMap<String, String>,
200
201    /// Trading accounts for this user.
202    #[serde(default)]
203    pub accounts: Vec<Account>,
204
205    /// Whether this user is enabled.
206    #[serde(default = "default_true")]
207    pub enabled: bool,
208}
209
210fn default_true() -> bool {
211    true
212}
213
214impl User {
215    /// Creates a new user for a broker.
216    #[must_use]
217    pub fn new(broker: BrokerType, token_env_var: impl Into<String>) -> Self {
218        Self {
219            id: uuid::Uuid::new_v4().to_string(),
220            name: String::new(),
221            broker,
222            token_env_var: token_env_var.into(),
223            credentials: HashMap::new(),
224            accounts: Vec::new(),
225            enabled: true,
226        }
227    }
228
229    /// Sets the user ID.
230    #[must_use]
231    pub fn with_id(mut self, id: impl Into<String>) -> Self {
232        self.id = id.into();
233        self
234    }
235
236    /// Sets the user name.
237    #[must_use]
238    pub fn with_name(mut self, name: impl Into<String>) -> Self {
239        self.name = name.into();
240        self
241    }
242
243    /// Adds a credential (e.g., API secret).
244    #[must_use]
245    pub fn with_credential(mut self, key: impl Into<String>, env_var: impl Into<String>) -> Self {
246        self.credentials.insert(key.into(), env_var.into());
247        self
248    }
249
250    /// Adds an account to this user.
251    pub fn add_account(&mut self, account: Account) {
252        self.accounts.push(account);
253    }
254
255    /// Returns an account by ID.
256    #[must_use]
257    pub fn get_account(&self, id: &str) -> Option<&Account> {
258        self.accounts.iter().find(|a| a.id == id)
259    }
260
261    /// Returns a mutable account by ID.
262    pub fn get_account_mut(&mut self, id: &str) -> Option<&mut Account> {
263        self.accounts.iter_mut().find(|a| a.id == id)
264    }
265
266    /// Returns all enabled accounts.
267    pub fn active_accounts(&self) -> impl Iterator<Item = &Account> {
268        self.accounts.iter()
269    }
270
271    /// Returns all tickers across all accounts.
272    #[must_use]
273    pub fn all_tickers(&self) -> Vec<String> {
274        let mut tickers = Vec::new();
275        for account in &self.accounts {
276            for ticker in account.allocation.all_tickers() {
277                if !tickers.contains(&ticker) {
278                    tickers.push(ticker);
279                }
280            }
281        }
282        tickers
283    }
284}
285
286/// Global settings for the trading bot.
287#[derive(Debug, Clone, Serialize, Deserialize, Default)]
288pub struct Settings {
289    /// Log level (trace, debug, info, warn, error).
290    #[serde(default = "default_log_level")]
291    pub log_level: String,
292
293    /// Enable verbose output.
294    #[serde(default)]
295    pub verbose: bool,
296
297    /// Run in dry-run mode (no actual trades).
298    #[serde(default)]
299    pub dry_run: bool,
300
301    /// Run in plan mode (show what would happen).
302    #[serde(default)]
303    pub plan: bool,
304}
305
306fn default_log_level() -> String {
307    "info".to_string()
308}
309
310/// Top-level configuration containing users and global settings.
311#[derive(Debug, Clone, Serialize, Deserialize, Default)]
312pub struct TradingConfig {
313    /// Global settings.
314    #[serde(default)]
315    pub settings: Settings,
316
317    /// User configurations.
318    #[serde(default)]
319    pub users: Vec<User>,
320}
321
322impl TradingConfig {
323    /// Creates a new empty configuration.
324    #[must_use]
325    pub fn new() -> Self {
326        Self::default()
327    }
328
329    /// Adds a user to the configuration.
330    pub fn add_user(&mut self, user: User) {
331        self.users.push(user);
332    }
333
334    /// Returns a user by ID.
335    #[must_use]
336    pub fn get_user(&self, id: &str) -> Option<&User> {
337        self.users.iter().find(|u| u.id == id)
338    }
339
340    /// Returns all enabled users.
341    pub fn active_users(&self) -> impl Iterator<Item = &User> {
342        self.users.iter().filter(|u| u.enabled)
343    }
344
345    /// Returns all accounts across all users.
346    pub fn all_accounts(&self) -> impl Iterator<Item = (&User, &Account)> {
347        self.users
348            .iter()
349            .flat_map(|u| u.accounts.iter().map(move |a| (u, a)))
350    }
351
352    /// Validates the configuration.
353    pub fn validate(&self) -> Result<(), ConfigValidationError> {
354        if self.users.is_empty() {
355            return Err(ConfigValidationError::NoUsers);
356        }
357
358        for user in &self.users {
359            if user.token_env_var.is_empty() {
360                return Err(ConfigValidationError::MissingToken(user.id.clone()));
361            }
362
363            if user.accounts.is_empty() {
364                return Err(ConfigValidationError::NoAccounts(user.id.clone()));
365            }
366
367            for account in &user.accounts {
368                if !account.allocation.is_valid() {
369                    return Err(ConfigValidationError::InvalidAllocation(
370                        user.id.clone(),
371                        account.id.clone(),
372                    ));
373                }
374            }
375        }
376
377        Ok(())
378    }
379}
380
381/// Configuration validation errors.
382#[derive(Debug, Clone)]
383pub enum ConfigValidationError {
384    /// No users configured.
385    NoUsers,
386    /// Missing API token for a user.
387    MissingToken(String),
388    /// No accounts configured for a user.
389    NoAccounts(String),
390    /// Invalid allocation for an account.
391    InvalidAllocation(String, String),
392}
393
394impl std::fmt::Display for ConfigValidationError {
395    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396        match self {
397            Self::NoUsers => write!(f, "No users configured"),
398            Self::MissingToken(user) => write!(f, "Missing API token for user '{user}'"),
399            Self::NoAccounts(user) => write!(f, "No accounts configured for user '{user}'"),
400            Self::InvalidAllocation(user, account) => {
401                write!(
402                    f,
403                    "Invalid allocation for account '{account}' of user '{user}'"
404                )
405            }
406        }
407    }
408}
409
410impl std::error::Error for ConfigValidationError {}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use rust_decimal_macros::dec;
416
417    #[test]
418    fn test_broker_type_from_name() {
419        assert_eq!(BrokerType::from_name("tbank"), BrokerType::TBank);
420        assert_eq!(BrokerType::from_name("BINANCE"), BrokerType::Binance);
421        assert_eq!(BrokerType::from_name("ib"), BrokerType::InteractiveBrokers);
422        assert_eq!(BrokerType::from_name("simulator"), BrokerType::Simulator);
423        assert_eq!(
424            BrokerType::from_name("custom"),
425            BrokerType::Custom("custom".to_string())
426        );
427    }
428
429    #[test]
430    fn test_create_user() {
431        let user = User::new(BrokerType::TBank, "TBANK_TOKEN")
432            .with_name("Test User")
433            .with_credential("secret", "TBANK_SECRET");
434
435        assert_eq!(user.broker, BrokerType::TBank);
436        assert_eq!(user.token_env_var, "TBANK_TOKEN");
437        assert_eq!(user.name, "Test User");
438        assert_eq!(
439            user.credentials.get("secret"),
440            Some(&"TBANK_SECRET".to_string())
441        );
442    }
443
444    #[test]
445    fn test_create_account() {
446        let alloc = Allocation::from_simple(&[
447            ("SBER".to_string(), dec!(50)),
448            ("LKOH".to_string(), dec!(50)),
449        ]);
450
451        let account = Account::new("main", alloc)
452            .with_name("Main Account")
453            .with_exchange_id("12345");
454
455        assert_eq!(account.id, "main");
456        assert_eq!(account.name, "Main Account");
457        assert_eq!(account.exchange_account_id, "12345");
458    }
459
460    #[test]
461    fn test_config_validation() {
462        let mut config = TradingConfig::new();
463
464        // Empty config should fail
465        assert!(config.validate().is_err());
466
467        // Add user without accounts
468        let user = User::new(BrokerType::TBank, "TOKEN");
469        config.add_user(user);
470        assert!(config.validate().is_err());
471
472        // Add account
473        let alloc =
474            Allocation::from_simple(&[("A".to_string(), dec!(50)), ("B".to_string(), dec!(50))]);
475        config.users[0].add_account(Account::new("main", alloc));
476
477        // Now it should pass
478        assert!(config.validate().is_ok());
479    }
480
481    #[test]
482    fn test_all_tickers() {
483        let mut user = User::new(BrokerType::TBank, "TOKEN");
484
485        let alloc1 = Allocation::from_simple(&[
486            ("SBER".to_string(), dec!(50)),
487            ("LKOH".to_string(), dec!(50)),
488        ]);
489        user.add_account(Account::new("acc1", alloc1));
490
491        let alloc2 = Allocation::from_simple(&[("GAZP".to_string(), dec!(100))]);
492        user.add_account(Account::new("acc2", alloc2));
493
494        let tickers = user.all_tickers();
495        assert!(tickers.contains(&"SBER".to_string()));
496        assert!(tickers.contains(&"LKOH".to_string()));
497        assert!(tickers.contains(&"GAZP".to_string()));
498    }
499}