Skip to main content

sandbox_quant/
config.rs

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