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    pub ui: UiConfig,
12    pub logging: LoggingConfig,
13}
14
15#[derive(Debug, Clone, Deserialize)]
16pub struct BinanceConfig {
17    pub rest_base_url: String,
18    pub ws_base_url: String,
19    #[serde(default = "default_futures_rest_base_url")]
20    pub futures_rest_base_url: String,
21    #[serde(default = "default_futures_ws_base_url")]
22    pub futures_ws_base_url: String,
23    pub symbol: String,
24    #[serde(default)]
25    pub symbols: Vec<String>,
26    #[serde(default)]
27    pub futures_symbols: Vec<String>,
28    pub recv_window: u64,
29    pub kline_interval: String,
30    #[serde(skip)]
31    pub api_key: String,
32    #[serde(skip)]
33    pub api_secret: String,
34    #[serde(skip)]
35    pub futures_api_key: String,
36    #[serde(skip)]
37    pub futures_api_secret: String,
38}
39
40#[derive(Debug, Clone, Deserialize)]
41pub struct StrategyConfig {
42    pub fast_period: usize,
43    pub slow_period: usize,
44    pub order_amount_usdt: f64,
45    pub min_ticks_between_signals: u64,
46}
47
48#[derive(Debug, Clone, Deserialize)]
49pub struct RiskConfig {
50    #[serde(default = "default_global_rate_limit_per_minute")]
51    pub global_rate_limit_per_minute: u32,
52    #[serde(default = "default_strategy_cooldown_ms")]
53    pub default_strategy_cooldown_ms: u64,
54    #[serde(default = "default_strategy_max_active_orders")]
55    pub default_strategy_max_active_orders: u32,
56    #[serde(default = "default_symbol_max_exposure_usdt")]
57    pub default_symbol_max_exposure_usdt: f64,
58    #[serde(default)]
59    pub strategy_limits: Vec<StrategyLimitConfig>,
60    #[serde(default)]
61    pub symbol_exposure_limits: Vec<SymbolExposureLimitConfig>,
62    #[serde(default)]
63    pub endpoint_rate_limits: EndpointRateLimitConfig,
64}
65
66#[derive(Debug, Clone, Deserialize)]
67pub struct StrategyLimitConfig {
68    pub source_tag: String,
69    pub cooldown_ms: Option<u64>,
70    pub max_active_orders: Option<u32>,
71}
72
73#[derive(Debug, Clone, Deserialize)]
74pub struct SymbolExposureLimitConfig {
75    pub symbol: String,
76    pub market: Option<String>,
77    pub max_exposure_usdt: f64,
78}
79
80#[derive(Debug, Clone, Deserialize)]
81pub struct EndpointRateLimitConfig {
82    #[serde(default = "default_endpoint_orders_limit_per_minute")]
83    pub orders_per_minute: u32,
84    #[serde(default = "default_endpoint_account_limit_per_minute")]
85    pub account_per_minute: u32,
86    #[serde(default = "default_endpoint_market_data_limit_per_minute")]
87    pub market_data_per_minute: u32,
88}
89
90impl Default for EndpointRateLimitConfig {
91    fn default() -> Self {
92        Self {
93            orders_per_minute: default_endpoint_orders_limit_per_minute(),
94            account_per_minute: default_endpoint_account_limit_per_minute(),
95            market_data_per_minute: default_endpoint_market_data_limit_per_minute(),
96        }
97    }
98}
99
100impl Default for RiskConfig {
101    fn default() -> Self {
102        Self {
103            global_rate_limit_per_minute: default_global_rate_limit_per_minute(),
104            default_strategy_cooldown_ms: default_strategy_cooldown_ms(),
105            default_strategy_max_active_orders: default_strategy_max_active_orders(),
106            default_symbol_max_exposure_usdt: default_symbol_max_exposure_usdt(),
107            strategy_limits: Vec::new(),
108            symbol_exposure_limits: Vec::new(),
109            endpoint_rate_limits: EndpointRateLimitConfig::default(),
110        }
111    }
112}
113
114#[derive(Debug, Clone, Deserialize)]
115pub struct UiConfig {
116    pub refresh_rate_ms: u64,
117    pub price_history_len: usize,
118}
119
120#[derive(Debug, Clone, Deserialize)]
121pub struct LoggingConfig {
122    pub level: String,
123}
124
125fn default_futures_rest_base_url() -> String {
126    "https://demo-fapi.binance.com".to_string()
127}
128
129fn default_futures_ws_base_url() -> String {
130    "wss://fstream.binancefuture.com/ws".to_string()
131}
132
133fn default_global_rate_limit_per_minute() -> u32 {
134    600
135}
136
137fn default_strategy_cooldown_ms() -> u64 {
138    3_000
139}
140
141fn default_strategy_max_active_orders() -> u32 {
142    1
143}
144
145fn default_symbol_max_exposure_usdt() -> f64 {
146    200.0
147}
148
149fn default_endpoint_orders_limit_per_minute() -> u32 {
150    240
151}
152
153fn default_endpoint_account_limit_per_minute() -> u32 {
154    180
155}
156
157fn default_endpoint_market_data_limit_per_minute() -> u32 {
158    360
159}
160
161/// Parse a Binance kline interval string (e.g. "1s", "1m", "1h", "1d", "1w", "1M") into milliseconds.
162pub fn parse_interval_ms(s: &str) -> Result<u64> {
163    if s.len() < 2 {
164        bail!("invalid interval '{}': expected format like '1m'", s);
165    }
166
167    let (num_str, suffix) = s.split_at(s.len() - 1);
168    let n: u64 = num_str.parse().with_context(|| {
169        format!(
170            "invalid interval '{}': quantity must be a positive integer",
171            s
172        )
173    })?;
174    if n == 0 {
175        bail!("invalid interval '{}': quantity must be > 0", s);
176    }
177
178    let unit_ms = match suffix {
179        "s" => 1_000,
180        "m" => 60_000,
181        "h" => 3_600_000,
182        "d" => 86_400_000,
183        "w" => 7 * 86_400_000,
184        "M" => 30 * 86_400_000,
185        _ => bail!(
186            "invalid interval '{}': unsupported suffix '{}', expected one of s/m/h/d/w/M",
187            s,
188            suffix
189        ),
190    };
191
192    n.checked_mul(unit_ms)
193        .with_context(|| format!("invalid interval '{}': value is too large", s))
194}
195
196impl BinanceConfig {
197    pub fn kline_interval_ms(&self) -> Result<u64> {
198        parse_interval_ms(&self.kline_interval)
199    }
200
201    pub fn tradable_symbols(&self) -> Vec<String> {
202        let mut out = Vec::new();
203        if !self.symbol.trim().is_empty() {
204            out.push(self.symbol.trim().to_ascii_uppercase());
205        }
206        for sym in &self.symbols {
207            let s = sym.trim().to_ascii_uppercase();
208            if !s.is_empty() && !out.iter().any(|v| v == &s) {
209                out.push(s);
210            }
211        }
212        out
213    }
214
215    pub fn tradable_instruments(&self) -> Vec<String> {
216        let mut out = self.tradable_symbols();
217        for sym in &self.futures_symbols {
218            let s = sym.trim().to_ascii_uppercase();
219            if !s.is_empty() {
220                let label = format!("{} (FUT)", s);
221                if !out.iter().any(|v| v == &label) {
222                    out.push(label);
223                }
224            }
225        }
226        out
227    }
228}
229
230impl Config {
231    pub fn load() -> Result<Self> {
232        dotenvy::dotenv().ok();
233
234        let config_path = Path::new("config/default.toml");
235        let config_str = std::fs::read_to_string(config_path)
236            .with_context(|| format!("failed to read {}", config_path.display()))?;
237
238        let mut config: Config =
239            toml::from_str(&config_str).context("failed to parse config/default.toml")?;
240
241        config.binance.api_key = std::env::var("BINANCE_API_KEY")
242            .context("BINANCE_API_KEY not set in .env or environment")?;
243        config.binance.api_secret = std::env::var("BINANCE_API_SECRET")
244            .context("BINANCE_API_SECRET not set in .env or environment")?;
245        config.binance.futures_api_key = std::env::var("BINANCE_FUTURES_API_KEY")
246            .unwrap_or_else(|_| config.binance.api_key.clone());
247        config.binance.futures_api_secret = std::env::var("BINANCE_FUTURES_API_SECRET")
248            .unwrap_or_else(|_| config.binance.api_secret.clone());
249
250        config
251            .binance
252            .kline_interval_ms()
253            .context("binance.kline_interval is invalid")?;
254
255        Ok(config)
256    }
257}