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