Skip to main content

sandbox_quant/command/
operator.rs

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(&current_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            &current_upper,
414            instruments,
415            priced_instruments,
416        ),
417        "option-order" => complete_option_order_input(
418            parts.as_slice(),
419            trailing_space,
420            current,
421            &current_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(&current_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}