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 = "default_symbol_max_exposure_usdt")]
57 pub default_symbol_max_exposure_usdt: f64,
58 #[serde(default)]
59 pub strategy_limits: Vec<StrategyLimitConfig>,
60 #[serde(default)]
61 pub symbol_exposure_limits: Vec<SymbolExposureLimitConfig>,
62 #[serde(default)]
63 pub endpoint_rate_limits: EndpointRateLimitConfig,
64}
65
66#[derive(Debug, Clone, Deserialize)]
67pub struct StrategyLimitConfig {
68 pub source_tag: String,
69 pub cooldown_ms: Option<u64>,
70 pub max_active_orders: Option<u32>,
71}
72
73#[derive(Debug, Clone, Deserialize)]
74pub struct SymbolExposureLimitConfig {
75 pub symbol: String,
76 pub market: Option<String>,
77 pub max_exposure_usdt: f64,
78}
79
80#[derive(Debug, Clone, Deserialize)]
81pub struct EndpointRateLimitConfig {
82 #[serde(default = "default_endpoint_orders_limit_per_minute")]
83 pub orders_per_minute: u32,
84 #[serde(default = "default_endpoint_account_limit_per_minute")]
85 pub account_per_minute: u32,
86 #[serde(default = "default_endpoint_market_data_limit_per_minute")]
87 pub market_data_per_minute: u32,
88}
89
90impl Default for EndpointRateLimitConfig {
91 fn default() -> Self {
92 Self {
93 orders_per_minute: default_endpoint_orders_limit_per_minute(),
94 account_per_minute: default_endpoint_account_limit_per_minute(),
95 market_data_per_minute: default_endpoint_market_data_limit_per_minute(),
96 }
97 }
98}
99
100impl Default for RiskConfig {
101 fn default() -> Self {
102 Self {
103 global_rate_limit_per_minute: default_global_rate_limit_per_minute(),
104 default_strategy_cooldown_ms: default_strategy_cooldown_ms(),
105 default_strategy_max_active_orders: default_strategy_max_active_orders(),
106 default_symbol_max_exposure_usdt: default_symbol_max_exposure_usdt(),
107 strategy_limits: Vec::new(),
108 symbol_exposure_limits: Vec::new(),
109 endpoint_rate_limits: EndpointRateLimitConfig::default(),
110 }
111 }
112}
113
114#[derive(Debug, Clone, Deserialize)]
115pub struct UiConfig {
116 pub refresh_rate_ms: u64,
117 pub price_history_len: usize,
118}
119
120#[derive(Debug, Clone, Deserialize)]
121pub struct LoggingConfig {
122 pub level: String,
123}
124
125fn default_futures_rest_base_url() -> String {
126 "https://demo-fapi.binance.com".to_string()
127}
128
129fn default_futures_ws_base_url() -> String {
130 "wss://fstream.binancefuture.com/ws".to_string()
131}
132
133fn default_global_rate_limit_per_minute() -> u32 {
134 600
135}
136
137fn default_strategy_cooldown_ms() -> u64 {
138 3_000
139}
140
141fn default_strategy_max_active_orders() -> u32 {
142 1
143}
144
145fn default_symbol_max_exposure_usdt() -> f64 {
146 200.0
147}
148
149fn default_endpoint_orders_limit_per_minute() -> u32 {
150 240
151}
152
153fn default_endpoint_account_limit_per_minute() -> u32 {
154 180
155}
156
157fn default_endpoint_market_data_limit_per_minute() -> u32 {
158 360
159}
160
161pub fn parse_interval_ms(s: &str) -> Result<u64> {
163 if s.len() < 2 {
164 bail!("invalid interval '{}': expected format like '1m'", s);
165 }
166
167 let (num_str, suffix) = s.split_at(s.len() - 1);
168 let n: u64 = num_str.parse().with_context(|| {
169 format!(
170 "invalid interval '{}': quantity must be a positive integer",
171 s
172 )
173 })?;
174 if n == 0 {
175 bail!("invalid interval '{}': quantity must be > 0", s);
176 }
177
178 let unit_ms = match suffix {
179 "s" => 1_000,
180 "m" => 60_000,
181 "h" => 3_600_000,
182 "d" => 86_400_000,
183 "w" => 7 * 86_400_000,
184 "M" => 30 * 86_400_000,
185 _ => bail!(
186 "invalid interval '{}': unsupported suffix '{}', expected one of s/m/h/d/w/M",
187 s,
188 suffix
189 ),
190 };
191
192 n.checked_mul(unit_ms)
193 .with_context(|| format!("invalid interval '{}': value is too large", s))
194}
195
196impl BinanceConfig {
197 pub fn kline_interval_ms(&self) -> Result<u64> {
198 parse_interval_ms(&self.kline_interval)
199 }
200
201 pub fn tradable_symbols(&self) -> Vec<String> {
202 let mut out = Vec::new();
203 if !self.symbol.trim().is_empty() {
204 out.push(self.symbol.trim().to_ascii_uppercase());
205 }
206 for sym in &self.symbols {
207 let s = sym.trim().to_ascii_uppercase();
208 if !s.is_empty() && !out.iter().any(|v| v == &s) {
209 out.push(s);
210 }
211 }
212 out
213 }
214
215 pub fn tradable_instruments(&self) -> Vec<String> {
216 let mut out = self.tradable_symbols();
217 for sym in &self.futures_symbols {
218 let s = sym.trim().to_ascii_uppercase();
219 if !s.is_empty() {
220 let label = format!("{} (FUT)", s);
221 if !out.iter().any(|v| v == &label) {
222 out.push(label);
223 }
224 }
225 }
226 out
227 }
228}
229
230impl Config {
231 pub fn load() -> Result<Self> {
232 dotenvy::dotenv().ok();
233
234 let config_path = Path::new("config/default.toml");
235 let config_str = std::fs::read_to_string(config_path)
236 .with_context(|| format!("failed to read {}", config_path.display()))?;
237
238 let mut config: Config =
239 toml::from_str(&config_str).context("failed to parse config/default.toml")?;
240
241 config.binance.api_key = std::env::var("BINANCE_API_KEY")
242 .context("BINANCE_API_KEY not set in .env or environment")?;
243 config.binance.api_secret = std::env::var("BINANCE_API_SECRET")
244 .context("BINANCE_API_SECRET not set in .env or environment")?;
245 config.binance.futures_api_key = std::env::var("BINANCE_FUTURES_API_KEY")
246 .unwrap_or_else(|_| config.binance.api_key.clone());
247 config.binance.futures_api_secret = std::env::var("BINANCE_FUTURES_API_SECRET")
248 .unwrap_or_else(|_| config.binance.api_secret.clone());
249
250 config
251 .binance
252 .kline_interval_ms()
253 .context("binance.kline_interval is invalid")?;
254
255 Ok(config)
256 }
257}