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(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
371/// Parse a Binance kline interval string (e.g. "1s", "1m", "1h", "1d", "1w", "1M") into milliseconds.
372pub 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}