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}