use super::Allocation;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum BrokerType {
#[default]
TBank,
Binance,
InteractiveBrokers,
Simulator,
Custom(String),
}
impl BrokerType {
#[must_use]
pub fn from_name(name: &str) -> Self {
match name.to_lowercase().as_str() {
"tbank" | "t-bank" | "tinkoff" => Self::TBank,
"binance" => Self::Binance,
"ib" | "ibkr" | "interactive_brokers" | "interactivebrokers" => {
Self::InteractiveBrokers
}
"sim" | "simulator" | "simulated" | "demo" => Self::Simulator,
_ => Self::Custom(name.to_string()),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::TBank => "tbank",
Self::Binance => "binance",
Self::InteractiveBrokers => "interactive_brokers",
Self::Simulator => "simulator",
Self::Custom(name) => name,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub exchange_account_id: String,
pub allocation: Allocation,
#[serde(default = "default_balance_interval")]
pub balance_interval_secs: u64,
#[serde(default = "default_order_delay")]
pub order_delay_ms: u64,
#[serde(default = "default_min_trade_percent")]
pub min_trade_percent: Decimal,
#[serde(default = "default_tolerance")]
pub tolerance_percent: Decimal,
}
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 Account {
#[must_use]
pub fn new(id: impl Into<String>, allocation: Allocation) -> Self {
let id = id.into();
Self {
name: id.clone(),
id,
exchange_account_id: String::new(),
allocation,
balance_interval_secs: default_balance_interval(),
order_delay_ms: default_order_delay(),
min_trade_percent: default_min_trade_percent(),
tolerance_percent: default_tolerance(),
}
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
#[must_use]
pub fn with_exchange_id(mut self, id: impl Into<String>) -> Self {
self.exchange_account_id = id.into();
self
}
#[must_use]
pub fn with_balance_interval(mut self, secs: u64) -> Self {
self.balance_interval_secs = secs;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
#[serde(default)]
pub id: String,
#[serde(default)]
pub name: String,
pub broker: BrokerType,
pub token_env_var: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub credentials: HashMap<String, String>,
#[serde(default)]
pub accounts: Vec<Account>,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool {
true
}
impl User {
#[must_use]
pub fn new(broker: BrokerType, token_env_var: impl Into<String>) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
name: String::new(),
broker,
token_env_var: token_env_var.into(),
credentials: HashMap::new(),
accounts: Vec::new(),
enabled: true,
}
}
#[must_use]
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = id.into();
self
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
#[must_use]
pub fn with_credential(mut self, key: impl Into<String>, env_var: impl Into<String>) -> Self {
self.credentials.insert(key.into(), env_var.into());
self
}
pub fn add_account(&mut self, account: Account) {
self.accounts.push(account);
}
#[must_use]
pub fn get_account(&self, id: &str) -> Option<&Account> {
self.accounts.iter().find(|a| a.id == id)
}
pub fn get_account_mut(&mut self, id: &str) -> Option<&mut Account> {
self.accounts.iter_mut().find(|a| a.id == id)
}
pub fn active_accounts(&self) -> impl Iterator<Item = &Account> {
self.accounts.iter()
}
#[must_use]
pub fn all_tickers(&self) -> Vec<String> {
let mut tickers = Vec::new();
for account in &self.accounts {
for ticker in account.allocation.all_tickers() {
if !tickers.contains(&ticker) {
tickers.push(ticker);
}
}
}
tickers
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Settings {
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default)]
pub verbose: bool,
#[serde(default)]
pub dry_run: bool,
#[serde(default)]
pub plan: bool,
}
fn default_log_level() -> String {
"info".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TradingConfig {
#[serde(default)]
pub settings: Settings,
#[serde(default)]
pub users: Vec<User>,
}
impl TradingConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_user(&mut self, user: User) {
self.users.push(user);
}
#[must_use]
pub fn get_user(&self, id: &str) -> Option<&User> {
self.users.iter().find(|u| u.id == id)
}
pub fn active_users(&self) -> impl Iterator<Item = &User> {
self.users.iter().filter(|u| u.enabled)
}
pub fn all_accounts(&self) -> impl Iterator<Item = (&User, &Account)> {
self.users
.iter()
.flat_map(|u| u.accounts.iter().map(move |a| (u, a)))
}
pub fn validate(&self) -> Result<(), ConfigValidationError> {
if self.users.is_empty() {
return Err(ConfigValidationError::NoUsers);
}
for user in &self.users {
if user.token_env_var.is_empty() {
return Err(ConfigValidationError::MissingToken(user.id.clone()));
}
if user.accounts.is_empty() {
return Err(ConfigValidationError::NoAccounts(user.id.clone()));
}
for account in &user.accounts {
if !account.allocation.is_valid() {
return Err(ConfigValidationError::InvalidAllocation(
user.id.clone(),
account.id.clone(),
));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub enum ConfigValidationError {
NoUsers,
MissingToken(String),
NoAccounts(String),
InvalidAllocation(String, String),
}
impl std::fmt::Display for ConfigValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoUsers => write!(f, "No users configured"),
Self::MissingToken(user) => write!(f, "Missing API token for user '{user}'"),
Self::NoAccounts(user) => write!(f, "No accounts configured for user '{user}'"),
Self::InvalidAllocation(user, account) => {
write!(
f,
"Invalid allocation for account '{account}' of user '{user}'"
)
}
}
}
}
impl std::error::Error for ConfigValidationError {}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_broker_type_from_name() {
assert_eq!(BrokerType::from_name("tbank"), BrokerType::TBank);
assert_eq!(BrokerType::from_name("BINANCE"), BrokerType::Binance);
assert_eq!(BrokerType::from_name("ib"), BrokerType::InteractiveBrokers);
assert_eq!(BrokerType::from_name("simulator"), BrokerType::Simulator);
assert_eq!(
BrokerType::from_name("custom"),
BrokerType::Custom("custom".to_string())
);
}
#[test]
fn test_create_user() {
let user = User::new(BrokerType::TBank, "TBANK_TOKEN")
.with_name("Test User")
.with_credential("secret", "TBANK_SECRET");
assert_eq!(user.broker, BrokerType::TBank);
assert_eq!(user.token_env_var, "TBANK_TOKEN");
assert_eq!(user.name, "Test User");
assert_eq!(
user.credentials.get("secret"),
Some(&"TBANK_SECRET".to_string())
);
}
#[test]
fn test_create_account() {
let alloc = Allocation::from_simple(&[
("SBER".to_string(), dec!(50)),
("LKOH".to_string(), dec!(50)),
]);
let account = Account::new("main", alloc)
.with_name("Main Account")
.with_exchange_id("12345");
assert_eq!(account.id, "main");
assert_eq!(account.name, "Main Account");
assert_eq!(account.exchange_account_id, "12345");
}
#[test]
fn test_config_validation() {
let mut config = TradingConfig::new();
assert!(config.validate().is_err());
let user = User::new(BrokerType::TBank, "TOKEN");
config.add_user(user);
assert!(config.validate().is_err());
let alloc =
Allocation::from_simple(&[("A".to_string(), dec!(50)), ("B".to_string(), dec!(50))]);
config.users[0].add_account(Account::new("main", alloc));
assert!(config.validate().is_ok());
}
#[test]
fn test_all_tickers() {
let mut user = User::new(BrokerType::TBank, "TOKEN");
let alloc1 = Allocation::from_simple(&[
("SBER".to_string(), dec!(50)),
("LKOH".to_string(), dec!(50)),
]);
user.add_account(Account::new("acc1", alloc1));
let alloc2 = Allocation::from_simple(&[("GAZP".to_string(), dec!(100))]);
user.add_account(Account::new("acc2", alloc2));
let tickers = user.all_tickers();
assert!(tickers.contains(&"SBER".to_string()));
assert!(tickers.contains(&"LKOH".to_string()));
assert!(tickers.contains(&"GAZP".to_string()));
}
}