1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
16#[serde(rename_all = "snake_case")]
17pub enum AllocationMode {
18 #[default]
20 Manual,
21 MarketCap,
23 Aum,
25 Decorrelation,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum MarketClosureBehavior {
33 #[default]
35 Skip,
36 Force,
38 DryRun,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct MarginConfig {
45 #[serde(default)]
47 pub enabled: bool,
48 #[serde(default = "default_multiplier")]
50 pub multiplier: Decimal,
51 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct AccountConfig {
73 pub id: String,
75 pub name: String,
77 pub exchange: String,
79 pub exchange_account_id: String,
81 #[serde(default = "default_token_env")]
83 pub token_env_var: String,
84 #[serde(default)]
86 pub desired_allocation: HashMap<String, Decimal>,
87 #[serde(default)]
89 pub allocation_mode: AllocationMode,
90 #[serde(default = "default_balance_interval")]
92 pub balance_interval_secs: u64,
93 #[serde(default = "default_order_delay")]
95 pub order_delay_ms: u64,
96 #[serde(default)]
98 pub margin: MarginConfig,
99 #[serde(default)]
101 pub market_closure_behavior: MarketClosureBehavior,
102 #[serde(default)]
104 pub min_profit_percent_for_close: Option<Decimal>,
105 #[serde(default = "default_min_trade_percent")]
107 pub min_trade_percent: Decimal,
108 #[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 }
120
121fn default_order_delay() -> u64 {
122 1000 }
124
125fn default_min_trade_percent() -> Decimal {
126 Decimal::new(1, 1) }
128
129fn default_tolerance() -> Decimal {
130 Decimal::new(5, 1) }
132
133impl AccountConfig {
134 #[must_use]
136 pub fn to_desired_allocation(&self) -> DesiredAllocation {
137 DesiredAllocation::from_map(self.desired_allocation.clone())
138 }
139
140 #[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), 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#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct UserConfig {
158 pub id: String,
160 pub name: String,
162 #[serde(default)]
164 pub email: Option<String>,
165 #[serde(default)]
167 pub accounts: Vec<AccountConfig>,
168 #[serde(default = "default_true")]
170 pub active: bool,
171}
172
173fn default_true() -> bool {
174 true
175}
176
177impl UserConfig {
178 #[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 pub fn add_account(&mut self, account: AccountConfig) {
192 self.accounts.push(account);
193 }
194
195 #[must_use]
197 pub fn get_account(&self, id: &str) -> Option<&AccountConfig> {
198 self.accounts.iter().find(|a| a.id == id)
199 }
200
201 pub fn active_accounts(&self) -> impl Iterator<Item = &AccountConfig> {
203 self.accounts.iter()
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct AppConfig {
210 #[serde(default = "default_version")]
212 pub version: String,
213 #[serde(default)]
215 pub settings: GlobalSettings,
216 #[serde(default)]
218 pub users: Vec<UserConfig>,
219 #[serde(default)]
221 pub accounts: Vec<AccountConfig>,
222}
223
224fn default_version() -> String {
225 "1.0.0".to_string()
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize, Default)]
230pub struct GlobalSettings {
231 #[serde(default = "default_log_level")]
233 pub log_level: String,
234 #[serde(default)]
236 pub verbose: bool,
237 #[serde(default)]
239 pub dry_run: bool,
240}
241
242fn default_log_level() -> String {
243 "info".to_string()
244}
245
246impl AppConfig {
247 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 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 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 #[must_use]
266 pub fn get_account(&self, id: &str) -> Option<&AccountConfig> {
267 if let Some(account) = self.accounts.iter().find(|a| a.id == id) {
269 return Some(account);
270 }
271
272 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 #[must_use]
284 pub fn get_user(&self, id: &str) -> Option<&UserConfig> {
285 self.users.iter().find(|u| u.id == id)
286 }
287
288 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 pub fn active_users(&self) -> impl Iterator<Item = &UserConfig> {
297 self.users.iter().filter(|u| u.active)
298 }
299
300 pub fn validate(&self) -> Result<(), ConfigError> {
302 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 for account in &self.accounts {
314 Self::validate_account(account)?;
315 }
316
317 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 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#[derive(Debug, Clone)]
365pub enum ConfigError {
366 IoError(String),
368 ParseError(String),
370 SerializeError(String),
372 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 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}