Skip to main content

scope_cli/cli/
health_args.rs

1//! Shared order book health threshold flags.
2//!
3//! Every command that runs order book health checks flattens [`HealthArgs`],
4//! so all of them accept the same overrides. Resolution order:
5//! flag > config file `market.health` > built-in default.
6
7use clap::Args;
8use scope::config::Config;
9use scope::market::{HealthOverrides, HealthThresholds, health};
10
11/// Order book health threshold overrides.
12///
13/// A flag left unset keeps the value from config `market.health`, or the
14/// built-in default when the config file does not set it.
15#[derive(Debug, Clone, Default, Args)]
16#[command(next_help_heading = "Health thresholds")]
17pub struct HealthArgs {
18    #[arg(long = "peg", value_name = "TARGET", help = with_default("Peg target (e.g., 1.0 for USD stablecoins).", health::DEFAULT_PEG_TARGET))]
19    pub peg_target: Option<f64>,
20
21    #[arg(long, value_name = "RANGE", help = with_default("Peg range for outlier filtering (orders outside peg ± range×5 excluded). E.g., 0.001 = ±0.5% around peg.", health::DEFAULT_PEG_RANGE))]
22    pub peg_range: Option<f64>,
23
24    #[arg(long, value_name = "N", help = with_default("Minimum valid order book levels (price > 0, quantity > 0) per side.", health::DEFAULT_MIN_LEVELS))]
25    pub min_levels: Option<usize>,
26
27    #[arg(long, value_name = "USDT", help = with_default("Minimum in-band depth per side in quote terms, e.g. USDT.", health::DEFAULT_MIN_DEPTH))]
28    pub min_depth: Option<f64>,
29
30    #[arg(long, value_name = "RATIO", help = with_default("Min bid/ask depth ratio (warn if ratio below this).", health::DEFAULT_MIN_BID_ASK_RATIO))]
31    pub min_bid_ask_ratio: Option<f64>,
32
33    #[arg(long, value_name = "RATIO", help = with_default("Max bid/ask depth ratio (warn if ratio above this).", health::DEFAULT_MAX_BID_ASK_RATIO))]
34    pub max_bid_ask_ratio: Option<f64>,
35
36    #[arg(long, value_name = "PCT", help = with_default("Max spread between best bid and best ask, in percent of mid price.", health::DEFAULT_MAX_SPREAD_PCT))]
37    pub max_spread_pct: Option<f64>,
38
39    #[arg(long, value_name = "USDT", help = with_default("Min depth of the top 3 valid levels per side, in quote terms.", health::DEFAULT_MIN_TOP3_DEPTH))]
40    pub min_top3_depth: Option<f64>,
41
42    #[arg(long, value_name = "USDT", help = with_default("Min depth of the top 10 valid levels per side, in quote terms.", health::DEFAULT_MIN_TOP10_DEPTH))]
43    pub min_top10_depth: Option<f64>,
44}
45
46/// Help text with the built-in default, so `--help` names the value in force.
47fn with_default(desc: &str, default: impl std::fmt::Display) -> String {
48    format!("{desc} [default: config `market.health`, else {default}]")
49}
50
51impl HealthArgs {
52    /// Resolves thresholds: these flags over config `market.health`.
53    pub fn resolve(&self, config: &Config) -> HealthThresholds {
54        config.market.health.with_overrides(&HealthOverrides {
55            peg_target: self.peg_target,
56            peg_range: self.peg_range,
57            min_levels: self.min_levels,
58            min_depth: self.min_depth,
59            min_bid_ask_ratio: self.min_bid_ask_ratio,
60            max_bid_ask_ratio: self.max_bid_ask_ratio,
61            max_spread_pct: self.max_spread_pct,
62            min_top3_depth: self.min_top3_depth,
63            min_top10_depth: self.min_top10_depth,
64        })
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use clap::Parser;
72
73    #[derive(Parser)]
74    struct Cli {
75        #[command(flatten)]
76        health: HealthArgs,
77    }
78
79    fn resolve(argv: &[&str], config: &Config) -> HealthThresholds {
80        let mut full = vec!["test"];
81        full.extend_from_slice(argv);
82        Cli::parse_from(full).health.resolve(config)
83    }
84
85    #[test]
86    fn test_no_flags_no_config_uses_builtin_defaults() {
87        assert_eq!(
88            resolve(&[], &Config::default()),
89            HealthThresholds::default()
90        );
91    }
92
93    #[test]
94    fn test_config_replaces_builtin_default() {
95        let mut config = Config::default();
96        config.market.health.min_levels = 20;
97        let t = resolve(&[], &config);
98        assert_eq!(t.min_levels, 20);
99        // Keys the config does not change keep the built-in default.
100        assert_eq!(t.min_top3_depth, HealthThresholds::default().min_top3_depth);
101    }
102
103    #[test]
104    fn test_flag_wins_over_config() {
105        let mut config = Config::default();
106        config.market.health.max_spread_pct = 5.0;
107        config.market.health.min_levels = 20;
108        let t = resolve(&["--max-spread-pct", "1.5"], &config);
109        assert_eq!(t.max_spread_pct, 1.5);
110        assert_eq!(t.min_levels, 20);
111    }
112
113    #[test]
114    fn test_peg_flag_sets_peg_target() {
115        let t = resolve(&["--peg", "0.98"], &Config::default());
116        assert_eq!(t.peg_target, 0.98);
117    }
118}