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, alias = "ev")]
12 pub alpha: AlphaConfig,
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 AlphaConfig {
96 #[serde(default = "default_alpha_enabled")]
97 pub enabled: bool,
98 #[serde(default = "default_alpha_mode")]
99 pub mode: String,
100 #[serde(default = "default_alpha_lookback_trades")]
101 pub lookback_trades: usize,
102 #[serde(default = "default_alpha_prior_a")]
103 pub prior_a: f64,
104 #[serde(default = "default_alpha_prior_b")]
105 pub prior_b: f64,
106 #[serde(default = "default_alpha_tail_prior_a")]
107 pub tail_prior_a: f64,
108 #[serde(default = "default_alpha_tail_prior_b")]
109 pub tail_prior_b: f64,
110 #[serde(default = "default_alpha_recency_lambda")]
111 pub recency_lambda: f64,
112 #[serde(default = "default_alpha_shrink_k")]
113 pub shrink_k: f64,
114 #[serde(default = "default_alpha_loss_threshold_usdt")]
115 pub loss_threshold_usdt: f64,
116 #[serde(default = "default_alpha_gamma_tail_penalty")]
117 pub gamma_tail_penalty: f64,
118 #[serde(default = "default_alpha_fee_slippage_penalty_usdt")]
119 pub fee_slippage_penalty_usdt: f64,
120 #[serde(
121 default = "default_alpha_entry_gate_min_alpha_usdt",
122 alias = "entry_gate_min_ev_usdt"
123 )]
124 pub entry_gate_min_alpha_usdt: f64,
125 #[serde(default = "default_alpha_forward_p_win")]
126 pub forward_p_win: f64,
127 #[serde(default = "default_alpha_forward_target_rr")]
128 pub forward_target_rr: f64,
129 #[serde(default = "default_alpha_predictor_mu", alias = "y_mu")]
130 pub predictor_mu: f64,
131 #[serde(default = "default_alpha_predictor_sigma_spot", alias = "y_sigma_spot")]
132 pub predictor_sigma_spot: f64,
133 #[serde(default = "default_alpha_predictor_sigma_futures", alias = "y_sigma_futures")]
134 pub predictor_sigma_futures: f64,
135 #[serde(default = "default_alpha_futures_multiplier")]
136 pub futures_multiplier: f64,
137 #[serde(
138 default = "default_alpha_predictor_ewma_alpha_mean",
139 alias = "y_ewma_alpha_mean"
140 )]
141 pub predictor_ewma_alpha_mean: f64,
142 #[serde(
143 default = "default_alpha_predictor_ewma_alpha_var",
144 alias = "y_ewma_alpha_var"
145 )]
146 pub predictor_ewma_alpha_var: f64,
147 #[serde(default = "default_alpha_predictor_min_sigma", alias = "y_min_sigma")]
148 pub predictor_min_sigma: f64,
149}
150
151impl Default for AlphaConfig {
152 fn default() -> Self {
153 Self {
154 enabled: default_alpha_enabled(),
155 mode: default_alpha_mode(),
156 lookback_trades: default_alpha_lookback_trades(),
157 prior_a: default_alpha_prior_a(),
158 prior_b: default_alpha_prior_b(),
159 tail_prior_a: default_alpha_tail_prior_a(),
160 tail_prior_b: default_alpha_tail_prior_b(),
161 recency_lambda: default_alpha_recency_lambda(),
162 shrink_k: default_alpha_shrink_k(),
163 loss_threshold_usdt: default_alpha_loss_threshold_usdt(),
164 gamma_tail_penalty: default_alpha_gamma_tail_penalty(),
165 fee_slippage_penalty_usdt: default_alpha_fee_slippage_penalty_usdt(),
166 entry_gate_min_alpha_usdt: default_alpha_entry_gate_min_alpha_usdt(),
167 forward_p_win: default_alpha_forward_p_win(),
168 forward_target_rr: default_alpha_forward_target_rr(),
169 predictor_mu: default_alpha_predictor_mu(),
170 predictor_sigma_spot: default_alpha_predictor_sigma_spot(),
171 predictor_sigma_futures: default_alpha_predictor_sigma_futures(),
172 futures_multiplier: default_alpha_futures_multiplier(),
173 predictor_ewma_alpha_mean: default_alpha_predictor_ewma_alpha_mean(),
174 predictor_ewma_alpha_var: default_alpha_predictor_ewma_alpha_var(),
175 predictor_min_sigma: default_alpha_predictor_min_sigma(),
176 }
177 }
178}
179
180#[derive(Debug, Clone, Deserialize)]
181pub struct ExitConfig {
182 #[serde(default = "default_exit_max_holding_ms")]
183 pub max_holding_ms: u64,
184 #[serde(default = "default_exit_stop_loss_pct")]
185 pub stop_loss_pct: f64,
186 #[serde(default = "default_exit_enforce_protective_stop")]
187 pub enforce_protective_stop: bool,
188}
189
190impl Default for ExitConfig {
191 fn default() -> Self {
192 Self {
193 max_holding_ms: default_exit_max_holding_ms(),
194 stop_loss_pct: default_exit_stop_loss_pct(),
195 enforce_protective_stop: default_exit_enforce_protective_stop(),
196 }
197 }
198}
199
200impl Default for EndpointRateLimitConfig {
201 fn default() -> Self {
202 Self {
203 orders_per_minute: default_endpoint_orders_limit_per_minute(),
204 account_per_minute: default_endpoint_account_limit_per_minute(),
205 market_data_per_minute: default_endpoint_market_data_limit_per_minute(),
206 }
207 }
208}
209
210impl Default for RiskConfig {
211 fn default() -> Self {
212 Self {
213 global_rate_limit_per_minute: default_global_rate_limit_per_minute(),
214 default_strategy_cooldown_ms: default_strategy_cooldown_ms(),
215 default_strategy_max_active_orders: default_strategy_max_active_orders(),
216 default_symbol_max_exposure_usdt: default_symbol_max_exposure_usdt(),
217 strategy_limits: Vec::new(),
218 symbol_exposure_limits: Vec::new(),
219 endpoint_rate_limits: EndpointRateLimitConfig::default(),
220 }
221 }
222}
223
224#[derive(Debug, Clone, Deserialize)]
225pub struct UiConfig {
226 pub refresh_rate_ms: u64,
227 pub price_history_len: usize,
228}
229
230#[derive(Debug, Clone, Deserialize)]
231pub struct LoggingConfig {
232 pub level: String,
233}
234
235fn default_futures_rest_base_url() -> String {
236 "https://demo-fapi.binance.com".to_string()
237}
238
239fn default_futures_ws_base_url() -> String {
240 "wss://fstream.binancefuture.com/ws".to_string()
241}
242
243fn default_global_rate_limit_per_minute() -> u32 {
244 600
245}
246
247fn default_strategy_cooldown_ms() -> u64 {
248 3_000
249}
250
251fn default_strategy_max_active_orders() -> u32 {
252 1
253}
254
255fn default_symbol_max_exposure_usdt() -> f64 {
256 200.0
257}
258
259fn default_endpoint_orders_limit_per_minute() -> u32 {
260 240
261}
262
263fn default_endpoint_account_limit_per_minute() -> u32 {
264 180
265}
266
267fn default_endpoint_market_data_limit_per_minute() -> u32 {
268 360
269}
270
271fn default_alpha_enabled() -> bool {
272 true
273}
274
275fn default_alpha_mode() -> String {
276 "shadow".to_string()
277}
278
279fn default_alpha_lookback_trades() -> usize {
280 200
281}
282
283fn default_alpha_prior_a() -> f64 {
284 6.0
285}
286
287fn default_alpha_prior_b() -> f64 {
288 6.0
289}
290
291fn default_alpha_tail_prior_a() -> f64 {
292 3.0
293}
294
295fn default_alpha_tail_prior_b() -> f64 {
296 7.0
297}
298
299fn default_alpha_recency_lambda() -> f64 {
300 0.08
301}
302
303fn default_alpha_shrink_k() -> f64 {
304 40.0
305}
306
307fn default_alpha_loss_threshold_usdt() -> f64 {
308 15.0
309}
310
311fn default_alpha_gamma_tail_penalty() -> f64 {
312 0.8
313}
314
315fn default_alpha_fee_slippage_penalty_usdt() -> f64 {
316 0.0
317}
318
319fn default_alpha_entry_gate_min_alpha_usdt() -> f64 {
320 0.0
321}
322
323fn default_alpha_forward_p_win() -> f64 {
324 0.5
325}
326
327fn default_alpha_forward_target_rr() -> f64 {
328 1.5
329}
330
331fn default_alpha_predictor_mu() -> f64 {
332 0.0
333}
334
335fn default_alpha_predictor_sigma_spot() -> f64 {
336 0.01
337}
338
339fn default_alpha_predictor_sigma_futures() -> f64 {
340 0.015
341}
342
343fn default_alpha_futures_multiplier() -> f64 {
344 1.0
345}
346
347fn default_alpha_predictor_ewma_alpha_mean() -> f64 {
348 0.08
349}
350
351fn default_alpha_predictor_ewma_alpha_var() -> f64 {
352 0.08
353}
354
355fn default_alpha_predictor_min_sigma() -> f64 {
356 0.001
357}
358
359fn default_exit_max_holding_ms() -> u64 {
360 1_800_000
361}
362
363fn default_exit_stop_loss_pct() -> f64 {
364 0.015
365}
366
367fn default_exit_enforce_protective_stop() -> bool {
368 true
369}
370
371pub fn parse_interval_ms(s: &str) -> Result<u64> {
373 if s.len() < 2 {
374 bail!("invalid interval '{}': expected format like '1m'", s);
375 }
376
377 let (num_str, suffix) = s.split_at(s.len() - 1);
378 let n: u64 = num_str.parse().with_context(|| {
379 format!(
380 "invalid interval '{}': quantity must be a positive integer",
381 s
382 )
383 })?;
384 if n == 0 {
385 bail!("invalid interval '{}': quantity must be > 0", s);
386 }
387
388 let unit_ms = match suffix {
389 "s" => 1_000,
390 "m" => 60_000,
391 "h" => 3_600_000,
392 "d" => 86_400_000,
393 "w" => 7 * 86_400_000,
394 "M" => 30 * 86_400_000,
395 _ => bail!(
396 "invalid interval '{}': unsupported suffix '{}', expected one of s/m/h/d/w/M",
397 s,
398 suffix
399 ),
400 };
401
402 n.checked_mul(unit_ms)
403 .with_context(|| format!("invalid interval '{}': value is too large", s))
404}
405
406impl BinanceConfig {
407 pub fn kline_interval_ms(&self) -> Result<u64> {
408 parse_interval_ms(&self.kline_interval)
409 }
410
411 pub fn tradable_symbols(&self) -> Vec<String> {
412 let mut out = Vec::new();
413 if !self.symbol.trim().is_empty() {
414 out.push(self.symbol.trim().to_ascii_uppercase());
415 }
416 for sym in &self.symbols {
417 let s = sym.trim().to_ascii_uppercase();
418 if !s.is_empty() && !out.iter().any(|v| v == &s) {
419 out.push(s);
420 }
421 }
422 out
423 }
424
425 pub fn tradable_instruments(&self) -> Vec<String> {
426 let mut out = self.tradable_symbols();
427 for sym in &self.futures_symbols {
428 let s = sym.trim().to_ascii_uppercase();
429 if !s.is_empty() {
430 let label = format!("{} (FUT)", s);
431 if !out.iter().any(|v| v == &label) {
432 out.push(label);
433 }
434 }
435 }
436 out
437 }
438}
439
440impl Config {
441 pub fn load() -> Result<Self> {
442 dotenvy::dotenv().ok();
443
444 let config_path = Path::new("config/default.toml");
445 let config_str = std::fs::read_to_string(config_path)
446 .with_context(|| format!("failed to read {}", config_path.display()))?;
447
448 let mut config: Config =
449 toml::from_str(&config_str).context("failed to parse config/default.toml")?;
450
451 config.binance.api_key = load_required_credential("BINANCE_API_KEY")?;
452 config.binance.api_secret = load_required_credential("BINANCE_API_SECRET")?;
453 config.binance.futures_api_key = load_optional_credential("BINANCE_FUTURES_API_KEY")
454 .unwrap_or_else(|| config.binance.api_key.clone());
455 config.binance.futures_api_secret = load_optional_credential("BINANCE_FUTURES_API_SECRET")
456 .unwrap_or_else(|| config.binance.api_secret.clone());
457
458 config
459 .binance
460 .kline_interval_ms()
461 .context("binance.kline_interval is invalid")?;
462
463 Ok(config)
464 }
465}
466
467fn sanitize_credential(raw: &str) -> String {
468 let trimmed = raw.trim();
469 let unquoted = if trimmed.len() >= 2 {
470 let starts = trimmed.as_bytes()[0];
471 let ends = trimmed.as_bytes()[trimmed.len() - 1];
472 if (starts == b'"' && ends == b'"') || (starts == b'\'' && ends == b'\'') {
473 &trimmed[1..trimmed.len() - 1]
474 } else {
475 trimmed
476 }
477 } else {
478 trimmed
479 };
480 unquoted.trim().to_string()
481}
482
483fn load_required_credential(name: &str) -> Result<String> {
484 let raw =
485 std::env::var(name).with_context(|| format!("{} not set in .env or environment", name))?;
486 let sanitized = sanitize_credential(&raw);
487 if sanitized.is_empty() {
488 bail!("{} is empty after trimming whitespace/quotes", name);
489 }
490 Ok(sanitized)
491}
492
493fn load_optional_credential(name: &str) -> Option<String> {
494 let raw = std::env::var(name).ok()?;
495 let sanitized = sanitize_credential(&raw);
496 if sanitized.is_empty() {
497 None
498 } else {
499 Some(sanitized)
500 }
501}