1use anyhow::{bail, Context, Result};
2use serde::Deserialize;
3use std::path::Path;
4
5#[derive(Debug, Clone, Deserialize)]
6pub struct Config {
7 pub binance: BinanceConfig,
8 pub strategy: StrategyConfig,
9 #[serde(default)]
10 pub risk: RiskConfig,
11 pub ui: UiConfig,
12 pub logging: LoggingConfig,
13}
14
15#[derive(Debug, Clone, Deserialize)]
16pub struct BinanceConfig {
17 pub rest_base_url: String,
18 pub ws_base_url: String,
19 #[serde(default = "default_futures_rest_base_url")]
20 pub futures_rest_base_url: String,
21 #[serde(default = "default_futures_ws_base_url")]
22 pub futures_ws_base_url: String,
23 pub symbol: String,
24 #[serde(default)]
25 pub symbols: Vec<String>,
26 #[serde(default)]
27 pub futures_symbols: Vec<String>,
28 pub recv_window: u64,
29 pub kline_interval: String,
30 #[serde(skip)]
31 pub api_key: String,
32 #[serde(skip)]
33 pub api_secret: String,
34 #[serde(skip)]
35 pub futures_api_key: String,
36 #[serde(skip)]
37 pub futures_api_secret: String,
38}
39
40#[derive(Debug, Clone, Deserialize)]
41pub struct StrategyConfig {
42 pub fast_period: usize,
43 pub slow_period: usize,
44 pub order_amount_usdt: f64,
45 pub min_ticks_between_signals: u64,
46}
47
48#[derive(Debug, Clone, Deserialize)]
49pub struct RiskConfig {
50 #[serde(default = "default_global_rate_limit_per_minute")]
51 pub global_rate_limit_per_minute: u32,
52 #[serde(default = "default_strategy_cooldown_ms")]
53 pub default_strategy_cooldown_ms: u64,
54 #[serde(default = "default_strategy_max_active_orders")]
55 pub default_strategy_max_active_orders: u32,
56 #[serde(default)]
57 pub strategy_limits: Vec<StrategyLimitConfig>,
58}
59
60#[derive(Debug, Clone, Deserialize)]
61pub struct StrategyLimitConfig {
62 pub source_tag: String,
63 pub cooldown_ms: Option<u64>,
64 pub max_active_orders: Option<u32>,
65}
66
67impl Default for RiskConfig {
68 fn default() -> Self {
69 Self {
70 global_rate_limit_per_minute: default_global_rate_limit_per_minute(),
71 default_strategy_cooldown_ms: default_strategy_cooldown_ms(),
72 default_strategy_max_active_orders: default_strategy_max_active_orders(),
73 strategy_limits: Vec::new(),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Deserialize)]
79pub struct UiConfig {
80 pub refresh_rate_ms: u64,
81 pub price_history_len: usize,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85pub struct LoggingConfig {
86 pub level: String,
87}
88
89fn default_futures_rest_base_url() -> String {
90 "https://demo-fapi.binance.com".to_string()
91}
92
93fn default_futures_ws_base_url() -> String {
94 "wss://fstream.binancefuture.com/ws".to_string()
95}
96
97fn default_global_rate_limit_per_minute() -> u32 {
98 600
99}
100
101fn default_strategy_cooldown_ms() -> u64 {
102 3_000
103}
104
105fn default_strategy_max_active_orders() -> u32 {
106 1
107}
108
109pub fn parse_interval_ms(s: &str) -> Result<u64> {
111 if s.len() < 2 {
112 bail!("invalid interval '{}': expected format like '1m'", s);
113 }
114
115 let (num_str, suffix) = s.split_at(s.len() - 1);
116 let n: u64 = num_str.parse().with_context(|| {
117 format!(
118 "invalid interval '{}': quantity must be a positive integer",
119 s
120 )
121 })?;
122 if n == 0 {
123 bail!("invalid interval '{}': quantity must be > 0", s);
124 }
125
126 let unit_ms = match suffix {
127 "s" => 1_000,
128 "m" => 60_000,
129 "h" => 3_600_000,
130 "d" => 86_400_000,
131 "w" => 7 * 86_400_000,
132 "M" => 30 * 86_400_000,
133 _ => bail!(
134 "invalid interval '{}': unsupported suffix '{}', expected one of s/m/h/d/w/M",
135 s,
136 suffix
137 ),
138 };
139
140 n.checked_mul(unit_ms)
141 .with_context(|| format!("invalid interval '{}': value is too large", s))
142}
143
144impl BinanceConfig {
145 pub fn kline_interval_ms(&self) -> Result<u64> {
146 parse_interval_ms(&self.kline_interval)
147 }
148
149 pub fn tradable_symbols(&self) -> Vec<String> {
150 let mut out = Vec::new();
151 if !self.symbol.trim().is_empty() {
152 out.push(self.symbol.trim().to_ascii_uppercase());
153 }
154 for sym in &self.symbols {
155 let s = sym.trim().to_ascii_uppercase();
156 if !s.is_empty() && !out.iter().any(|v| v == &s) {
157 out.push(s);
158 }
159 }
160 out
161 }
162
163 pub fn tradable_instruments(&self) -> Vec<String> {
164 let mut out = self.tradable_symbols();
165 for sym in &self.futures_symbols {
166 let s = sym.trim().to_ascii_uppercase();
167 if !s.is_empty() {
168 let label = format!("{} (FUT)", s);
169 if !out.iter().any(|v| v == &label) {
170 out.push(label);
171 }
172 }
173 }
174 out
175 }
176}
177
178impl Config {
179 pub fn load() -> Result<Self> {
180 dotenvy::dotenv().ok();
181
182 let config_path = Path::new("config/default.toml");
183 let config_str = std::fs::read_to_string(config_path)
184 .with_context(|| format!("failed to read {}", config_path.display()))?;
185
186 let mut config: Config =
187 toml::from_str(&config_str).context("failed to parse config/default.toml")?;
188
189 config.binance.api_key = std::env::var("BINANCE_API_KEY")
190 .context("BINANCE_API_KEY not set in .env or environment")?;
191 config.binance.api_secret = std::env::var("BINANCE_API_SECRET")
192 .context("BINANCE_API_SECRET not set in .env or environment")?;
193 config.binance.futures_api_key = std::env::var("BINANCE_FUTURES_API_KEY")
194 .unwrap_or_else(|_| config.binance.api_key.clone());
195 config.binance.futures_api_secret = std::env::var("BINANCE_FUTURES_API_SECRET")
196 .unwrap_or_else(|_| config.binance.api_secret.clone());
197
198 config
199 .binance
200 .kline_interval_ms()
201 .context("binance.kline_interval is invalid")?;
202
203 Ok(config)
204 }
205}