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(¤t_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}