Skip to main content

sandbox_quant/app/
shell.rs

1use crate::app::bootstrap::{AppBootstrap, BinanceMode};
2use crate::app::cli::{
3    complete_shell_input_with_market_data, parse_shell_input, shell_help_text, ShellInput,
4};
5use crate::app::output::render_command_output;
6use crate::app::runtime::AppRuntime;
7use crate::exchange::binance::client::BinanceExchange;
8use crate::terminal::app::{TerminalApp, TerminalEvent};
9use crate::terminal::completion::ShellCompletion;
10pub use crate::terminal::completion::{
11    format_completion_line, next_completion_index, previous_completion_index, scroll_lines_needed,
12};
13use crate::terminal::loop_shell::run_terminal;
14use crate::ui::operator_terminal::{
15    mode_name, operator_prompt, prompt_status_from_store, shell_intro_panel,
16};
17use std::collections::BTreeSet;
18use std::sync::{Mutex, OnceLock};
19use std::time::{Duration, Instant};
20
21pub fn run_shell(
22    app: &mut AppBootstrap<BinanceExchange>,
23    runtime: &mut AppRuntime,
24) -> Result<(), Box<dyn std::error::Error>> {
25    let mut terminal = OperatorTerminal { app, runtime };
26    run_terminal(&mut terminal)
27}
28
29struct OperatorTerminal<'a> {
30    app: &'a mut AppBootstrap<BinanceExchange>,
31    runtime: &'a mut AppRuntime,
32}
33
34impl TerminalApp for OperatorTerminal<'_> {
35    fn intro_panel(&self) -> String {
36        shell_intro_panel(mode_name(current_mode(self.app)), "~/project/sandbox-quant")
37    }
38
39    fn help_text(&self) -> String {
40        shell_help_text().to_string()
41    }
42
43    fn prompt(&self) -> String {
44        let mode = current_mode(self.app);
45        let status = prompt_status(self.app);
46        operator_prompt(mode, &status)
47    }
48
49    fn complete(&self, line: &str) -> Vec<ShellCompletion> {
50        current_completions(self.app, line)
51    }
52
53    fn execute_line(&mut self, line: &str) -> Result<TerminalEvent, String> {
54        match parse_shell_input(line) {
55            Ok(ShellInput::Empty) => Ok(TerminalEvent::NoOutput),
56            Ok(ShellInput::Help) => Ok(TerminalEvent::Output(shell_help_text().to_string())),
57            Ok(ShellInput::Exit) => Ok(TerminalEvent::Exit),
58            Ok(ShellInput::Mode(mode)) => self
59                .app
60                .switch_mode(mode)
61                .map(|_| TerminalEvent::Output(format!("mode switched to {}", mode_name(mode))))
62                .map_err(|error| error.to_string()),
63            Ok(ShellInput::Command(command)) => {
64                let rendered_command = command.clone();
65                self.runtime
66                    .run(self.app, command)
67                    .map_err(|error| error.to_string())?;
68                Ok(TerminalEvent::Output(render_command_output(
69                    &rendered_command,
70                    &self.app.portfolio_store,
71                    &self.app.price_store,
72                    &self.app.event_log,
73                    &self.app.strategy_store,
74                    self.app.mode,
75                )))
76            }
77            Err(error) => Err(error),
78        }
79    }
80}
81
82fn current_mode(app: &AppBootstrap<BinanceExchange>) -> BinanceMode {
83    app.mode
84}
85
86fn prompt_status(app: &AppBootstrap<BinanceExchange>) -> String {
87    prompt_status_from_store(&app.portfolio_store)
88}
89
90fn current_completions(app: &AppBootstrap<BinanceExchange>, buffer: &str) -> Vec<ShellCompletion> {
91    let mut instruments = completion_instruments(&app.portfolio_store, &app.event_log);
92    if should_include_option_symbols(buffer) {
93        instruments.extend(option_completion_symbols(&app.exchange));
94        instruments.sort();
95        instruments.dedup();
96    }
97    let priced_instruments = app
98        .price_store
99        .snapshot()
100        .into_iter()
101        .map(|(instrument, price)| (instrument.0, price))
102        .collect::<Vec<_>>();
103    complete_shell_input_with_market_data(buffer, &instruments, &priced_instruments)
104}
105
106#[derive(Debug, Clone)]
107struct OptionCompletionCache {
108    transport_name: String,
109    fetched_at: Instant,
110    symbols: Vec<String>,
111}
112
113fn option_completion_symbols(exchange: &BinanceExchange) -> Vec<String> {
114    static CACHE: OnceLock<Mutex<Option<OptionCompletionCache>>> = OnceLock::new();
115    let cache = CACHE.get_or_init(|| Mutex::new(None));
116    let transport_name = exchange.transport_name().to_string();
117
118    if let Some(cached) = cache
119        .lock()
120        .expect("lock option completion cache")
121        .as_ref()
122        .filter(|cached| {
123            cached.transport_name == transport_name
124                && cached.fetched_at.elapsed() < Duration::from_secs(300)
125        })
126        .cloned()
127    {
128        return cached.symbols;
129    }
130
131    let symbols = exchange.load_option_symbols().unwrap_or_default();
132    *cache.lock().expect("lock option completion cache") = Some(OptionCompletionCache {
133        transport_name,
134        fetched_at: Instant::now(),
135        symbols: symbols.clone(),
136    });
137    symbols
138}
139
140fn should_include_option_symbols(buffer: &str) -> bool {
141    let trimmed = buffer.trim_start();
142    let without_prefix = trimmed.strip_prefix('/').unwrap_or(trimmed);
143    let trailing_space = without_prefix.ends_with(' ');
144    let parts: Vec<&str> = without_prefix.split_whitespace().collect();
145    if parts.first().copied() != Some("option-order") {
146        return false;
147    }
148    let arg_index = if trailing_space {
149        parts.len()
150    } else {
151        parts.len().saturating_sub(1)
152    };
153    arg_index <= 1
154}
155
156fn completion_instruments(
157    store: &crate::portfolio::store::PortfolioStateStore,
158    event_log: &crate::storage::event_log::EventLog,
159) -> Vec<String> {
160    let mut instruments = BTreeSet::new();
161
162    for instrument in store.snapshot.positions.keys() {
163        instruments.insert(instrument.0.clone());
164    }
165
166    for instrument in store.snapshot.open_orders.keys() {
167        instruments.insert(instrument.0.clone());
168    }
169
170    for event in event_log.records.iter().rev() {
171        if event.kind != "app.execution.completed" {
172            continue;
173        }
174        if let Some(instrument) = event.payload["instrument"].as_str() {
175            instruments.insert(instrument.to_string());
176        }
177    }
178
179    instruments.into_iter().collect()
180}
181
182#[cfg(test)]
183mod tests {
184    use super::{completion_instruments, prompt_status_from_store};
185    use crate::domain::balance::BalanceSnapshot;
186    use crate::domain::instrument::Instrument;
187    use crate::domain::market::Market;
188    use crate::domain::order::{OpenOrder, OrderStatus};
189    use crate::domain::position::{PositionSnapshot, Side};
190    use crate::portfolio::store::PortfolioStateStore;
191    use crate::storage::event_log::{log, EventLog};
192    use serde_json::json;
193
194    #[test]
195    fn completion_instruments_include_positions_open_orders_and_recent_execution_symbols() {
196        let mut store = PortfolioStateStore::default();
197        store.apply_snapshot(crate::exchange::types::AuthoritativeSnapshot {
198            balances: vec![BalanceSnapshot {
199                asset: "USDT".to_string(),
200                free: 1000.0,
201                locked: 0.0,
202            }],
203            positions: vec![PositionSnapshot {
204                instrument: Instrument::new("BTCUSDT"),
205                market: Market::Futures,
206                signed_qty: 0.25,
207                entry_price: Some(65000.0),
208            }],
209            open_orders: vec![OpenOrder {
210                order_id: None,
211                client_order_id: "eth-order".to_string(),
212                instrument: Instrument::new("ETHUSDT"),
213                market: Market::Futures,
214                side: Side::Sell,
215                orig_qty: 1.0,
216                executed_qty: 0.0,
217                reduce_only: false,
218                status: OrderStatus::Submitted,
219            }],
220        });
221
222        let mut event_log = EventLog::default();
223        log(
224            &mut event_log,
225            "app.execution.completed",
226            json!({
227                "command_kind": "set_target_exposure",
228                "instrument": "SOLUSDT",
229                "outcome_kind": "submitted",
230            }),
231        );
232
233        let instruments = completion_instruments(&store, &event_log);
234
235        assert_eq!(
236            instruments,
237            vec![
238                "BTCUSDT".to_string(),
239                "ETHUSDT".to_string(),
240                "SOLUSDT".to_string(),
241            ]
242        );
243    }
244
245    #[test]
246    fn prompt_status_uses_non_flat_positions_and_open_order_count() {
247        let mut store = PortfolioStateStore::default();
248        store.apply_snapshot(crate::exchange::types::AuthoritativeSnapshot {
249            balances: vec![BalanceSnapshot {
250                asset: "USDT".to_string(),
251                free: 1000.0,
252                locked: 0.0,
253            }],
254            positions: vec![
255                PositionSnapshot {
256                    instrument: Instrument::new("BTCUSDT"),
257                    market: Market::Futures,
258                    signed_qty: 0.25,
259                    entry_price: Some(65000.0),
260                },
261                PositionSnapshot {
262                    instrument: Instrument::new("ETHUSDT"),
263                    market: Market::Futures,
264                    signed_qty: 0.0,
265                    entry_price: None,
266                },
267            ],
268            open_orders: vec![OpenOrder {
269                order_id: None,
270                client_order_id: "btc-order".to_string(),
271                instrument: Instrument::new("BTCUSDT"),
272                market: Market::Futures,
273                side: Side::Sell,
274                orig_qty: 0.25,
275                executed_qty: 0.0,
276                reduce_only: false,
277                status: OrderStatus::Submitted,
278            }],
279        });
280
281        assert_eq!(prompt_status_from_store(&store), "[fresh|1 pos|1 ord]");
282    }
283}