Skip to main content

sandbox_quant/ui/
operator_output.rs

1use crate::app::bootstrap::BinanceMode;
2use crate::app::commands::{AppCommand, PortfolioView};
3use crate::execution::price_source::PriceSource;
4use crate::market_data::price_store::PriceStore;
5use crate::portfolio::store::PortfolioStateStore;
6use crate::storage::event_log::EventLog;
7use crate::strategy::command::StrategyCommand;
8use crate::strategy::store::StrategyStore;
9use std::collections::BTreeMap;
10
11pub fn render_command_output(
12    command: &AppCommand,
13    store: &PortfolioStateStore,
14    prices: &PriceStore,
15    event_log: &EventLog,
16    strategy_store: &StrategyStore,
17    mode: BinanceMode,
18) -> String {
19    match command {
20        AppCommand::Portfolio(view) => render_portfolio_output(view, store, prices, event_log),
21        AppCommand::RefreshAuthoritativeState => render_refresh_summary(store, prices, event_log),
22        AppCommand::Execution(_) => render_execution_summary(event_log),
23        AppCommand::Strategy(command) => {
24            render_strategy_output(command, event_log, strategy_store, mode)
25        }
26    }
27}
28
29fn render_strategy_output(
30    command: &StrategyCommand,
31    event_log: &EventLog,
32    store: &StrategyStore,
33    mode: BinanceMode,
34) -> String {
35    match command {
36        StrategyCommand::Templates => {
37            let mut lines = vec![
38                "strategy templates".to_string(),
39                format!("mode={}", mode.as_str()),
40                "templates=1".to_string(),
41                "template=liquidation-breakdown-short".to_string(),
42            ];
43            for (index, step) in crate::strategy::model::StrategyTemplate::LiquidationBreakdownShort
44                .steps()
45                .iter()
46                .enumerate()
47            {
48                lines.push(format!("{}. {}", index + 1, step));
49            }
50            lines.join("\n")
51        }
52        StrategyCommand::List => {
53            let watches = store.active_watches(mode);
54            let mut lines = vec![
55                "strategy watches".to_string(),
56                format!("mode={}", mode.as_str()),
57                format!("active={}", watches.len()),
58            ];
59            if watches.is_empty() {
60                lines.push("- none".to_string());
61            } else {
62                lines.extend(watches.into_iter().map(|watch| {
63                    format!(
64                        "- id={} template={} instrument={} state={} step={}/7",
65                        watch.id,
66                        watch.template.slug(),
67                        watch.instrument.0,
68                        watch.state.as_str(),
69                        watch.current_step
70                    )
71                }));
72            }
73            lines.join("\n")
74        }
75        StrategyCommand::History => {
76            let history = store.history(mode);
77            let mut lines = vec![
78                "strategy history".to_string(),
79                format!("mode={}", mode.as_str()),
80                format!("runs={}", history.len()),
81            ];
82            if history.is_empty() {
83                lines.push("- none".to_string());
84            } else {
85                lines.extend(history.iter().rev().take(10).map(|watch| {
86                    format!(
87                        "- id={} template={} instrument={} state={} updated_at={}",
88                        watch.id,
89                        watch.template.slug(),
90                        watch.instrument.0,
91                        watch.state.as_str(),
92                        watch.updated_at.to_rfc3339(),
93                    )
94                }));
95            }
96            lines.join("\n")
97        }
98        StrategyCommand::Show { watch_id } => {
99            let Some(watch) = store.get(mode, *watch_id) else {
100                return format!(
101                    "strategy watch\nmode={}\nwatch_id={watch_id}\nstate=missing",
102                    mode.as_str()
103                );
104            };
105            let mut lines = vec![
106                "strategy watch".to_string(),
107                format!("mode={}", mode.as_str()),
108                format!("watch_id={}", watch.id),
109                format!("template={}", watch.template.slug()),
110                format!("instrument={}", watch.instrument.0),
111                format!("state={}", watch.state.as_str()),
112                format!("current_step={}/7", watch.current_step),
113                format!("risk_pct={}", watch.config.risk_pct),
114                format!("win_rate={}", watch.config.win_rate),
115                format!("r_multiple={}", watch.config.r_multiple),
116                format!(
117                    "max_entry_slippage_pct={}",
118                    watch.config.max_entry_slippage_pct
119                ),
120            ];
121            for (index, step) in watch.template.steps().iter().enumerate() {
122                let marker = if watch.current_step == index + 1 {
123                    ">"
124                } else {
125                    "-"
126                };
127                lines.push(format!("{marker} {}. {}", index + 1, step));
128            }
129            lines.join("\n")
130        }
131        StrategyCommand::Start { .. } => {
132            let Some(last_event) = event_log.records.last() else {
133                return "strategy started\nlast_event=none".to_string();
134            };
135            format!(
136                "strategy started\nmode={}\nwatch_id={}\ntemplate={}\ninstrument={}\nstate={}\nrisk_pct={}\nwin_rate={}\nr_multiple={}\nmax_entry_slippage_pct={}\ncurrent_step={}/7",
137                last_event.payload["mode"].as_str().unwrap_or("unknown"),
138                last_event.payload["watch_id"].as_u64().unwrap_or_default(),
139                last_event.payload["template"].as_str().unwrap_or("unknown"),
140                last_event.payload["instrument"].as_str().unwrap_or("unknown"),
141                last_event.payload["state"].as_str().unwrap_or("unknown"),
142                last_event.payload["risk_pct"].as_f64().unwrap_or_default(),
143                last_event.payload["win_rate"].as_f64().unwrap_or_default(),
144                last_event.payload["r_multiple"].as_f64().unwrap_or_default(),
145                last_event.payload["max_entry_slippage_pct"].as_f64().unwrap_or_default(),
146                last_event.payload["current_step"].as_u64().unwrap_or_default(),
147            )
148        }
149        StrategyCommand::Stop { .. } => {
150            let Some(last_event) = event_log.records.last() else {
151                return "strategy stopped\nlast_event=none".to_string();
152            };
153            format!(
154                "strategy stopped\nmode={}\nwatch_id={}\ntemplate={}\ninstrument={}\nstate={}",
155                last_event.payload["mode"].as_str().unwrap_or("unknown"),
156                last_event.payload["watch_id"].as_u64().unwrap_or_default(),
157                last_event.payload["template"].as_str().unwrap_or("unknown"),
158                last_event.payload["instrument"]
159                    .as_str()
160                    .unwrap_or("unknown"),
161                last_event.payload["state"].as_str().unwrap_or("unknown"),
162            )
163        }
164    }
165}
166
167fn render_portfolio_output(
168    view: &PortfolioView,
169    store: &PortfolioStateStore,
170    prices: &PriceStore,
171    event_log: &EventLog,
172) -> String {
173    match view {
174        PortfolioView::Overview => render_refresh_summary_with_header(
175            "portfolio",
176            store,
177            prices,
178            event_log,
179            true,
180            true,
181            true,
182        ),
183        PortfolioView::Positions => render_refresh_summary_with_header(
184            "portfolio positions",
185            store,
186            prices,
187            event_log,
188            true,
189            false,
190            false,
191        ),
192        PortfolioView::Balances => render_refresh_summary_with_header(
193            "portfolio balances",
194            store,
195            prices,
196            event_log,
197            false,
198            true,
199            false,
200        ),
201        PortfolioView::Orders => render_refresh_summary_with_header(
202            "portfolio orders",
203            store,
204            prices,
205            event_log,
206            false,
207            false,
208            true,
209        ),
210    }
211}
212
213fn render_refresh_summary(
214    store: &PortfolioStateStore,
215    prices: &PriceStore,
216    event_log: &EventLog,
217) -> String {
218    render_refresh_summary_with_header(
219        "refresh completed",
220        store,
221        prices,
222        event_log,
223        true,
224        true,
225        true,
226    )
227}
228
229fn render_refresh_summary_with_header(
230    header: &str,
231    store: &PortfolioStateStore,
232    prices: &PriceStore,
233    event_log: &EventLog,
234    show_positions: bool,
235    show_balances: bool,
236    show_orders: bool,
237) -> String {
238    let last_event = event_log
239        .records
240        .last()
241        .map(|event| event.kind.as_str())
242        .unwrap_or("none");
243    let latest_refresh = event_log
244        .records
245        .iter()
246        .rev()
247        .find(|event| event.kind == "app.portfolio.refreshed");
248    let aggregated_balances = aggregate_visible_balances(store);
249    let total_equity_usdt = aggregated_balances
250        .values()
251        .map(|balance| balance.total())
252        .sum::<f64>();
253    let available_quote_usdt = aggregated_balances
254        .iter()
255        .filter(|(asset, _)| asset.as_str() == "USDT" || asset.as_str() == "USDC")
256        .map(|(_, balance)| balance.free)
257        .sum::<f64>();
258    let visible_positions = store
259        .snapshot
260        .positions
261        .values()
262        .filter(|position| !position.is_flat())
263        .collect::<Vec<_>>();
264    let gross_exposure_usdt = visible_positions
265        .iter()
266        .filter(|position| position.market != crate::domain::market::Market::Options)
267        .filter_map(|position| {
268            let price = prices
269                .current_price(&position.instrument)
270                .or(position.entry_price)?;
271            Some(position.abs_qty() * price)
272        })
273        .sum::<f64>();
274    let net_exposure_usdt = visible_positions
275        .iter()
276        .filter(|position| position.market != crate::domain::market::Market::Options)
277        .filter_map(|position| {
278            let price = prices
279                .current_price(&position.instrument)
280                .or(position.entry_price)?;
281            Some(position.signed_qty * price)
282        })
283        .sum::<f64>();
284    let unrealized_pnl_usdt = visible_positions
285        .iter()
286        .filter_map(|position| {
287            let current_price = prices.current_price(&position.instrument)?;
288            let entry_price = position.entry_price?;
289            Some((current_price - entry_price) * position.signed_qty)
290        })
291        .sum::<f64>();
292    let gross_exposure_usdt = normalize_display_value(gross_exposure_usdt);
293    let net_exposure_usdt = normalize_display_value(net_exposure_usdt);
294    let unrealized_pnl_usdt = normalize_display_value(unrealized_pnl_usdt);
295    let leverage = if total_equity_usdt > f64::EPSILON {
296        normalize_display_value(gross_exposure_usdt / total_equity_usdt)
297    } else {
298        0.0
299    };
300    let margin_ratio_text = if visible_positions.is_empty() && store.snapshot.open_orders.is_empty()
301    {
302        "n/a".to_string()
303    } else {
304        latest_refresh
305            .and_then(|event| event.payload["margin_ratio"].as_f64())
306            .map(|value| format!("{value:.4}"))
307            .unwrap_or_else(|| "n/a".to_string())
308    };
309
310    let mut lines = vec![
311        header.to_string(),
312        format!("staleness={:?}", store.staleness),
313        format!("last_event={last_event}"),
314    ];
315
316    if header == "portfolio" {
317        lines.push("account".to_string());
318        lines.push(format!("  total_equity_usdt={total_equity_usdt:.2}"));
319        lines.push(format!("  available_quote_usdt={available_quote_usdt:.2}"));
320        lines.push("risk".to_string());
321        lines.push(format!("  positions={}", visible_positions.len()));
322        lines.push(format!(
323            "  open_orders={}",
324            store.snapshot.open_orders.len()
325        ));
326        lines.push(format!("  gross_exposure_usdt={gross_exposure_usdt:.2}"));
327        lines.push(format!("  net_exposure_usdt={net_exposure_usdt:.2}"));
328        lines.push(format!("  leverage={leverage:.4}"));
329        lines.push(format!("  margin_ratio={margin_ratio_text}"));
330        lines.push("pnl".to_string());
331        lines.push(format!("  unrealized_pnl_usdt={unrealized_pnl_usdt:.2}"));
332        lines.push(format!(
333            "  today_realized_pnl_usdt={}",
334            latest_refresh
335                .and_then(|event| event.payload["today_realized_pnl_usdt"].as_f64())
336                .map(|value| format!("{value:.2}"))
337                .unwrap_or_else(|| "n/a".to_string())
338        ));
339        lines.push(format!(
340            "  today_funding_pnl_usdt={}",
341            latest_refresh
342                .and_then(|event| event.payload["today_funding_pnl_usdt"].as_f64())
343                .map(|value| format!("{value:.2}"))
344                .unwrap_or_else(|| "n/a".to_string())
345        ));
346    }
347
348    if show_balances {
349        lines.push(format!("balances ({})", aggregated_balances.len()));
350        let balance_lines = aggregated_balances
351            .iter()
352            .take(8)
353            .map(|(asset, balance)| {
354                format!(
355                    "  - {} free={:.8} locked={:.8} total={:.8}",
356                    asset,
357                    balance.free,
358                    balance.locked,
359                    balance.total()
360                )
361            })
362            .collect::<Vec<_>>();
363
364        if balance_lines.is_empty() {
365            lines.push("  - none".to_string());
366        } else {
367            lines.extend(balance_lines);
368        }
369    }
370
371    if show_positions {
372        lines.push(format!("positions ({})", visible_positions.len()));
373        let position_lines = visible_positions
374            .into_iter()
375            .take(12)
376            .map(|position| {
377                let side = position
378                    .side()
379                    .map(|side| format!("{side:?}"))
380                    .unwrap_or_else(|| "Flat".to_string());
381                let market = format_market(position.market);
382                let notional = if position.market == crate::domain::market::Market::Options {
383                    None
384                } else {
385                    position.entry_price.map(|price| position.abs_qty() * price)
386                };
387                let exposure = notional.and_then(|notional| {
388                    if total_equity_usdt > f64::EPSILON {
389                        Some(notional / total_equity_usdt)
390                    } else {
391                        None
392                    }
393                });
394                let target_exposure = if position.market == crate::domain::market::Market::Options {
395                    None
396                } else {
397                    latest_target_exposure(event_log, &position.instrument)
398                };
399                let target_delta = match (target_exposure, exposure) {
400                    (Some(target), Some(current)) => Some(target - current),
401                    _ => None,
402                };
403                format!(
404                    "  - {} market={} side={} qty={:.8} entry={} notional={} current_exposure={} target_exposure={} target_delta={}",
405                    position.instrument.0,
406                    market,
407                    side,
408                    position.abs_qty(),
409                    position
410                        .entry_price
411                        .map(|price| format!("{price:.8}"))
412                        .unwrap_or_else(|| "-".to_string()),
413                    notional
414                        .map(|value| format!("{value:.2}"))
415                        .unwrap_or_else(|| "-".to_string()),
416                    exposure
417                        .map(|value| format!("{value:.4}"))
418                        .unwrap_or_else(|| "-".to_string()),
419                    target_exposure
420                        .map(|value| format!("{value:.4}"))
421                        .unwrap_or_else(|| "-".to_string()),
422                    target_delta
423                        .map(|value| format!("{value:.4}"))
424                        .unwrap_or_else(|| "-".to_string()),
425                )
426            })
427            .collect::<Vec<_>>();
428
429        if position_lines.is_empty() {
430            lines.push("  - none".to_string());
431        } else {
432            lines.extend(position_lines);
433        }
434    }
435
436    if show_orders {
437        lines.push(format!(
438            "open orders ({})",
439            store.snapshot.open_orders.len()
440        ));
441        let order_lines = store
442            .snapshot
443            .open_orders
444            .iter()
445            .take(12)
446            .flat_map(|(instrument, orders)| {
447                orders.iter().map(move |order| {
448                    format!(
449                        "  - {} {} side={:?} qty={:.8} filled={:.8} reduce_only={} status={:?}",
450                        instrument.0,
451                        format_market(order.market),
452                        order.side,
453                        order.orig_qty,
454                        order.executed_qty,
455                        order.reduce_only,
456                        order.status
457                    )
458                })
459            })
460            .collect::<Vec<_>>();
461
462        if order_lines.is_empty() {
463            lines.push("  - none".to_string());
464        } else {
465            lines.extend(order_lines);
466        }
467    }
468
469    lines.join("\n")
470}
471
472fn aggregate_visible_balances(
473    store: &PortfolioStateStore,
474) -> BTreeMap<String, crate::domain::balance::BalanceSnapshot> {
475    let mut aggregated = BTreeMap::new();
476
477    for balance in store
478        .snapshot
479        .balances
480        .iter()
481        .filter(|balance| balance.total().abs() > f64::EPSILON)
482    {
483        let entry = aggregated.entry(balance.asset.clone()).or_insert(
484            crate::domain::balance::BalanceSnapshot {
485                asset: balance.asset.clone(),
486                free: 0.0,
487                locked: 0.0,
488            },
489        );
490        entry.free += balance.free;
491        entry.locked += balance.locked;
492    }
493
494    aggregated
495}
496
497fn normalize_display_value(value: f64) -> f64 {
498    if value.abs() <= f64::EPSILON {
499        0.0
500    } else {
501        value
502    }
503}
504
505fn render_execution_summary(event_log: &EventLog) -> String {
506    let Some(last_event) = event_log.records.last() else {
507        return "execution completed\nlast_event=none".to_string();
508    };
509
510    if last_event.kind != "app.execution.completed" {
511        return format!("execution completed\nlast_event={}", last_event.kind);
512    }
513
514    match last_event.payload["command_kind"].as_str() {
515        Some("set_target_exposure") => format!(
516            "execution completed\ncommand=set-target-exposure\ninstrument={}\ntarget={}\norder_type={}\nremaining_positions={}\nflat_confirmed={}\nremaining_gross_exposure_usdt={:.2}\noutcome={}",
517            last_event.payload["instrument"].as_str().unwrap_or("unknown"),
518            last_event.payload["target"].as_f64().unwrap_or_default(),
519            last_event.payload["order_type"].as_str().unwrap_or("unknown"),
520            last_event.payload["remaining_positions"]
521                .as_u64()
522                .unwrap_or_default(),
523            last_event.payload["flat_confirmed"].as_bool().unwrap_or(false),
524            normalize_display_value(
525                last_event.payload["remaining_gross_exposure_usdt"]
526                    .as_f64()
527                    .unwrap_or_default(),
528            ),
529            last_event.payload["outcome_kind"].as_str().unwrap_or("unknown"),
530        ),
531        Some("submit_option_order") => format!(
532            "execution completed\ncommand=option-order\ninstrument={}\nside={}\nqty={}\norder_type={}\nremaining_positions={}\nflat_confirmed={}\nremaining_gross_exposure_usdt={:.2}\noutcome={}",
533            last_event.payload["instrument"].as_str().unwrap_or("unknown"),
534            last_event.payload["side"].as_str().unwrap_or("unknown"),
535            last_event.payload["qty"].as_f64().unwrap_or_default(),
536            last_event.payload["order_type"].as_str().unwrap_or("unknown"),
537            last_event.payload["remaining_positions"]
538                .as_u64()
539                .unwrap_or_default(),
540            last_event.payload["flat_confirmed"].as_bool().unwrap_or(false),
541            normalize_display_value(
542                last_event.payload["remaining_gross_exposure_usdt"]
543                    .as_f64()
544                    .unwrap_or_default(),
545            ),
546            last_event.payload["outcome_kind"].as_str().unwrap_or("unknown"),
547        ),
548        Some("close_symbol") => format!(
549            "execution completed\ncommand=close-symbol\ninstrument={}\nremaining_positions={}\nflat_confirmed={}\nremaining_gross_exposure_usdt={:.2}\noutcome={}",
550            last_event.payload["instrument"].as_str().unwrap_or("unknown"),
551            last_event.payload["remaining_positions"]
552                .as_u64()
553                .unwrap_or_default(),
554            last_event.payload["flat_confirmed"].as_bool().unwrap_or(false),
555            normalize_display_value(
556                last_event.payload["remaining_gross_exposure_usdt"]
557                    .as_f64()
558                    .unwrap_or_default(),
559            ),
560            last_event.payload["outcome_kind"].as_str().unwrap_or("unknown"),
561        ),
562        Some("close_all") => format!(
563            "execution completed\ncommand=close-all\nbatch_id={}\nsubmitted={}\nskipped={}\nrejected={}\nremaining_positions={}\nflat_confirmed={}\nremaining_gross_exposure_usdt={:.2}\noutcome={}",
564            last_event.payload["batch_id"].as_u64().unwrap_or_default(),
565            last_event.payload["submitted"].as_u64().unwrap_or_default(),
566            last_event.payload["skipped"].as_u64().unwrap_or_default(),
567            last_event.payload["rejected"].as_u64().unwrap_or_default(),
568            last_event.payload["remaining_positions"]
569                .as_u64()
570                .unwrap_or_default(),
571            last_event.payload["flat_confirmed"].as_bool().unwrap_or(false),
572            normalize_display_value(
573                last_event.payload["remaining_gross_exposure_usdt"]
574                    .as_f64()
575                    .unwrap_or_default(),
576            ),
577            last_event.payload["outcome_kind"].as_str().unwrap_or("unknown"),
578        ),
579        _ => format!("execution completed\nlast_event={}", last_event.kind),
580    }
581}
582
583fn latest_target_exposure(
584    event_log: &EventLog,
585    instrument: &crate::domain::instrument::Instrument,
586) -> Option<f64> {
587    event_log
588        .records
589        .iter()
590        .rev()
591        .find(|event| {
592            event.kind == "app.execution.completed"
593                && event.payload["command_kind"].as_str() == Some("set_target_exposure")
594                && event.payload["instrument"].as_str() == Some(instrument.0.as_str())
595        })
596        .and_then(|event| event.payload["target"].as_f64())
597}
598
599fn format_market(market: crate::domain::market::Market) -> &'static str {
600    match market {
601        crate::domain::market::Market::Spot => "SPOT",
602        crate::domain::market::Market::Futures => "FUTURES",
603        crate::domain::market::Market::Options => "OPTIONS",
604    }
605}