Skip to main content

trader_bot/config/
mod.rs

1//! Configuration management for the trading bot.
2//!
3//! This module provides configuration types for multi-user, multi-account,
4//! and multi-strategy setups.
5
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10
11use crate::domain::DesiredAllocation;
12use crate::strategy::BalancerConfig;
13
14/// Mode for determining desired allocation.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
16#[serde(rename_all = "snake_case")]
17pub enum AllocationMode {
18    /// Manual allocation with fixed percentages.
19    #[default]
20    Manual,
21    /// Allocation based on market capitalization.
22    MarketCap,
23    /// Allocation based on assets under management.
24    Aum,
25    /// Decorrelation strategy.
26    Decorrelation,
27}
28
29/// Behavior when market is closed.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum MarketClosureBehavior {
33    /// Skip the rebalancing iteration.
34    #[default]
35    Skip,
36    /// Force orders (may be queued or rejected).
37    Force,
38    /// Perform calculations but don't place orders.
39    DryRun,
40}
41
42/// Margin trading configuration.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct MarginConfig {
45    /// Whether margin trading is enabled.
46    #[serde(default)]
47    pub enabled: bool,
48    /// Portfolio multiplier (1.0 = no margin).
49    #[serde(default = "default_multiplier")]
50    pub multiplier: Decimal,
51    /// Maximum margin size in base currency.
52    #[serde(default)]
53    pub max_margin_size: Option<Decimal>,
54}
55
56fn default_multiplier() -> Decimal {
57    Decimal::ONE
58}
59
60impl Default for MarginConfig {
61    fn default() -> Self {
62        Self {
63            enabled: false,
64            multiplier: Decimal::ONE,
65            max_margin_size: None,
66        }
67    }
68}
69
70/// Configuration for a single trading account.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct AccountConfig {
73    /// Unique account identifier.
74    pub id: String,
75    /// Human-readable account name.
76    pub name: String,
77    /// Exchange/broker identifier.
78    pub exchange: String,
79    /// Exchange account ID (from broker).
80    pub exchange_account_id: String,
81    /// API token environment variable name (not the actual token).
82    #[serde(default = "default_token_env")]
83    pub token_env_var: String,
84    /// Desired wallet allocation.
85    #[serde(default)]
86    pub desired_allocation: HashMap<String, Decimal>,
87    /// Allocation mode.
88    #[serde(default)]
89    pub allocation_mode: AllocationMode,
90    /// Rebalancing interval in seconds.
91    #[serde(default = "default_balance_interval")]
92    pub balance_interval_secs: u64,
93    /// Delay between orders in milliseconds.
94    #[serde(default = "default_order_delay")]
95    pub order_delay_ms: u64,
96    /// Margin trading configuration.
97    #[serde(default)]
98    pub margin: MarginConfig,
99    /// Behavior when market is closed.
100    #[serde(default)]
101    pub market_closure_behavior: MarketClosureBehavior,
102    /// Minimum profit percentage for closing positions.
103    #[serde(default)]
104    pub min_profit_percent_for_close: Option<Decimal>,
105    /// Minimum trade percentage threshold.
106    #[serde(default = "default_min_trade_percent")]
107    pub min_trade_percent: Decimal,
108    /// Tolerance for considering allocation balanced.
109    #[serde(default = "default_tolerance")]
110    pub tolerance_percent: Decimal,
111}
112
113fn default_token_env() -> String {
114    "EXCHANGE_API_TOKEN".to_string()
115}
116
117fn default_balance_interval() -> u64 {
118    3600 // 1 hour
119}
120
121fn default_order_delay() -> u64 {
122    1000 // 1 second
123}
124
125fn default_min_trade_percent() -> Decimal {
126    Decimal::new(1, 1) // 0.1%
127}
128
129fn default_tolerance() -> Decimal {
130    Decimal::new(5, 1) // 0.5%
131}
132
133impl AccountConfig {
134    /// Converts the desired allocation to a DesiredAllocation instance.
135    #[must_use]
136    pub fn to_desired_allocation(&self) -> DesiredAllocation {
137        DesiredAllocation::from_map(self.desired_allocation.clone())
138    }
139
140    /// Converts to a BalancerConfig.
141    #[must_use]
142    pub fn to_balancer_config(&self) -> BalancerConfig {
143        BalancerConfig {
144            min_trade_percent: self.min_trade_percent,
145            min_trade_value: Decimal::new(100, 0), // 100 currency units
146            tolerance_percent: self.tolerance_percent,
147            order_delay_ms: self.order_delay_ms,
148            dry_run: matches!(self.market_closure_behavior, MarketClosureBehavior::DryRun),
149            max_orders_per_cycle: 20,
150            min_profit_percent_for_close: self.min_profit_percent_for_close,
151        }
152    }
153}
154
155/// Configuration for a single user.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct UserConfig {
158    /// Unique user identifier.
159    pub id: String,
160    /// Human-readable user name.
161    pub name: String,
162    /// Email address (optional).
163    #[serde(default)]
164    pub email: Option<String>,
165    /// User's accounts.
166    #[serde(default)]
167    pub accounts: Vec<AccountConfig>,
168    /// Whether the user is active.
169    #[serde(default = "default_true")]
170    pub active: bool,
171}
172
173fn default_true() -> bool {
174    true
175}
176
177impl UserConfig {
178    /// Creates a new user configuration.
179    #[must_use]
180    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
181        Self {
182            id: id.into(),
183            name: name.into(),
184            email: None,
185            accounts: Vec::new(),
186            active: true,
187        }
188    }
189
190    /// Adds an account to the user.
191    pub fn add_account(&mut self, account: AccountConfig) {
192        self.accounts.push(account);
193    }
194
195    /// Gets an account by ID.
196    #[must_use]
197    pub fn get_account(&self, id: &str) -> Option<&AccountConfig> {
198        self.accounts.iter().find(|a| a.id == id)
199    }
200
201    /// Returns all active accounts.
202    pub fn active_accounts(&self) -> impl Iterator<Item = &AccountConfig> {
203        self.accounts.iter()
204    }
205}
206
207/// Top-level application configuration.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct AppConfig {
210    /// Application version.
211    #[serde(default = "default_version")]
212    pub version: String,
213    /// Global settings.
214    #[serde(default)]
215    pub settings: GlobalSettings,
216    /// User configurations (for multi-user support).
217    #[serde(default)]
218    pub users: Vec<UserConfig>,
219    /// Account configurations (for single-user mode, backwards compatible).
220    #[serde(default)]
221    pub accounts: Vec<AccountConfig>,
222}
223
224fn default_version() -> String {
225    "1.0.0".to_string()
226}
227
228/// Global application settings.
229#[derive(Debug, Clone, Serialize, Deserialize, Default)]
230pub struct GlobalSettings {
231    /// Log level.
232    #[serde(default = "default_log_level")]
233    pub log_level: String,
234    /// Whether to enable verbose logging.
235    #[serde(default)]
236    pub verbose: bool,
237    /// Whether to run in dry mode globally.
238    #[serde(default)]
239    pub dry_run: bool,
240}
241
242fn default_log_level() -> String {
243    "info".to_string()
244}
245
246impl AppConfig {
247    /// Loads configuration from a JSON file.
248    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
249        let content = std::fs::read_to_string(path.as_ref())
250            .map_err(|e| ConfigError::IoError(e.to_string()))?;
251        Self::from_json(&content)
252    }
253
254    /// Parses configuration from a JSON string.
255    pub fn from_json(json: &str) -> Result<Self, ConfigError> {
256        serde_json::from_str(json).map_err(|e| ConfigError::ParseError(e.to_string()))
257    }
258
259    /// Serializes configuration to JSON.
260    pub fn to_json(&self) -> Result<String, ConfigError> {
261        serde_json::to_string_pretty(self).map_err(|e| ConfigError::SerializeError(e.to_string()))
262    }
263
264    /// Gets an account by ID (searches both direct accounts and user accounts).
265    #[must_use]
266    pub fn get_account(&self, id: &str) -> Option<&AccountConfig> {
267        // First check direct accounts
268        if let Some(account) = self.accounts.iter().find(|a| a.id == id) {
269            return Some(account);
270        }
271
272        // Then check user accounts
273        for user in &self.users {
274            if let Some(account) = user.get_account(id) {
275                return Some(account);
276            }
277        }
278
279        None
280    }
281
282    /// Gets a user by ID.
283    #[must_use]
284    pub fn get_user(&self, id: &str) -> Option<&UserConfig> {
285        self.users.iter().find(|u| u.id == id)
286    }
287
288    /// Returns all accounts from all users plus direct accounts.
289    pub fn all_accounts(&self) -> impl Iterator<Item = &AccountConfig> {
290        self.accounts
291            .iter()
292            .chain(self.users.iter().flat_map(|u| u.accounts.iter()))
293    }
294
295    /// Returns all active users.
296    pub fn active_users(&self) -> impl Iterator<Item = &UserConfig> {
297        self.users.iter().filter(|u| u.active)
298    }
299
300    /// Validates the configuration.
301    pub fn validate(&self) -> Result<(), ConfigError> {
302        // Check we have at least some accounts (either direct or via users)
303        let has_accounts =
304            !self.accounts.is_empty() || self.users.iter().any(|u| !u.accounts.is_empty());
305
306        if !has_accounts {
307            return Err(ConfigError::ValidationError(
308                "No accounts configured".to_string(),
309            ));
310        }
311
312        // Validate direct accounts
313        for account in &self.accounts {
314            Self::validate_account(account)?;
315        }
316
317        // Validate user accounts
318        for user in &self.users {
319            if user.id.is_empty() {
320                return Err(ConfigError::ValidationError(
321                    "User ID cannot be empty".to_string(),
322                ));
323            }
324            for account in &user.accounts {
325                Self::validate_account(account)?;
326            }
327        }
328
329        Ok(())
330    }
331
332    fn validate_account(account: &AccountConfig) -> Result<(), ConfigError> {
333        if account.id.is_empty() {
334            return Err(ConfigError::ValidationError(
335                "Account ID cannot be empty".to_string(),
336            ));
337        }
338        if account.exchange.is_empty() {
339            return Err(ConfigError::ValidationError(format!(
340                "Account {} has no exchange specified",
341                account.id
342            )));
343        }
344
345        // Validate allocation sums to ~100% for manual mode
346        if account.allocation_mode == AllocationMode::Manual
347            && !account.desired_allocation.is_empty()
348        {
349            let total: Decimal = account.desired_allocation.values().copied().sum();
350            let diff = (total - Decimal::from(100)).abs();
351            if diff > Decimal::from(1) {
352                return Err(ConfigError::ValidationError(format!(
353                    "Account {} allocation sums to {total}%, expected ~100%",
354                    account.id
355                )));
356            }
357        }
358
359        Ok(())
360    }
361}
362
363/// Configuration-related errors.
364#[derive(Debug, Clone)]
365pub enum ConfigError {
366    /// File I/O error.
367    IoError(String),
368    /// JSON parsing error.
369    ParseError(String),
370    /// JSON serialization error.
371    SerializeError(String),
372    /// Configuration validation error.
373    ValidationError(String),
374}
375
376impl std::fmt::Display for ConfigError {
377    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378        match self {
379            Self::IoError(e) => write!(f, "IO error: {e}"),
380            Self::ParseError(e) => write!(f, "Parse error: {e}"),
381            Self::SerializeError(e) => write!(f, "Serialize error: {e}"),
382            Self::ValidationError(e) => write!(f, "Validation error: {e}"),
383        }
384    }
385}
386
387impl std::error::Error for ConfigError {}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use rust_decimal_macros::dec;
393
394    #[test]
395    fn test_parse_config() {
396        let json = r#"{
397            "version": "1.0.0",
398            "settings": {
399                "log_level": "debug",
400                "verbose": true
401            },
402            "accounts": [
403                {
404                    "id": "main",
405                    "name": "Main Account",
406                    "exchange": "tbank",
407                    "exchange_account_id": "12345",
408                    "desired_allocation": {
409                        "SBER": 30,
410                        "LKOH": 30,
411                        "GAZP": 40
412                    },
413                    "allocation_mode": "manual",
414                    "balance_interval_secs": 3600
415                }
416            ]
417        }"#;
418
419        let config: AppConfig = serde_json::from_str(json).unwrap();
420        assert_eq!(config.accounts.len(), 1);
421        assert_eq!(config.accounts[0].id, "main");
422        assert_eq!(
423            config.accounts[0].desired_allocation.get("SBER"),
424            Some(&dec!(30))
425        );
426    }
427
428    #[test]
429    fn test_config_validation() {
430        let config = AppConfig {
431            version: "1.0.0".into(),
432            settings: GlobalSettings::default(),
433            users: vec![],
434            accounts: vec![],
435        };
436
437        let result = config.validate();
438        assert!(result.is_err());
439    }
440
441    #[test]
442    fn test_allocation_validation() {
443        let mut desired = HashMap::new();
444        desired.insert("A".to_string(), dec!(30));
445        desired.insert("B".to_string(), dec!(30));
446        // Only 60% allocated
447
448        let config = AppConfig {
449            version: "1.0.0".into(),
450            settings: GlobalSettings::default(),
451            users: vec![],
452            accounts: vec![AccountConfig {
453                id: "test".into(),
454                name: "Test".into(),
455                exchange: "sim".into(),
456                exchange_account_id: "123".into(),
457                token_env_var: "TOKEN".into(),
458                desired_allocation: desired,
459                allocation_mode: AllocationMode::Manual,
460                balance_interval_secs: 3600,
461                order_delay_ms: 1000,
462                margin: MarginConfig::default(),
463                market_closure_behavior: MarketClosureBehavior::Skip,
464                min_profit_percent_for_close: None,
465                min_trade_percent: dec!(0.1),
466                tolerance_percent: dec!(0.5),
467            }],
468        };
469
470        let result = config.validate();
471        assert!(result.is_err());
472    }
473
474    #[test]
475    fn test_to_balancer_config() {
476        let account = AccountConfig {
477            id: "test".into(),
478            name: "Test".into(),
479            exchange: "sim".into(),
480            exchange_account_id: "123".into(),
481            token_env_var: "TOKEN".into(),
482            desired_allocation: HashMap::new(),
483            allocation_mode: AllocationMode::Manual,
484            balance_interval_secs: 3600,
485            order_delay_ms: 500,
486            margin: MarginConfig::default(),
487            market_closure_behavior: MarketClosureBehavior::Skip,
488            min_profit_percent_for_close: Some(dec!(5)),
489            min_trade_percent: dec!(0.2),
490            tolerance_percent: dec!(1),
491        };
492
493        let balancer_config = account.to_balancer_config();
494        assert_eq!(balancer_config.order_delay_ms, 500);
495        assert_eq!(balancer_config.min_trade_percent, dec!(0.2));
496        assert_eq!(balancer_config.min_profit_percent_for_close, Some(dec!(5)));
497    }
498}