Skip to main content

sandbox_quant/app/
cli.rs

1use crate::app::commands::AppCommand;
2use crate::app::bootstrap::BinanceMode;
3use crate::domain::exposure::Exposure;
4use crate::domain::instrument::Instrument;
5use crate::execution::command::{CommandSource, ExecutionCommand};
6
7#[derive(Debug, Clone, PartialEq)]
8pub enum ShellInput {
9    Empty,
10    Help,
11    Exit,
12    Mode(BinanceMode),
13    Command(AppCommand),
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ShellCompletion {
18    pub value: String,
19    pub description: String,
20}
21
22pub fn parse_app_command(args: &[String]) -> Result<AppCommand, String> {
23    match args.first().map(String::as_str).unwrap_or("refresh") {
24        "refresh" => Ok(AppCommand::RefreshAuthoritativeState),
25        "close-all" => Ok(AppCommand::Execution(ExecutionCommand::CloseAll {
26            source: CommandSource::User,
27        })),
28        "close-symbol" => {
29            let instrument = args
30                .get(1)
31                .ok_or("usage: close-symbol <instrument>")?
32                .clone();
33            Ok(AppCommand::Execution(ExecutionCommand::CloseSymbol {
34                instrument: Instrument::new(normalize_instrument_symbol(&instrument)),
35                source: CommandSource::User,
36            }))
37        }
38        "set-target-exposure" => {
39            let instrument = args
40                .get(1)
41                .ok_or("usage: set-target-exposure <instrument> <target>")?
42                .clone();
43            let raw_target = args
44                .get(2)
45                .ok_or("usage: set-target-exposure <instrument> <target>")?;
46            let target = raw_target
47                .parse::<f64>()
48                .map_err(|_| format!("invalid target exposure: {raw_target}"))?;
49            let exposure = Exposure::new(target).ok_or(format!(
50                "target exposure out of range: {target}. expected -1.0..=1.0"
51            ))?;
52            Ok(AppCommand::Execution(
53                ExecutionCommand::SetTargetExposure {
54                    instrument: Instrument::new(normalize_instrument_symbol(&instrument)),
55                    target: exposure,
56                    source: CommandSource::User,
57                },
58            ))
59        }
60        other => Err(format!(
61            "unsupported command: {other}. supported commands: refresh, close-all, close-symbol, set-target-exposure"
62        )),
63    }
64}
65
66pub fn parse_shell_input(line: &str) -> Result<ShellInput, String> {
67    let trimmed = line.trim();
68    if trimmed.is_empty() {
69        return Ok(ShellInput::Empty);
70    }
71
72    let without_prefix = trimmed.strip_prefix('/').unwrap_or(trimmed);
73    match without_prefix {
74        "help" => return Ok(ShellInput::Help),
75        "exit" | "quit" => return Ok(ShellInput::Exit),
76        _ => {}
77    }
78
79    let args: Vec<String> = without_prefix
80        .split_whitespace()
81        .map(str::to_string)
82        .collect();
83    if args.first().map(String::as_str) == Some("mode") {
84        let raw_mode = args.get(1).ok_or("usage: /mode <real|demo>")?;
85        let mode = match raw_mode.as_str() {
86            "real" => BinanceMode::Real,
87            "demo" => BinanceMode::Demo,
88            _ => return Err(format!("unsupported mode: {raw_mode}. expected real or demo")),
89        };
90        return Ok(ShellInput::Mode(mode));
91    }
92    parse_app_command(&args).map(ShellInput::Command)
93}
94
95pub fn shell_help_text() -> &'static str {
96    "/refresh\n/close-all\n/close-symbol <instrument>\n/set-target-exposure <instrument> <target>\n/mode <real|demo>\n/help\n/exit"
97}
98
99pub fn complete_shell_input(line: &str, instruments: &[String]) -> Vec<String> {
100    complete_shell_input_with_description(line, instruments)
101        .into_iter()
102        .map(|item| item.value)
103        .collect()
104}
105
106pub fn complete_shell_input_with_description(
107    line: &str,
108    instruments: &[String],
109) -> Vec<ShellCompletion> {
110    let trimmed = line.trim_start();
111    let without_prefix = trimmed.strip_prefix('/').unwrap_or(trimmed);
112    let trailing_space = without_prefix.ends_with(' ');
113    let parts: Vec<&str> = without_prefix.split_whitespace().collect();
114
115    if parts.is_empty() {
116        return shell_commands()
117            .into_iter()
118            .map(|command| ShellCompletion {
119                value: format!("/{}", command.name),
120                description: command.description.to_string(),
121            })
122            .collect();
123    }
124
125    if parts.len() == 1 && !trailing_space {
126        return shell_commands()
127            .into_iter()
128            .filter(|command| command.name.starts_with(parts[0]))
129            .map(|command| ShellCompletion {
130                value: format!("/{}", command.name),
131                description: command.description.to_string(),
132            })
133            .collect();
134    }
135
136    let command = parts[0];
137    let current = if trailing_space {
138        ""
139    } else {
140        parts.last().copied().unwrap_or_default()
141    };
142    let current_upper = current.trim().to_ascii_uppercase();
143
144    match command {
145        "mode" => ["real", "demo"]
146            .into_iter()
147            .filter(|mode| mode.starts_with(current))
148            .map(|mode| ShellCompletion {
149                value: format!("/mode {mode}"),
150                description: match mode {
151                    "real" => "switch to real Binance endpoints",
152                    "demo" => "switch to Binance demo endpoints",
153                    _ => "",
154                }
155                .to_string(),
156            })
157            .collect(),
158        "close-symbol" | "set-target-exposure" => {
159            let mut known_matches: Vec<String> = instruments
160                .iter()
161                .filter(|instrument| {
162                    current_upper.is_empty()
163                        || instrument.starts_with(&current_upper)
164                        || instrument.starts_with(&normalize_instrument_symbol(current))
165                })
166                .cloned()
167                .collect();
168
169            if known_matches.is_empty() {
170                known_matches.extend(fallback_instrument_suggestions(current));
171            }
172
173            known_matches
174                .into_iter()
175                .map(|instrument| ShellCompletion {
176                    value: format!("/{command} {instrument}"),
177                    description: match command {
178                        "close-symbol" => "submit a close order for this instrument",
179                        "set-target-exposure" => "plan and submit toward target exposure",
180                        _ => "",
181                    }
182                    .to_string(),
183                })
184                .fold(Vec::<ShellCompletion>::new(), |mut acc, item| {
185                    if !acc.iter().any(|existing| existing.value == item.value) {
186                        acc.push(item);
187                    }
188                    acc
189                })
190        }
191        _ => Vec::new(),
192    }
193}
194
195pub fn normalize_instrument_symbol(raw: &str) -> String {
196    let upper = raw.trim().to_ascii_uppercase();
197    let known_quotes = ["USDT", "USDC", "BUSD", "FDUSD"];
198    if known_quotes.iter().any(|quote| upper.ends_with(quote)) {
199        upper
200    } else {
201        format!("{upper}USDT")
202    }
203}
204
205fn fallback_instrument_suggestions(prefix: &str) -> impl Iterator<Item = String> {
206    let base = prefix.trim().to_ascii_uppercase();
207    let mut suggestions = Vec::new();
208    if !base.is_empty() {
209        suggestions.push(normalize_instrument_symbol(&base));
210        suggestions.push(format!("{base}USDC"));
211    }
212    suggestions.into_iter()
213}
214
215struct ShellCommandSpec {
216    name: &'static str,
217    description: &'static str,
218}
219
220fn shell_commands() -> [ShellCommandSpec; 7] {
221    [
222        ShellCommandSpec {
223            name: "refresh",
224            description: "refresh authoritative account, position, and order state",
225        },
226        ShellCommandSpec {
227            name: "close-all",
228            description: "submit close orders for all currently open instruments",
229        },
230        ShellCommandSpec {
231            name: "close-symbol",
232            description: "submit a close order for one instrument",
233        },
234        ShellCommandSpec {
235            name: "set-target-exposure",
236            description: "plan and submit toward a signed target exposure",
237        },
238        ShellCommandSpec {
239            name: "mode",
240            description: "switch between real and demo Binance endpoints",
241        },
242        ShellCommandSpec {
243            name: "help",
244            description: "show available slash commands",
245        },
246        ShellCommandSpec {
247            name: "exit",
248            description: "leave the interactive shell",
249        },
250    ]
251}