1use crate::app::bootstrap::BinanceMode;
2use crate::app::commands::{AppCommand, PortfolioView};
3use crate::domain::exposure::Exposure;
4use crate::domain::instrument::Instrument;
5use crate::domain::order_type::OrderType;
6use crate::domain::position::Side;
7use crate::execution::command::{CommandSource, ExecutionCommand};
8use crate::strategy::command::{StrategyCommand, StrategyStartConfig};
9use crate::strategy::model::StrategyTemplate;
10use crate::terminal::completion::ShellCompletion;
11
12#[derive(Debug, Clone, PartialEq)]
13pub enum ShellInput {
14 Empty,
15 Help,
16 Exit,
17 Mode(BinanceMode),
18 Command(AppCommand),
19}
20
21pub fn parse_app_command(args: &[String]) -> Result<AppCommand, String> {
22 match args.first().map(String::as_str).unwrap_or("refresh") {
23 "refresh" | "portfolio" => Ok(parse_portfolio_command(args)),
24 "positions" => Ok(AppCommand::Portfolio(PortfolioView::Positions)),
25 "balances" => Ok(AppCommand::Portfolio(PortfolioView::Balances)),
26 "orders" => Ok(AppCommand::Portfolio(PortfolioView::Orders)),
27 "close-all" => Ok(AppCommand::Execution(ExecutionCommand::CloseAll {
28 source: CommandSource::User,
29 })),
30 "close-symbol" => {
31 let instrument = args
32 .get(1)
33 .ok_or("usage: close-symbol <instrument>")?
34 .clone();
35 Ok(AppCommand::Execution(ExecutionCommand::CloseSymbol {
36 instrument: Instrument::new(normalize_instrument_symbol(&instrument)),
37 source: CommandSource::User,
38 }))
39 }
40 "set-target-exposure" => {
41 let instrument = args
42 .get(1)
43 .ok_or("usage: set-target-exposure <instrument> <target> [market|limit <price>]")?
44 .clone();
45 let raw_target = args
46 .get(2)
47 .ok_or("usage: set-target-exposure <instrument> <target> [market|limit <price>]")?;
48 let target = raw_target
49 .parse::<f64>()
50 .map_err(|_| format!("invalid target exposure: {raw_target}"))?;
51 let exposure = Exposure::new(target).ok_or(format!(
52 "target exposure out of range: {target}. expected -1.0..=1.0"
53 ))?;
54 let order_type = match args.get(3).map(String::as_str) {
55 None | Some("market") => OrderType::Market,
56 Some("limit") => {
57 let raw_price = args
58 .get(4)
59 .ok_or("usage: set-target-exposure <instrument> <target> limit <price>")?;
60 let price = raw_price
61 .parse::<f64>()
62 .map_err(|_| format!("invalid limit price: {raw_price}"))?;
63 if price <= f64::EPSILON {
64 return Err(format!("invalid limit price: {raw_price}"));
65 }
66 OrderType::Limit { price }
67 }
68 Some(other) => {
69 return Err(format!(
70 "unsupported order type: {other}. expected market or limit"
71 ))
72 }
73 };
74 Ok(AppCommand::Execution(
75 ExecutionCommand::SetTargetExposure {
76 instrument: Instrument::new(normalize_instrument_symbol(&instrument)),
77 target: exposure,
78 order_type,
79 source: CommandSource::User,
80 },
81 ))
82 }
83 "option-order" => {
84 let symbol = args
85 .get(1)
86 .ok_or("usage: option-order <symbol> <buy|sell> <qty> <limit_price>")?
87 .clone();
88 let side = match args.get(2).map(String::as_str) {
89 Some("buy") => Side::Buy,
90 Some("sell") => Side::Sell,
91 Some(other) => {
92 return Err(format!(
93 "unsupported option side: {other}. expected buy or sell"
94 ))
95 }
96 None => return Err("usage: option-order <symbol> <buy|sell> <qty> <limit_price>".to_string()),
97 };
98 let raw_qty = args
99 .get(3)
100 .ok_or("usage: option-order <symbol> <buy|sell> <qty> <limit_price>")?;
101 let qty = raw_qty
102 .parse::<f64>()
103 .map_err(|_| format!("invalid option quantity: {raw_qty}"))?;
104 if qty <= f64::EPSILON {
105 return Err(format!("invalid option quantity: {raw_qty}"));
106 }
107 let raw_price = args
108 .get(4)
109 .ok_or("usage: option-order <symbol> <buy|sell> <qty> <limit_price>")?;
110 let price = raw_price
111 .parse::<f64>()
112 .map_err(|_| format!("invalid limit price: {raw_price}"))?;
113 if price <= f64::EPSILON {
114 return Err(format!("invalid limit price: {raw_price}"));
115 }
116 Ok(AppCommand::Execution(ExecutionCommand::SubmitOptionOrder {
117 instrument: Instrument::new(normalize_option_symbol(&symbol)),
118 side,
119 qty,
120 order_type: OrderType::Limit { price },
121 source: CommandSource::User,
122 }))
123 }
124 "strategy" => parse_strategy_command(args),
125 other => Err(format!(
126 "unsupported command: {other}. supported commands: portfolio, positions, balances, orders, close-all, close-symbol, set-target-exposure, option-order, strategy"
127 )),
128 }
129}
130
131fn parse_strategy_command(args: &[String]) -> Result<AppCommand, String> {
132 match args.get(1).map(String::as_str) {
133 Some("templates") => Ok(AppCommand::Strategy(StrategyCommand::Templates)),
134 Some("list") => Ok(AppCommand::Strategy(StrategyCommand::List)),
135 Some("history") => Ok(AppCommand::Strategy(StrategyCommand::History)),
136 Some("show") => {
137 let watch_id = parse_watch_id(args.get(2), "usage: strategy show <watch_id>")?;
138 Ok(AppCommand::Strategy(StrategyCommand::Show { watch_id }))
139 }
140 Some("stop") => {
141 let watch_id = parse_watch_id(args.get(2), "usage: strategy stop <watch_id>")?;
142 Ok(AppCommand::Strategy(StrategyCommand::Stop { watch_id }))
143 }
144 Some("start") => {
145 let template = parse_strategy_template(
146 args.get(2),
147 "usage: strategy start <template> <instrument> --risk-pct <value> --win-rate <value> --r <value> --max-entry-slippage <value>",
148 )?;
149 let instrument = args
150 .get(3)
151 .ok_or("usage: strategy start <template> <instrument> --risk-pct <value> --win-rate <value> --r <value> --max-entry-slippage <value>")?;
152 let config = parse_strategy_start_flags(&args[4..])?;
153 Ok(AppCommand::Strategy(StrategyCommand::Start {
154 template,
155 instrument: Instrument::new(normalize_instrument_symbol(instrument)),
156 config,
157 }))
158 }
159 _ => Err("usage: strategy <templates|start|list|show|stop|history>".to_string()),
160 }
161}
162
163fn parse_watch_id(raw: Option<&String>, usage: &str) -> Result<u64, String> {
164 let raw = raw.ok_or_else(|| usage.to_string())?;
165 raw.parse::<u64>()
166 .map_err(|_| format!("invalid watch id: {raw}"))
167}
168
169fn parse_strategy_template(raw: Option<&String>, usage: &str) -> Result<StrategyTemplate, String> {
170 match raw.map(String::as_str) {
171 Some("liquidation-breakdown-short") => Ok(StrategyTemplate::LiquidationBreakdownShort),
172 Some(other) => Err(format!(
173 "unsupported strategy template: {other}. expected liquidation-breakdown-short"
174 )),
175 None => Err(usage.to_string()),
176 }
177}
178
179fn parse_strategy_start_flags(args: &[String]) -> Result<StrategyStartConfig, String> {
180 let mut risk_pct = 0.005;
181 let mut win_rate = 0.8;
182 let mut r_multiple = 1.5;
183 let mut max_entry_slippage_pct = 0.001;
184 let mut index = 0usize;
185
186 while index < args.len() {
187 let flag = args
188 .get(index)
189 .ok_or("missing strategy flag".to_string())?
190 .as_str();
191 let value = args
192 .get(index + 1)
193 .ok_or_else(|| format!("missing value for {flag}"))?;
194 let parsed = value
195 .parse::<f64>()
196 .map_err(|_| format!("invalid value for {flag}: {value}"))?;
197 match flag {
198 "--risk-pct" => risk_pct = parsed,
199 "--win-rate" => win_rate = parsed,
200 "--r" => r_multiple = parsed,
201 "--max-entry-slippage" => max_entry_slippage_pct = parsed,
202 _ => return Err(format!("unsupported strategy flag: {flag}")),
203 }
204 index += 2;
205 }
206
207 let config = StrategyStartConfig {
208 risk_pct,
209 win_rate,
210 r_multiple,
211 max_entry_slippage_pct,
212 };
213
214 if !(0.0 < config.risk_pct && config.risk_pct <= 1.0) {
215 return Err(format!(
216 "invalid strategy risk_pct: {}. expected 0 < risk_pct <= 1",
217 config.risk_pct
218 ));
219 }
220 if !(0.0..=1.0).contains(&config.win_rate) {
221 return Err(format!(
222 "invalid strategy win_rate: {}. expected 0 <= win_rate <= 1",
223 config.win_rate
224 ));
225 }
226 if config.r_multiple <= f64::EPSILON {
227 return Err(format!(
228 "invalid strategy r_multiple: {}. expected r > 0",
229 config.r_multiple
230 ));
231 }
232 if config.max_entry_slippage_pct <= f64::EPSILON {
233 return Err(format!(
234 "invalid strategy max_entry_slippage_pct: {}. expected slippage > 0",
235 config.max_entry_slippage_pct
236 ));
237 }
238
239 Ok(config)
240}
241
242fn parse_portfolio_command(args: &[String]) -> AppCommand {
243 match args.get(1).map(String::as_str) {
244 None => AppCommand::Portfolio(PortfolioView::Overview),
245 Some("positions") => AppCommand::Portfolio(PortfolioView::Positions),
246 Some("balances") => AppCommand::Portfolio(PortfolioView::Balances),
247 Some("orders") => AppCommand::Portfolio(PortfolioView::Orders),
248 Some("refresh") => AppCommand::Portfolio(PortfolioView::Overview),
249 Some(_) => AppCommand::Portfolio(PortfolioView::Overview),
250 }
251}
252
253pub fn parse_shell_input(line: &str) -> Result<ShellInput, String> {
254 let trimmed = line.trim();
255 if trimmed.is_empty() {
256 return Ok(ShellInput::Empty);
257 }
258
259 let without_prefix = trimmed.strip_prefix('/').unwrap_or(trimmed);
260 match without_prefix {
261 "help" => return Ok(ShellInput::Help),
262 "exit" | "quit" => return Ok(ShellInput::Exit),
263 _ => {}
264 }
265
266 let args: Vec<String> = without_prefix
267 .split_whitespace()
268 .map(str::to_string)
269 .collect();
270 if args.first().map(String::as_str) == Some("mode") {
271 let raw_mode = args.get(1).ok_or("usage: /mode <real|demo>")?;
272 let mode = match raw_mode.as_str() {
273 "real" => BinanceMode::Real,
274 "demo" => BinanceMode::Demo,
275 _ => {
276 return Err(format!(
277 "unsupported mode: {raw_mode}. expected real or demo"
278 ))
279 }
280 };
281 return Ok(ShellInput::Mode(mode));
282 }
283 parse_app_command(&args).map(ShellInput::Command)
284}
285
286pub fn shell_help_text() -> &'static str {
287 "/portfolio [positions|balances|orders]\n/positions\n/balances\n/orders\n/close-all\n/close-symbol <instrument>\n/set-target-exposure <instrument> <target> [market|limit <price>]\n/option-order <symbol> <buy|sell> <qty> <limit_price>\n/strategy <templates|start|list|show|stop|history>\n/mode <real|demo>\n/help\n/exit"
288}
289
290pub fn complete_shell_input(line: &str, instruments: &[String]) -> Vec<String> {
291 complete_shell_input_with_description(line, instruments)
292 .into_iter()
293 .map(|item| item.value)
294 .collect()
295}
296
297pub fn complete_shell_input_with_description(
298 line: &str,
299 instruments: &[String],
300) -> Vec<ShellCompletion> {
301 complete_shell_input_with_market_data(line, instruments, &[])
302}
303
304pub fn complete_shell_input_with_market_data(
305 line: &str,
306 instruments: &[String],
307 priced_instruments: &[(String, f64)],
308) -> Vec<ShellCompletion> {
309 let trimmed = line.trim_start();
310 let without_prefix = trimmed.strip_prefix('/').unwrap_or(trimmed);
311 let trailing_space = without_prefix.ends_with(' ');
312 let parts: Vec<&str> = without_prefix.split_whitespace().collect();
313
314 if parts.is_empty() {
315 return shell_commands()
316 .into_iter()
317 .map(|command| ShellCompletion {
318 value: format!("/{}", command.name),
319 description: command.description.to_string(),
320 })
321 .collect();
322 }
323
324 if parts.len() == 1 && !trailing_space {
325 return shell_commands()
326 .into_iter()
327 .filter(|command| command.name.starts_with(parts[0]))
328 .map(|command| ShellCompletion {
329 value: format!("/{}", command.name),
330 description: command.description.to_string(),
331 })
332 .collect();
333 }
334
335 let command = parts[0];
336 let current = if trailing_space {
337 ""
338 } else {
339 parts.last().copied().unwrap_or_default()
340 };
341 let current_upper = current.trim().to_ascii_uppercase();
342
343 match command {
344 "mode" => ["real", "demo"]
345 .into_iter()
346 .filter(|mode| mode.starts_with(current))
347 .map(|mode| ShellCompletion {
348 value: format!("/mode {mode}"),
349 description: match mode {
350 "real" => "switch to real Binance endpoints",
351 "demo" => "switch to Binance demo endpoints",
352 _ => "",
353 }
354 .to_string(),
355 })
356 .collect(),
357 "portfolio" => ["positions", "balances", "orders"]
358 .into_iter()
359 .filter(|section| section.starts_with(current))
360 .map(|section| ShellCompletion {
361 value: format!("/portfolio {section}"),
362 description: match section {
363 "positions" => "show non-flat positions after refresh",
364 "balances" => "show visible balances after refresh",
365 "orders" => "show open orders after refresh",
366 _ => "",
367 }
368 .to_string(),
369 })
370 .collect(),
371 "close-symbol" => {
372 let normalized_prefix = fallback_base_symbol(current)
373 .as_deref()
374 .map(normalize_instrument_symbol);
375 let mut known_matches: Vec<String> = instruments
376 .iter()
377 .filter(|instrument| {
378 current_upper.is_empty()
379 || instrument.starts_with(¤t_upper)
380 || normalized_prefix
381 .as_ref()
382 .is_some_and(|prefix| instrument.starts_with(prefix))
383 })
384 .cloned()
385 .collect();
386
387 if known_matches.is_empty() {
388 known_matches.extend(fallback_instrument_suggestions(current));
389 }
390
391 known_matches
392 .into_iter()
393 .map(|instrument| ShellCompletion {
394 value: format!("/{command} {instrument}"),
395 description: match command {
396 "close-symbol" => "submit a close order for this instrument",
397 "set-target-exposure" => "plan and submit toward target exposure",
398 _ => "",
399 }
400 .to_string(),
401 })
402 .fold(Vec::<ShellCompletion>::new(), |mut acc, item| {
403 if !acc.iter().any(|existing| existing.value == item.value) {
404 acc.push(item);
405 }
406 acc
407 })
408 }
409 "set-target-exposure" => complete_target_exposure_input(
410 parts.as_slice(),
411 trailing_space,
412 current,
413 ¤t_upper,
414 instruments,
415 priced_instruments,
416 ),
417 "option-order" => complete_option_order_input(
418 parts.as_slice(),
419 trailing_space,
420 current,
421 ¤t_upper,
422 instruments,
423 ),
424 "strategy" => {
425 complete_strategy_input(parts.as_slice(), trailing_space, current, instruments)
426 }
427 _ => Vec::new(),
428 }
429}
430
431fn complete_strategy_input(
432 parts: &[&str],
433 trailing_space: bool,
434 current: &str,
435 instruments: &[String],
436) -> Vec<ShellCompletion> {
437 let arg_index = if trailing_space {
438 parts.len()
439 } else {
440 parts.len().saturating_sub(1)
441 };
442
443 match arg_index {
444 1 => ["templates", "start", "list", "show", "stop", "history"]
445 .into_iter()
446 .filter(|item| item.starts_with(current))
447 .map(|item| ShellCompletion {
448 value: format!("/strategy {item}"),
449 description: match item {
450 "templates" => "show available strategy templates",
451 "start" => "arm a strategy watch",
452 "list" => "show active strategy watches",
453 "show" => "show one strategy watch",
454 "stop" => "stop one active strategy watch",
455 "history" => "show finished strategy watches",
456 _ => "",
457 }
458 .to_string(),
459 })
460 .collect(),
461 2 if parts.first().copied() == Some("strategy")
462 && parts.get(1).copied() == Some("start") =>
463 {
464 StrategyTemplate::all()
465 .into_iter()
466 .filter(|template| template.slug().starts_with(current))
467 .map(|template| ShellCompletion {
468 value: format!("/strategy start {}", template.slug()),
469 description: "event-driven one-shot short strategy".to_string(),
470 })
471 .collect()
472 }
473 3 if parts.first().copied() == Some("strategy")
474 && parts.get(1).copied() == Some("start") =>
475 {
476 complete_strategy_start_instrument(parts, current, instruments)
477 }
478 4 if parts.first().copied() == Some("strategy")
479 && parts.get(1).copied() == Some("start") =>
480 {
481 vec![ShellCompletion {
482 value: format!(
483 "/strategy start {} {} --risk-pct 0.005 --win-rate 0.8 --r 1.5 --max-entry-slippage 0.001",
484 parts.get(2).copied().unwrap_or("liquidation-breakdown-short"),
485 normalize_instrument_symbol(parts.get(3).copied().unwrap_or("BTC")),
486 ),
487 description: "start a liquidation breakdown short watch".to_string(),
488 }]
489 }
490 _ => Vec::new(),
491 }
492}
493
494fn complete_strategy_start_instrument(
495 parts: &[&str],
496 current: &str,
497 instruments: &[String],
498) -> Vec<ShellCompletion> {
499 let current_upper = current.trim().to_ascii_uppercase();
500 let normalized_prefix = fallback_base_symbol(current)
501 .as_deref()
502 .map(normalize_instrument_symbol);
503 let mut known_matches: Vec<String> = instruments
504 .iter()
505 .filter(|instrument| {
506 current_upper.is_empty()
507 || instrument.starts_with(¤t_upper)
508 || normalized_prefix
509 .as_ref()
510 .is_some_and(|prefix| instrument.starts_with(prefix))
511 })
512 .cloned()
513 .collect();
514
515 if known_matches.is_empty() {
516 known_matches.extend(fallback_instrument_suggestions(current));
517 }
518
519 known_matches
520 .into_iter()
521 .map(|instrument| ShellCompletion {
522 value: format!(
523 "/strategy start {} {}",
524 parts
525 .get(2)
526 .copied()
527 .unwrap_or("liquidation-breakdown-short"),
528 instrument
529 ),
530 description: "choose a futures instrument".to_string(),
531 })
532 .collect()
533}
534
535pub fn normalize_instrument_symbol(raw: &str) -> String {
536 let upper = raw.trim().to_ascii_uppercase();
537 if looks_like_option_symbol(&upper) {
538 return upper;
539 }
540 let known_quotes = ["USDT", "USDC", "BUSD", "FDUSD"];
541 if known_quotes.iter().any(|quote| upper.ends_with(quote)) {
542 upper
543 } else {
544 format!("{upper}USDT")
545 }
546}
547
548pub fn normalize_option_symbol(raw: &str) -> String {
549 raw.trim().to_ascii_uppercase()
550}
551
552fn fallback_instrument_suggestions(prefix: &str) -> impl Iterator<Item = String> {
553 let Some(base) = fallback_base_symbol(prefix) else {
554 return Vec::new().into_iter();
555 };
556 let mut suggestions = Vec::new();
557 suggestions.push(normalize_instrument_symbol(&base));
558 suggestions.push(format!("{base}USDC"));
559 suggestions.into_iter()
560}
561
562fn fallback_base_symbol(prefix: &str) -> Option<String> {
563 let base = prefix.trim().to_ascii_uppercase();
564 let known_quotes = ["USDT", "USDC", "BUSD", "FDUSD"];
565 if base.is_empty()
566 || base.len() > 12
567 || !base.chars().all(|ch| ch.is_ascii_alphanumeric())
568 || known_quotes.iter().any(|quote| base.contains(quote))
569 {
570 None
571 } else {
572 Some(base)
573 }
574}
575
576fn complete_target_exposure_input(
577 parts: &[&str],
578 trailing_space: bool,
579 current: &str,
580 current_upper: &str,
581 instruments: &[String],
582 priced_instruments: &[(String, f64)],
583) -> Vec<ShellCompletion> {
584 let arg_index = if trailing_space {
585 parts.len()
586 } else {
587 parts.len().saturating_sub(1)
588 };
589
590 match arg_index {
591 1 => {
592 complete_instrument_argument("set-target-exposure", current, current_upper, instruments)
593 }
594 2 => target_exposure_suggestions(parts.get(1).copied().unwrap_or(""), current),
595 3 => order_type_suggestions(
596 parts.get(1).copied().unwrap_or(""),
597 parts.get(2).copied().unwrap_or(""),
598 current,
599 ),
600 4 => limit_price_suggestions(
601 parts.get(1).copied().unwrap_or(""),
602 parts.get(2).copied().unwrap_or(""),
603 parts.get(3).copied().unwrap_or(""),
604 current,
605 priced_instruments,
606 ),
607 _ => Vec::new(),
608 }
609}
610
611fn complete_instrument_argument(
612 command: &str,
613 current: &str,
614 current_upper: &str,
615 instruments: &[String],
616) -> Vec<ShellCompletion> {
617 let normalized_prefix = fallback_base_symbol(current)
618 .as_deref()
619 .map(normalize_instrument_symbol);
620 let mut known_matches: Vec<String> = instruments
621 .iter()
622 .filter(|instrument| {
623 current_upper.is_empty()
624 || instrument.starts_with(current_upper)
625 || normalized_prefix
626 .as_ref()
627 .is_some_and(|prefix| instrument.starts_with(prefix))
628 })
629 .cloned()
630 .collect();
631
632 if known_matches.is_empty() {
633 known_matches.extend(fallback_instrument_suggestions(current));
634 }
635
636 known_matches
637 .into_iter()
638 .map(|instrument| ShellCompletion {
639 value: format!("/{command} {instrument}"),
640 description: "plan and submit toward target exposure".to_string(),
641 })
642 .fold(Vec::<ShellCompletion>::new(), |mut acc, item| {
643 if !acc.iter().any(|existing| existing.value == item.value) {
644 acc.push(item);
645 }
646 acc
647 })
648}
649
650fn complete_option_order_input(
651 parts: &[&str],
652 trailing_space: bool,
653 current: &str,
654 current_upper: &str,
655 instruments: &[String],
656) -> Vec<ShellCompletion> {
657 let arg_index = if trailing_space {
658 parts.len()
659 } else {
660 parts.len().saturating_sub(1)
661 };
662
663 match arg_index {
664 1 => complete_option_symbol_argument(current, current_upper, instruments),
665 2 => ["buy", "sell"]
666 .into_iter()
667 .filter(|side| side.starts_with(current))
668 .map(|side| ShellCompletion {
669 value: format!(
670 "/option-order {} {side}",
671 parts.get(1).copied().unwrap_or("BTC-260327-200000-C")
672 ),
673 description: "choose option order side".to_string(),
674 })
675 .collect(),
676 3 => ["0.01", "0.10", "1.00"]
677 .into_iter()
678 .filter(|qty| qty.starts_with(current))
679 .map(|qty| ShellCompletion {
680 value: format!(
681 "/option-order {} {} {qty}",
682 parts.get(1).copied().unwrap_or("BTC-260327-200000-C"),
683 parts.get(2).copied().unwrap_or("buy"),
684 ),
685 description: "order quantity".to_string(),
686 })
687 .collect(),
688 4 => ["5", "50", "500"]
689 .into_iter()
690 .filter(|price| price.starts_with(current))
691 .map(|price| ShellCompletion {
692 value: format!(
693 "/option-order {} {} {} {price}",
694 parts.get(1).copied().unwrap_or("BTC-260327-200000-C"),
695 parts.get(2).copied().unwrap_or("buy"),
696 parts.get(3).copied().unwrap_or("0.01"),
697 ),
698 description: "limit price".to_string(),
699 })
700 .collect(),
701 _ => Vec::new(),
702 }
703}
704
705fn complete_option_symbol_argument(
706 current: &str,
707 current_upper: &str,
708 instruments: &[String],
709) -> Vec<ShellCompletion> {
710 let option_symbols = instruments
711 .iter()
712 .filter(|instrument| looks_like_option_symbol(instrument))
713 .cloned()
714 .collect::<Vec<_>>();
715
716 if current_upper.is_empty() {
717 return option_underlying_prefixes(&option_symbols)
718 .into_iter()
719 .map(|prefix| ShellCompletion {
720 value: format!("/option-order {prefix}"),
721 description: "type expiry/strike to narrow option contracts".to_string(),
722 })
723 .collect();
724 }
725
726 let direct_matches = option_symbols
727 .iter()
728 .filter(|instrument| instrument.starts_with(current_upper))
729 .cloned()
730 .collect::<Vec<_>>();
731 if !direct_matches.is_empty() {
732 return direct_matches
733 .into_iter()
734 .map(|instrument| ShellCompletion {
735 value: format!("/option-order {instrument}"),
736 description: "submit a Binance options limit order".to_string(),
737 })
738 .collect();
739 }
740
741 option_underlying_prefixes(&option_symbols)
742 .into_iter()
743 .filter(|prefix| prefix.starts_with(&format!("{}-", current.trim().to_ascii_uppercase())))
744 .map(|prefix| ShellCompletion {
745 value: format!("/option-order {prefix}"),
746 description: "type expiry/strike to narrow option contracts".to_string(),
747 })
748 .collect()
749}
750
751fn option_underlying_prefixes(option_symbols: &[String]) -> Vec<String> {
752 option_symbols
753 .iter()
754 .filter_map(|symbol| symbol.split('-').next().map(|base| format!("{base}-")))
755 .fold(Vec::<String>::new(), |mut acc, item| {
756 if !acc.iter().any(|existing| existing == &item) {
757 acc.push(item);
758 }
759 acc
760 })
761}
762
763fn looks_like_option_symbol(raw: &str) -> bool {
764 let mut parts = raw.split('-');
765 let Some(base) = parts.next() else {
766 return false;
767 };
768 let Some(expiry) = parts.next() else {
769 return false;
770 };
771 let Some(strike) = parts.next() else {
772 return false;
773 };
774 let Some(kind) = parts.next() else {
775 return false;
776 };
777 parts.next().is_none()
778 && !base.is_empty()
779 && expiry.len() == 6
780 && expiry.chars().all(|ch| ch.is_ascii_digit())
781 && strike.chars().all(|ch| ch.is_ascii_digit())
782 && matches!(kind, "C" | "P")
783}
784
785fn target_exposure_suggestions(instrument: &str, current: &str) -> Vec<ShellCompletion> {
786 let prefix = current.trim();
787 ["0.25", "0.5", "-0.25", "-0.5", "1.0", "-1.0"]
788 .into_iter()
789 .filter(|target| target.starts_with(prefix))
790 .map(|target| ShellCompletion {
791 value: format!("/set-target-exposure {instrument} {target}"),
792 description: "target signed exposure".to_string(),
793 })
794 .collect()
795}
796
797fn order_type_suggestions(instrument: &str, target: &str, current: &str) -> Vec<ShellCompletion> {
798 ["market", "limit"]
799 .into_iter()
800 .filter(|order_type| order_type.starts_with(current))
801 .map(|order_type| ShellCompletion {
802 value: format!("/set-target-exposure {instrument} {target} {order_type}"),
803 description: match order_type {
804 "market" => "submit immediately at market",
805 "limit" => "submit at an explicit limit price",
806 _ => "",
807 }
808 .to_string(),
809 })
810 .collect()
811}
812
813fn limit_price_suggestions(
814 instrument: &str,
815 target: &str,
816 order_type: &str,
817 current: &str,
818 priced_instruments: &[(String, f64)],
819) -> Vec<ShellCompletion> {
820 if order_type != "limit" {
821 return Vec::new();
822 }
823
824 let suggestions = if current.trim().is_empty() {
825 price_examples(instrument, priced_instruments)
826 } else {
827 vec![current.to_string()]
828 };
829
830 suggestions
831 .into_iter()
832 .map(|price| ShellCompletion {
833 value: format!("/set-target-exposure {instrument} {target} limit {price}"),
834 description: "limit price example; replace with desired price".to_string(),
835 })
836 .collect()
837}
838
839fn price_examples(instrument: &str, priced_instruments: &[(String, f64)]) -> Vec<String> {
840 let maybe_price = priced_instruments
841 .iter()
842 .find(|(known, _)| known == instrument)
843 .map(|(_, price)| *price);
844
845 match maybe_price {
846 Some(price) if price > f64::EPSILON => {
847 let ticked = if price >= 1000.0 {
848 10.0
849 } else if price >= 100.0 {
850 1.0
851 } else if price >= 1.0 {
852 0.1
853 } else {
854 0.01
855 };
856 let below = (price * 0.995 / ticked).floor() * ticked;
857 let near = (price / ticked).round() * ticked;
858 let above = (price * 1.005 / ticked).ceil() * ticked;
859 vec![
860 format!("{below:.2}"),
861 format!("{near:.2}"),
862 format!("{above:.2}"),
863 ]
864 }
865 _ => vec!["1000".to_string(), "50000".to_string(), "68000".to_string()],
866 }
867}
868
869struct ShellCommandSpec {
870 name: &'static str,
871 description: &'static str,
872}
873
874fn shell_commands() -> [ShellCommandSpec; 12] {
875 [
876 ShellCommandSpec {
877 name: "portfolio",
878 description: "refresh and show portfolio overview",
879 },
880 ShellCommandSpec {
881 name: "positions",
882 description: "refresh and show non-flat positions",
883 },
884 ShellCommandSpec {
885 name: "balances",
886 description: "refresh and show visible balances",
887 },
888 ShellCommandSpec {
889 name: "orders",
890 description: "refresh and show open orders",
891 },
892 ShellCommandSpec {
893 name: "close-all",
894 description: "submit close orders for all currently open instruments",
895 },
896 ShellCommandSpec {
897 name: "close-symbol",
898 description: "submit a close order for one instrument",
899 },
900 ShellCommandSpec {
901 name: "set-target-exposure",
902 description: "plan and submit toward a signed target exposure",
903 },
904 ShellCommandSpec {
905 name: "option-order",
906 description: "submit a Binance options limit order",
907 },
908 ShellCommandSpec {
909 name: "strategy",
910 description: "manage event-driven strategy watches",
911 },
912 ShellCommandSpec {
913 name: "mode",
914 description: "switch between real and demo Binance endpoints",
915 },
916 ShellCommandSpec {
917 name: "help",
918 description: "show available slash commands",
919 },
920 ShellCommandSpec {
921 name: "exit",
922 description: "leave the interactive shell",
923 },
924 ]
925}