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_help(), 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    #[arg(long, value_name = "PCT", value_parser = positive_pct, help = with_default("Price step between levels of a synthetic AMM book (DEX venues), in percent.", health::DEFAULT_AMM_STEP_PCT))]
46    pub amm_step_pct: Option<f64>,
47
48    #[arg(long, value_name = "PCT", value_parser = positive_pct, help = with_default("Half-width of the depth window for the depth/volume rule, in percent of the mid price.", health::DEFAULT_DEPTH_WINDOW_PCT))]
49    pub depth_window_pct: Option<f64>,
50
51    #[arg(long, value_name = "PCT", value_parser = positive_pct, help = with_default("Depth/volume floor: depth within the window below this percent of 24h volume fails.", health::DEFAULT_MIN_DEPTH_VOLUME_PCT))]
52    pub min_depth_volume_pct: Option<f64>,
53
54    #[arg(long, value_name = "PCT", value_parser = positive_pct, help = with_default("Depth/volume ceiling: depth within the window above this percent of 24h volume warns.", health::DEFAULT_MAX_DEPTH_VOLUME_PCT))]
55    pub max_depth_volume_pct: Option<f64>,
56}
57
58/// Parses a percentage that must be finite and greater than zero.
59fn positive_pct(s: &str) -> Result<f64, String> {
60    let v: f64 = s.parse().map_err(|e| format!("{e}"))?;
61    if v.is_finite() && v > 0.0 {
62        Ok(v)
63    } else {
64        Err(format!("must be a number greater than 0, got {s}"))
65    }
66}
67
68/// Help for `--peg-range`, with the band width taken from the constants.
69fn peg_range_help() -> String {
70    let band_pct = health::PegBands::new(health::DEFAULT_PEG_TARGET, health::DEFAULT_PEG_RANGE)
71        .outlier_width()
72        / health::DEFAULT_PEG_TARGET
73        * health::PERCENT_PER_RATIO;
74    format!(
75        "Peg range. The outlier band is peg ± range×{factor}: levels outside it are left out of depth and level counts (best prices still include them). E.g., {range} = ±{band_pct}% around a peg of {target}.",
76        factor = health::PEG_OUTLIER_FACTOR,
77        range = health::DEFAULT_PEG_RANGE,
78        target = health::DEFAULT_PEG_TARGET,
79    )
80}
81
82/// Help text with the built-in default, so `--help` names the value in force.
83fn with_default(desc: &str, default: impl std::fmt::Display) -> String {
84    format!("{desc} [default: config `market.health`, else {default}]")
85}
86
87impl HealthArgs {
88    /// Resolves thresholds: these flags over config `market.health`.
89    pub fn resolve(&self, config: &Config) -> HealthThresholds {
90        config.market.health.with_overrides(&self.overrides())
91    }
92
93    /// The flags as overrides; an unset flag is `None`.
94    pub fn overrides(&self) -> HealthOverrides {
95        HealthOverrides {
96            peg_target: self.peg_target,
97            peg_range: self.peg_range,
98            min_levels: self.min_levels,
99            min_depth: self.min_depth,
100            min_bid_ask_ratio: self.min_bid_ask_ratio,
101            max_bid_ask_ratio: self.max_bid_ask_ratio,
102            max_spread_pct: self.max_spread_pct,
103            min_top3_depth: self.min_top3_depth,
104            min_top10_depth: self.min_top10_depth,
105            amm_step_pct: self.amm_step_pct,
106            depth_window_pct: self.depth_window_pct,
107            min_depth_volume_pct: self.min_depth_volume_pct,
108            max_depth_volume_pct: self.max_depth_volume_pct,
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use clap::Parser;
117
118    #[derive(Parser)]
119    struct Cli {
120        #[command(flatten)]
121        health: HealthArgs,
122    }
123
124    fn resolve(argv: &[&str], config: &Config) -> HealthThresholds {
125        let mut full = vec!["test"];
126        full.extend_from_slice(argv);
127        Cli::parse_from(full).health.resolve(config)
128    }
129
130    #[test]
131    fn test_no_flags_no_config_uses_builtin_defaults() {
132        assert_eq!(
133            resolve(&[], &Config::default()),
134            HealthThresholds::default()
135        );
136    }
137
138    #[test]
139    fn test_config_replaces_builtin_default() {
140        let mut config = Config::default();
141        config.market.health.min_levels = 20;
142        let t = resolve(&[], &config);
143        assert_eq!(t.min_levels, 20);
144        // Keys the config does not change keep the built-in default.
145        assert_eq!(t.min_top3_depth, HealthThresholds::default().min_top3_depth);
146    }
147
148    #[test]
149    fn test_flag_wins_over_config() {
150        let mut config = Config::default();
151        config.market.health.max_spread_pct = 5.0;
152        config.market.health.min_levels = 20;
153        let t = resolve(&["--max-spread-pct", "1.5"], &config);
154        assert_eq!(t.max_spread_pct, 1.5);
155        assert_eq!(t.min_levels, 20);
156    }
157
158    #[test]
159    fn test_peg_flag_sets_peg_target() {
160        let t = resolve(&["--peg", "0.98"], &Config::default());
161        assert_eq!(t.peg_target, 0.98);
162    }
163
164    #[test]
165    fn test_amm_step_flag_resolves_and_rejects_non_positive() {
166        #[derive(clap::Parser)]
167        struct W {
168            #[command(flatten)]
169            h: HealthArgs,
170        }
171        use clap::Parser;
172        let w = W::try_parse_from(["x", "--amm-step-pct", "0.05"]).unwrap();
173        assert_eq!(w.h.resolve(&Config::default()).amm_step_pct, 0.05);
174        let w = W::try_parse_from(["x"]).unwrap();
175        assert_eq!(
176            w.h.resolve(&Config::default()).amm_step_pct,
177            health::DEFAULT_AMM_STEP_PCT
178        );
179        for bad in ["0", "-1", "nan", "inf", "abc"] {
180            assert!(
181                W::try_parse_from(["x", "--amm-step-pct", bad]).is_err(),
182                "{}",
183                bad
184            );
185        }
186    }
187
188    #[test]
189    fn test_depth_volume_flags_override_config() {
190        let mut config = Config::default();
191        config.market.health.min_depth_volume_pct = 3.0;
192        let t = resolve(
193            &["--max-depth-volume-pct", "25", "--depth-window-pct", "1"],
194            &config,
195        );
196        assert_eq!(t.min_depth_volume_pct, 3.0);
197        assert_eq!(t.max_depth_volume_pct, 25.0);
198        assert_eq!(t.depth_window_pct, 1.0);
199    }
200}