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 #[serde(default)]
12 pub ev: EvConfig,
13 #[serde(default)]
14 pub exit: ExitConfig,
15 pub ui: UiConfig,
16 pub logging: LoggingConfig,
17}
18
19#[derive(Debug, Clone, Deserialize)]
20pub struct BinanceConfig {
21 pub rest_base_url: String,
22 pub ws_base_url: String,
23 #[serde(default = "default_futures_rest_base_url")]
24 pub futures_rest_base_url: String,
25 #[serde(default = "default_futures_ws_base_url")]
26 pub futures_ws_base_url: String,
27 pub symbol: String,
28 #[serde(default)]
29 pub symbols: Vec<String>,
30 #[serde(default)]
31 pub futures_symbols: Vec<String>,
32 pub recv_window: u64,
33 pub kline_interval: String,
34 #[serde(skip)]
35 pub api_key: String,
36 #[serde(skip)]
37 pub api_secret: String,
38 #[serde(skip)]
39 pub futures_api_key: String,
40 #[serde(skip)]
41 pub futures_api_secret: String,
42}
43
44#[derive(Debug, Clone, Deserialize)]
45pub struct StrategyConfig {
46 pub fast_period: usize,
47 pub slow_period: usize,
48 pub order_amount_usdt: f64,
49 pub min_ticks_between_signals: u64,
50}
51
52#[derive(Debug, Clone, Deserialize)]
53pub struct RiskConfig {
54 #[serde(default = "default_global_rate_limit_per_minute")]
55 pub global_rate_limit_per_minute: u32,
56 #[serde(default = "default_strategy_cooldown_ms")]
57 pub default_strategy_cooldown_ms: u64,
58 #[serde(default = "default_strategy_max_active_orders")]
59 pub default_strategy_max_active_orders: u32,
60 #[serde(default = "default_symbol_max_exposure_usdt")]
61 pub default_symbol_max_exposure_usdt: f64,
62 #[serde(default)]
63 pub strategy_limits: Vec<StrategyLimitConfig>,
64 #[serde(default)]
65 pub symbol_exposure_limits: Vec<SymbolExposureLimitConfig>,
66 #[serde(default)]
67 pub endpoint_rate_limits: EndpointRateLimitConfig,
68}
69
70#[derive(Debug, Clone, Deserialize)]
71pub struct StrategyLimitConfig {
72 pub source_tag: String,
73 pub cooldown_ms: Option<u64>,
74 pub max_active_orders: Option<u32>,
75}
76
77#[derive(Debug, Clone, Deserialize)]
78pub struct SymbolExposureLimitConfig {
79 pub symbol: String,
80 pub market: Option<String>,
81 pub max_exposure_usdt: f64,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85pub struct EndpointRateLimitConfig {
86 #[serde(default = "default_endpoint_orders_limit_per_minute")]
87 pub orders_per_minute: u32,
88 #[serde(default = "default_endpoint_account_limit_per_minute")]
89 pub account_per_minute: u32,
90 #[serde(default = "default_endpoint_market_data_limit_per_minute")]
91 pub market_data_per_minute: u32,
92}
93
94#[derive(Debug, Clone, Deserialize)]
95pub struct EvConfig {
96 #[serde(default = "default_ev_enabled")]
97 pub enabled: bool,
98 #[serde(default = "default_ev_mode")]
99 pub mode: String,
100 #[serde(default = "default_ev_lookback_trades")]
101 pub lookback_trades: usize,
102 #[serde(default = "default_ev_prior_a")]
103 pub prior_a: f64,
104 #[serde(default = "default_ev_prior_b")]
105 pub prior_b: f64,
106 #[serde(default = "default_ev_tail_prior_a")]
107 pub tail_prior_a: f64,
108 #[serde(default = "default_ev_tail_prior_b")]
109 pub tail_prior_b: f64,
110 #[serde(default = "default_ev_recency_lambda")]
111 pub recency_lambda: f64,
112 #[serde(default = "default_ev_shrink_k")]
113 pub shrink_k: f64,
114 #[serde(default = "default_ev_loss_threshold_usdt")]
115 pub loss_threshold_usdt: f64,
116 #[serde(default = "default_ev_gamma_tail_penalty")]
117 pub gamma_tail_penalty: f64,
118 #[serde(default = "default_ev_fee_slippage_penalty_usdt")]
119 pub fee_slippage_penalty_usdt: f64,
120 #[serde(default = "default_ev_entry_gate_min_ev_usdt")]
121 pub entry_gate_min_ev_usdt: f64,
122}
123
124impl Default for EvConfig {
125 fn default() -> Self {
126 Self {
127 enabled: default_ev_enabled(),
128 mode: default_ev_mode(),
129 lookback_trades: default_ev_lookback_trades(),
130 prior_a: default_ev_prior_a(),
131 prior_b: default_ev_prior_b(),
132 tail_prior_a: default_ev_tail_prior_a(),
133 tail_prior_b: default_ev_tail_prior_b(),
134 recency_lambda: default_ev_recency_lambda(),
135 shrink_k: default_ev_shrink_k(),
136 loss_threshold_usdt: default_ev_loss_threshold_usdt(),
137 gamma_tail_penalty: default_ev_gamma_tail_penalty(),
138 fee_slippage_penalty_usdt: default_ev_fee_slippage_penalty_usdt(),
139 entry_gate_min_ev_usdt: default_ev_entry_gate_min_ev_usdt(),
140 }
141 }
142}
143
144#[derive(Debug, Clone, Deserialize)]
145pub struct ExitConfig {
146 #[serde(default = "default_exit_max_holding_ms")]
147 pub max_holding_ms: u64,
148 #[serde(default = "default_exit_stop_loss_pct")]
149 pub stop_loss_pct: f64,
150 #[serde(default = "default_exit_enforce_protective_stop")]
151 pub enforce_protective_stop: bool,
152}
153
154impl Default for ExitConfig {
155 fn default() -> Self {
156 Self {
157 max_holding_ms: default_exit_max_holding_ms(),
158 stop_loss_pct: default_exit_stop_loss_pct(),
159 enforce_protective_stop: default_exit_enforce_protective_stop(),
160 }
161 }
162}
163
164impl Default for EndpointRateLimitConfig {
165 fn default() -> Self {
166 Self {
167 orders_per_minute: default_endpoint_orders_limit_per_minute(),
168 account_per_minute: default_endpoint_account_limit_per_minute(),
169 market_data_per_minute: default_endpoint_market_data_limit_per_minute(),
170 }
171 }
172}
173
174impl Default for RiskConfig {
175 fn default() -> Self {
176 Self {
177 global_rate_limit_per_minute: default_global_rate_limit_per_minute(),
178 default_strategy_cooldown_ms: default_strategy_cooldown_ms(),
179 default_strategy_max_active_orders: default_strategy_max_active_orders(),
180 default_symbol_max_exposure_usdt: default_symbol_max_exposure_usdt(),
181 strategy_limits: Vec::new(),
182 symbol_exposure_limits: Vec::new(),
183 endpoint_rate_limits: EndpointRateLimitConfig::default(),
184 }
185 }
186}
187
188#[derive(Debug, Clone, Deserialize)]
189pub struct UiConfig {
190 pub refresh_rate_ms: u64,
191 pub price_history_len: usize,
192}
193
194#[derive(Debug, Clone, Deserialize)]
195pub struct LoggingConfig {
196 pub level: String,
197}
198
199fn default_futures_rest_base_url() -> String {
200 "https://demo-fapi.binance.com".to_string()
201}
202
203fn default_futures_ws_base_url() -> String {
204 "wss://fstream.binancefuture.com/ws".to_string()
205}
206
207fn default_global_rate_limit_per_minute() -> u32 {
208 600
209}
210
211fn default_strategy_cooldown_ms() -> u64 {
212 3_000
213}
214
215fn default_strategy_max_active_orders() -> u32 {
216 1
217}
218
219fn default_symbol_max_exposure_usdt() -> f64 {
220 200.0
221}
222
223fn default_endpoint_orders_limit_per_minute() -> u32 {
224 240
225}
226
227fn default_endpoint_account_limit_per_minute() -> u32 {
228 180
229}
230
231fn default_endpoint_market_data_limit_per_minute() -> u32 {
232 360
233}
234
235fn default_ev_enabled() -> bool {
236 true
237}
238
239fn default_ev_mode() -> String {
240 "shadow".to_string()
241}
242
243fn default_ev_lookback_trades() -> usize {
244 200
245}
246
247fn default_ev_prior_a() -> f64 {
248 6.0
249}
250
251fn default_ev_prior_b() -> f64 {
252 6.0
253}
254
255fn default_ev_tail_prior_a() -> f64 {
256 3.0
257}
258
259fn default_ev_tail_prior_b() -> f64 {
260 7.0
261}
262
263fn default_ev_recency_lambda() -> f64 {
264 0.08
265}
266
267fn default_ev_shrink_k() -> f64 {
268 40.0
269}
270
271fn default_ev_loss_threshold_usdt() -> f64 {
272 15.0
273}
274
275fn default_ev_gamma_tail_penalty() -> f64 {
276 0.8
277}
278
279fn default_ev_fee_slippage_penalty_usdt() -> f64 {
280 0.0
281}
282
283fn default_ev_entry_gate_min_ev_usdt() -> f64 {
284 0.0
285}
286
287fn default_exit_max_holding_ms() -> u64 {
288 1_800_000
289}
290
291fn default_exit_stop_loss_pct() -> f64 {
292 0.015
293}
294
295fn default_exit_enforce_protective_stop() -> bool {
296 true
297}
298
299pub fn parse_interval_ms(s: &str) -> Result<u64> {
301 if s.len() < 2 {
302 bail!("invalid interval '{}': expected format like '1m'", s);
303 }
304
305 let (num_str, suffix) = s.split_at(s.len() - 1);
306 let n: u64 = num_str.parse().with_context(|| {
307 format!(
308 "invalid interval '{}': quantity must be a positive integer",
309 s
310 )
311 })?;
312 if n == 0 {
313 bail!("invalid interval '{}': quantity must be > 0", s);
314 }
315
316 let unit_ms = match suffix {
317 "s" => 1_000,
318 "m" => 60_000,
319 "h" => 3_600_000,
320 "d" => 86_400_000,
321 "w" => 7 * 86_400_000,
322 "M" => 30 * 86_400_000,
323 _ => bail!(
324 "invalid interval '{}': unsupported suffix '{}', expected one of s/m/h/d/w/M",
325 s,
326 suffix
327 ),
328 };
329
330 n.checked_mul(unit_ms)
331 .with_context(|| format!("invalid interval '{}': value is too large", s))
332}
333
334impl BinanceConfig {
335 pub fn kline_interval_ms(&self) -> Result<u64> {
336 parse_interval_ms(&self.kline_interval)
337 }
338
339 pub fn tradable_symbols(&self) -> Vec<String> {
340 let mut out = Vec::new();
341 if !self.symbol.trim().is_empty() {
342 out.push(self.symbol.trim().to_ascii_uppercase());
343 }
344 for sym in &self.symbols {
345 let s = sym.trim().to_ascii_uppercase();
346 if !s.is_empty() && !out.iter().any(|v| v == &s) {
347 out.push(s);
348 }
349 }
350 out
351 }
352
353 pub fn tradable_instruments(&self) -> Vec<String> {
354 let mut out = self.tradable_symbols();
355 for sym in &self.futures_symbols {
356 let s = sym.trim().to_ascii_uppercase();
357 if !s.is_empty() {
358 let label = format!("{} (FUT)", s);
359 if !out.iter().any(|v| v == &label) {
360 out.push(label);
361 }
362 }
363 }
364 out
365 }
366}
367
368impl Config {
369 pub fn load() -> Result<Self> {
370 dotenvy::dotenv().ok();
371
372 let config_path = Path::new("config/default.toml");
373 let config_str = std::fs::read_to_string(config_path)
374 .with_context(|| format!("failed to read {}", config_path.display()))?;
375
376 let mut config: Config =
377 toml::from_str(&config_str).context("failed to parse config/default.toml")?;
378
379 config.binance.api_key = std::env::var("BINANCE_API_KEY")
380 .context("BINANCE_API_KEY not set in .env or environment")?;
381 config.binance.api_secret = std::env::var("BINANCE_API_SECRET")
382 .context("BINANCE_API_SECRET not set in .env or environment")?;
383 config.binance.futures_api_key = std::env::var("BINANCE_FUTURES_API_KEY")
384 .unwrap_or_else(|_| config.binance.api_key.clone());
385 config.binance.futures_api_secret = std::env::var("BINANCE_FUTURES_API_SECRET")
386 .unwrap_or_else(|_| config.binance.api_secret.clone());
387
388 config
389 .binance
390 .kline_interval_ms()
391 .context("binance.kline_interval is invalid")?;
392
393 Ok(config)
394 }
395}