use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use crate::domain::DesiredAllocation;
use crate::strategy::BalancerConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AllocationMode {
#[default]
Manual,
MarketCap,
Aum,
Decorrelation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MarketClosureBehavior {
#[default]
Skip,
Force,
DryRun,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarginConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_multiplier")]
pub multiplier: Decimal,
#[serde(default)]
pub max_margin_size: Option<Decimal>,
}
fn default_multiplier() -> Decimal {
Decimal::ONE
}
impl Default for MarginConfig {
fn default() -> Self {
Self {
enabled: false,
multiplier: Decimal::ONE,
max_margin_size: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountConfig {
pub id: String,
pub name: String,
pub exchange: String,
pub exchange_account_id: String,
#[serde(default = "default_token_env")]
pub token_env_var: String,
#[serde(default)]
pub desired_allocation: HashMap<String, Decimal>,
#[serde(default)]
pub allocation_mode: AllocationMode,
#[serde(default = "default_balance_interval")]
pub balance_interval_secs: u64,
#[serde(default = "default_order_delay")]
pub order_delay_ms: u64,
#[serde(default)]
pub margin: MarginConfig,
#[serde(default)]
pub market_closure_behavior: MarketClosureBehavior,
#[serde(default)]
pub min_profit_percent_for_close: Option<Decimal>,
#[serde(default = "default_min_trade_percent")]
pub min_trade_percent: Decimal,
#[serde(default = "default_tolerance")]
pub tolerance_percent: Decimal,
}
fn default_token_env() -> String {
"EXCHANGE_API_TOKEN".to_string()
}
fn default_balance_interval() -> u64 {
3600 }
fn default_order_delay() -> u64 {
1000 }
fn default_min_trade_percent() -> Decimal {
Decimal::new(1, 1) }
fn default_tolerance() -> Decimal {
Decimal::new(5, 1) }
impl AccountConfig {
#[must_use]
pub fn to_desired_allocation(&self) -> DesiredAllocation {
DesiredAllocation::from_map(self.desired_allocation.clone())
}
#[must_use]
pub fn to_balancer_config(&self) -> BalancerConfig {
BalancerConfig {
min_trade_percent: self.min_trade_percent,
min_trade_value: Decimal::new(100, 0), tolerance_percent: self.tolerance_percent,
order_delay_ms: self.order_delay_ms,
dry_run: matches!(self.market_closure_behavior, MarketClosureBehavior::DryRun),
max_orders_per_cycle: 20,
min_profit_percent_for_close: self.min_profit_percent_for_close,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserConfig {
pub id: String,
pub name: String,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub accounts: Vec<AccountConfig>,
#[serde(default = "default_true")]
pub active: bool,
}
fn default_true() -> bool {
true
}
impl UserConfig {
#[must_use]
pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
email: None,
accounts: Vec::new(),
active: true,
}
}
pub fn add_account(&mut self, account: AccountConfig) {
self.accounts.push(account);
}
#[must_use]
pub fn get_account(&self, id: &str) -> Option<&AccountConfig> {
self.accounts.iter().find(|a| a.id == id)
}
pub fn active_accounts(&self) -> impl Iterator<Item = &AccountConfig> {
self.accounts.iter()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub settings: GlobalSettings,
#[serde(default)]
pub users: Vec<UserConfig>,
#[serde(default)]
pub accounts: Vec<AccountConfig>,
}
fn default_version() -> String {
"1.0.0".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalSettings {
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default)]
pub verbose: bool,
#[serde(default)]
pub dry_run: bool,
}
fn default_log_level() -> String {
"info".to_string()
}
impl AppConfig {
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path.as_ref())
.map_err(|e| ConfigError::IoError(e.to_string()))?;
Self::from_json(&content)
}
pub fn from_json(json: &str) -> Result<Self, ConfigError> {
serde_json::from_str(json).map_err(|e| ConfigError::ParseError(e.to_string()))
}
pub fn to_json(&self) -> Result<String, ConfigError> {
serde_json::to_string_pretty(self).map_err(|e| ConfigError::SerializeError(e.to_string()))
}
#[must_use]
pub fn get_account(&self, id: &str) -> Option<&AccountConfig> {
if let Some(account) = self.accounts.iter().find(|a| a.id == id) {
return Some(account);
}
for user in &self.users {
if let Some(account) = user.get_account(id) {
return Some(account);
}
}
None
}
#[must_use]
pub fn get_user(&self, id: &str) -> Option<&UserConfig> {
self.users.iter().find(|u| u.id == id)
}
pub fn all_accounts(&self) -> impl Iterator<Item = &AccountConfig> {
self.accounts
.iter()
.chain(self.users.iter().flat_map(|u| u.accounts.iter()))
}
pub fn active_users(&self) -> impl Iterator<Item = &UserConfig> {
self.users.iter().filter(|u| u.active)
}
pub fn validate(&self) -> Result<(), ConfigError> {
let has_accounts =
!self.accounts.is_empty() || self.users.iter().any(|u| !u.accounts.is_empty());
if !has_accounts {
return Err(ConfigError::ValidationError(
"No accounts configured".to_string(),
));
}
for account in &self.accounts {
Self::validate_account(account)?;
}
for user in &self.users {
if user.id.is_empty() {
return Err(ConfigError::ValidationError(
"User ID cannot be empty".to_string(),
));
}
for account in &user.accounts {
Self::validate_account(account)?;
}
}
Ok(())
}
fn validate_account(account: &AccountConfig) -> Result<(), ConfigError> {
if account.id.is_empty() {
return Err(ConfigError::ValidationError(
"Account ID cannot be empty".to_string(),
));
}
if account.exchange.is_empty() {
return Err(ConfigError::ValidationError(format!(
"Account {} has no exchange specified",
account.id
)));
}
if account.allocation_mode == AllocationMode::Manual
&& !account.desired_allocation.is_empty()
{
let total: Decimal = account.desired_allocation.values().copied().sum();
let diff = (total - Decimal::from(100)).abs();
if diff > Decimal::from(1) {
return Err(ConfigError::ValidationError(format!(
"Account {} allocation sums to {total}%, expected ~100%",
account.id
)));
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub enum ConfigError {
IoError(String),
ParseError(String),
SerializeError(String),
ValidationError(String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::IoError(e) => write!(f, "IO error: {e}"),
Self::ParseError(e) => write!(f, "Parse error: {e}"),
Self::SerializeError(e) => write!(f, "Serialize error: {e}"),
Self::ValidationError(e) => write!(f, "Validation error: {e}"),
}
}
}
impl std::error::Error for ConfigError {}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_parse_config() {
let json = r#"{
"version": "1.0.0",
"settings": {
"log_level": "debug",
"verbose": true
},
"accounts": [
{
"id": "main",
"name": "Main Account",
"exchange": "tbank",
"exchange_account_id": "12345",
"desired_allocation": {
"SBER": 30,
"LKOH": 30,
"GAZP": 40
},
"allocation_mode": "manual",
"balance_interval_secs": 3600
}
]
}"#;
let config: AppConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.accounts.len(), 1);
assert_eq!(config.accounts[0].id, "main");
assert_eq!(
config.accounts[0].desired_allocation.get("SBER"),
Some(&dec!(30))
);
}
#[test]
fn test_config_validation() {
let config = AppConfig {
version: "1.0.0".into(),
settings: GlobalSettings::default(),
users: vec![],
accounts: vec![],
};
let result = config.validate();
assert!(result.is_err());
}
#[test]
fn test_allocation_validation() {
let mut desired = HashMap::new();
desired.insert("A".to_string(), dec!(30));
desired.insert("B".to_string(), dec!(30));
let config = AppConfig {
version: "1.0.0".into(),
settings: GlobalSettings::default(),
users: vec![],
accounts: vec![AccountConfig {
id: "test".into(),
name: "Test".into(),
exchange: "sim".into(),
exchange_account_id: "123".into(),
token_env_var: "TOKEN".into(),
desired_allocation: desired,
allocation_mode: AllocationMode::Manual,
balance_interval_secs: 3600,
order_delay_ms: 1000,
margin: MarginConfig::default(),
market_closure_behavior: MarketClosureBehavior::Skip,
min_profit_percent_for_close: None,
min_trade_percent: dec!(0.1),
tolerance_percent: dec!(0.5),
}],
};
let result = config.validate();
assert!(result.is_err());
}
#[test]
fn test_to_balancer_config() {
let account = AccountConfig {
id: "test".into(),
name: "Test".into(),
exchange: "sim".into(),
exchange_account_id: "123".into(),
token_env_var: "TOKEN".into(),
desired_allocation: HashMap::new(),
allocation_mode: AllocationMode::Manual,
balance_interval_secs: 3600,
order_delay_ms: 500,
margin: MarginConfig::default(),
market_closure_behavior: MarketClosureBehavior::Skip,
min_profit_percent_for_close: Some(dec!(5)),
min_trade_percent: dec!(0.2),
tolerance_percent: dec!(1),
};
let balancer_config = account.to_balancer_config();
assert_eq!(balancer_config.order_delay_ms, 500);
assert_eq!(balancer_config.min_trade_percent, dec!(0.2));
assert_eq!(balancer_config.min_profit_percent_for_close, Some(dec!(5)));
}
}