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 #[serde(default = "default_ev_forward_p_win")]
123 pub forward_p_win: f64,
124 #[serde(default = "default_ev_forward_target_rr")]
125 pub forward_target_rr: f64,
126 #[serde(default = "default_ev_y_mu")]
127 pub y_mu: f64,
128 #[serde(default = "default_ev_y_sigma_spot")]
129 pub y_sigma_spot: f64,
130 #[serde(default = "default_ev_y_sigma_futures")]
131 pub y_sigma_futures: f64,
132 #[serde(default = "default_ev_futures_multiplier")]
133 pub futures_multiplier: f64,
134 #[serde(default = "default_ev_y_ewma_alpha_mean")]
135 pub y_ewma_alpha_mean: f64,
136 #[serde(default = "default_ev_y_ewma_alpha_var")]
137 pub y_ewma_alpha_var: f64,
138 #[serde(default = "default_ev_y_min_sigma")]
139 pub y_min_sigma: f64,
140}
141
142impl Default for EvConfig {
143 fn default() -> Self {
144 Self {
145 enabled: default_ev_enabled(),
146 mode: default_ev_mode(),
147 lookback_trades: default_ev_lookback_trades(),
148 prior_a: default_ev_prior_a(),
149 prior_b: default_ev_prior_b(),
150 tail_prior_a: default_ev_tail_prior_a(),
151 tail_prior_b: default_ev_tail_prior_b(),
152 recency_lambda: default_ev_recency_lambda(),
153 shrink_k: default_ev_shrink_k(),
154 loss_threshold_usdt: default_ev_loss_threshold_usdt(),
155 gamma_tail_penalty: default_ev_gamma_tail_penalty(),
156 fee_slippage_penalty_usdt: default_ev_fee_slippage_penalty_usdt(),
157 entry_gate_min_ev_usdt: default_ev_entry_gate_min_ev_usdt(),
158 forward_p_win: default_ev_forward_p_win(),
159 forward_target_rr: default_ev_forward_target_rr(),
160 y_mu: default_ev_y_mu(),
161 y_sigma_spot: default_ev_y_sigma_spot(),
162 y_sigma_futures: default_ev_y_sigma_futures(),
163 futures_multiplier: default_ev_futures_multiplier(),
164 y_ewma_alpha_mean: default_ev_y_ewma_alpha_mean(),
165 y_ewma_alpha_var: default_ev_y_ewma_alpha_var(),
166 y_min_sigma: default_ev_y_min_sigma(),
167 }
168 }
169}
170
171#[derive(Debug, Clone, Deserialize)]
172pub struct ExitConfig {
173 #[serde(default = "default_exit_max_holding_ms")]
174 pub max_holding_ms: u64,
175 #[serde(default = "default_exit_stop_loss_pct")]
176 pub stop_loss_pct: f64,
177 #[serde(default = "default_exit_enforce_protective_stop")]
178 pub enforce_protective_stop: bool,
179}
180
181impl Default for ExitConfig {
182 fn default() -> Self {
183 Self {
184 max_holding_ms: default_exit_max_holding_ms(),
185 stop_loss_pct: default_exit_stop_loss_pct(),
186 enforce_protective_stop: default_exit_enforce_protective_stop(),
187 }
188 }
189}
190
191impl Default for EndpointRateLimitConfig {
192 fn default() -> Self {
193 Self {
194 orders_per_minute: default_endpoint_orders_limit_per_minute(),
195 account_per_minute: default_endpoint_account_limit_per_minute(),
196 market_data_per_minute: default_endpoint_market_data_limit_per_minute(),
197 }
198 }
199}
200
201impl Default for RiskConfig {
202 fn default() -> Self {
203 Self {
204 global_rate_limit_per_minute: default_global_rate_limit_per_minute(),
205 default_strategy_cooldown_ms: default_strategy_cooldown_ms(),
206 default_strategy_max_active_orders: default_strategy_max_active_orders(),
207 default_symbol_max_exposure_usdt: default_symbol_max_exposure_usdt(),
208 strategy_limits: Vec::new(),
209 symbol_exposure_limits: Vec::new(),
210 endpoint_rate_limits: EndpointRateLimitConfig::default(),
211 }
212 }
213}
214
215#[derive(Debug, Clone, Deserialize)]
216pub struct UiConfig {
217 pub refresh_rate_ms: u64,
218 pub price_history_len: usize,
219}
220
221#[derive(Debug, Clone, Deserialize)]
222pub struct LoggingConfig {
223 pub level: String,
224}
225
226fn default_futures_rest_base_url() -> String {
227 "https://demo-fapi.binance.com".to_string()
228}
229
230fn default_futures_ws_base_url() -> String {
231 "wss://fstream.binancefuture.com/ws".to_string()
232}
233
234fn default_global_rate_limit_per_minute() -> u32 {
235 600
236}
237
238fn default_strategy_cooldown_ms() -> u64 {
239 3_000
240}
241
242fn default_strategy_max_active_orders() -> u32 {
243 1
244}
245
246fn default_symbol_max_exposure_usdt() -> f64 {
247 200.0
248}
249
250fn default_endpoint_orders_limit_per_minute() -> u32 {
251 240
252}
253
254fn default_endpoint_account_limit_per_minute() -> u32 {
255 180
256}
257
258fn default_endpoint_market_data_limit_per_minute() -> u32 {
259 360
260}
261
262fn default_ev_enabled() -> bool {
263 true
264}
265
266fn default_ev_mode() -> String {
267 "shadow".to_string()
268}
269
270fn default_ev_lookback_trades() -> usize {
271 200
272}
273
274fn default_ev_prior_a() -> f64 {
275 6.0
276}
277
278fn default_ev_prior_b() -> f64 {
279 6.0
280}
281
282fn default_ev_tail_prior_a() -> f64 {
283 3.0
284}
285
286fn default_ev_tail_prior_b() -> f64 {
287 7.0
288}
289
290fn default_ev_recency_lambda() -> f64 {
291 0.08
292}
293
294fn default_ev_shrink_k() -> f64 {
295 40.0
296}
297
298fn default_ev_loss_threshold_usdt() -> f64 {
299 15.0
300}
301
302fn default_ev_gamma_tail_penalty() -> f64 {
303 0.8
304}
305
306fn default_ev_fee_slippage_penalty_usdt() -> f64 {
307 0.0
308}
309
310fn default_ev_entry_gate_min_ev_usdt() -> f64 {
311 0.0
312}
313
314fn default_ev_forward_p_win() -> f64 {
315 0.5
316}
317
318fn default_ev_forward_target_rr() -> f64 {
319 1.5
320}
321
322fn default_ev_y_mu() -> f64 {
323 0.0
324}
325
326fn default_ev_y_sigma_spot() -> f64 {
327 0.01
328}
329
330fn default_ev_y_sigma_futures() -> f64 {
331 0.015
332}
333
334fn default_ev_futures_multiplier() -> f64 {
335 1.0
336}
337
338fn default_ev_y_ewma_alpha_mean() -> f64 {
339 0.08
340}
341
342fn default_ev_y_ewma_alpha_var() -> f64 {
343 0.08
344}
345
346fn default_ev_y_min_sigma() -> f64 {
347 0.001
348}
349
350fn default_exit_max_holding_ms() -> u64 {
351 1_800_000
352}
353
354fn default_exit_stop_loss_pct() -> f64 {
355 0.015
356}
357
358fn default_exit_enforce_protective_stop() -> bool {
359 true
360}
361
362pub fn parse_interval_ms(s: &str) -> Result<u64> {
364 if s.len() < 2 {
365 bail!("invalid interval '{}': expected format like '1m'", s);
366 }
367
368 let (num_str, suffix) = s.split_at(s.len() - 1);
369 let n: u64 = num_str.parse().with_context(|| {
370 format!(
371 "invalid interval '{}': quantity must be a positive integer",
372 s
373 )
374 })?;
375 if n == 0 {
376 bail!("invalid interval '{}': quantity must be > 0", s);
377 }
378
379 let unit_ms = match suffix {
380 "s" => 1_000,
381 "m" => 60_000,
382 "h" => 3_600_000,
383 "d" => 86_400_000,
384 "w" => 7 * 86_400_000,
385 "M" => 30 * 86_400_000,
386 _ => bail!(
387 "invalid interval '{}': unsupported suffix '{}', expected one of s/m/h/d/w/M",
388 s,
389 suffix
390 ),
391 };
392
393 n.checked_mul(unit_ms)
394 .with_context(|| format!("invalid interval '{}': value is too large", s))
395}
396
397impl BinanceConfig {
398 pub fn kline_interval_ms(&self) -> Result<u64> {
399 parse_interval_ms(&self.kline_interval)
400 }
401
402 pub fn tradable_symbols(&self) -> Vec<String> {
403 let mut out = Vec::new();
404 if !self.symbol.trim().is_empty() {
405 out.push(self.symbol.trim().to_ascii_uppercase());
406 }
407 for sym in &self.symbols {
408 let s = sym.trim().to_ascii_uppercase();
409 if !s.is_empty() && !out.iter().any(|v| v == &s) {
410 out.push(s);
411 }
412 }
413 out
414 }
415
416 pub fn tradable_instruments(&self) -> Vec<String> {
417 let mut out = self.tradable_symbols();
418 for sym in &self.futures_symbols {
419 let s = sym.trim().to_ascii_uppercase();
420 if !s.is_empty() {
421 let label = format!("{} (FUT)", s);
422 if !out.iter().any(|v| v == &label) {
423 out.push(label);
424 }
425 }
426 }
427 out
428 }
429}
430
431impl Config {
432 pub fn load() -> Result<Self> {
433 dotenvy::dotenv().ok();
434
435 let config_path = Path::new("config/default.toml");
436 let config_str = std::fs::read_to_string(config_path)
437 .with_context(|| format!("failed to read {}", config_path.display()))?;
438
439 let mut config: Config =
440 toml::from_str(&config_str).context("failed to parse config/default.toml")?;
441
442 config.binance.api_key = std::env::var("BINANCE_API_KEY")
443 .context("BINANCE_API_KEY not set in .env or environment")?;
444 config.binance.api_secret = std::env::var("BINANCE_API_SECRET")
445 .context("BINANCE_API_SECRET not set in .env or environment")?;
446 config.binance.futures_api_key = std::env::var("BINANCE_FUTURES_API_KEY")
447 .unwrap_or_else(|_| config.binance.api_key.clone());
448 config.binance.futures_api_secret = std::env::var("BINANCE_FUTURES_API_SECRET")
449 .unwrap_or_else(|_| config.binance.api_secret.clone());
450
451 config
452 .binance
453 .kline_interval_ms()
454 .context("binance.kline_interval is invalid")?;
455
456 Ok(config)
457 }
458}