Skip to main content

sandbox_quant/app/
output.rs

1use crate::app::commands::AppCommand;
2use crate::portfolio::store::PortfolioStateStore;
3use crate::storage::event_log::EventLog;
4use std::collections::BTreeMap;
5
6pub fn render_command_output(
7    command: &AppCommand,
8    store: &PortfolioStateStore,
9    event_log: &EventLog,
10) -> String {
11    match command {
12        AppCommand::RefreshAuthoritativeState => render_refresh_summary(store, event_log),
13        AppCommand::Execution(_) => render_execution_summary(event_log),
14    }
15}
16
17fn render_refresh_summary(store: &PortfolioStateStore, event_log: &EventLog) -> String {
18    let last_event = event_log
19        .records
20        .last()
21        .map(|event| event.kind.as_str())
22        .unwrap_or("none");
23    let aggregated_balances = aggregate_visible_balances(store);
24    let visible_positions = store
25        .snapshot
26        .positions
27        .values()
28        .filter(|position| !position.is_flat())
29        .collect::<Vec<_>>();
30
31    let mut lines = vec![
32        "refresh completed".to_string(),
33        format!("staleness={:?}", store.staleness),
34        format!("last_event={last_event}"),
35        format!("balances ({})", aggregated_balances.len()),
36    ];
37
38    let balance_lines = aggregated_balances
39        .iter()
40        .take(8)
41        .map(|(asset, balance)| {
42            format!(
43                "  - {} free={:.8} locked={:.8} total={:.8}",
44                asset,
45                balance.free,
46                balance.locked,
47                balance.total()
48            )
49        })
50        .collect::<Vec<_>>();
51
52    if balance_lines.is_empty() {
53        lines.push("  - none".to_string());
54    } else {
55        lines.extend(balance_lines);
56    }
57
58    lines.push(format!("positions ({})", visible_positions.len()));
59    let position_lines = visible_positions
60        .into_iter()
61        .take(12)
62        .map(|position| {
63            let side = position
64                .side()
65                .map(|side| format!("{side:?}"))
66                .unwrap_or_else(|| "Flat".to_string());
67            let market = match position.market {
68                crate::domain::market::Market::Spot => "SPOT",
69                crate::domain::market::Market::Futures => "FUTURES",
70            };
71            format!(
72                "  - {} market={} side={} qty={:.8} entry={}",
73                position.instrument.0,
74                market,
75                side,
76                position.abs_qty(),
77                position
78                    .entry_price
79                    .map(|price| format!("{price:.8}"))
80                    .unwrap_or_else(|| "-".to_string())
81            )
82        })
83        .collect::<Vec<_>>();
84
85    if position_lines.is_empty() {
86        lines.push("  - none".to_string());
87    } else {
88        lines.extend(position_lines);
89    }
90
91    lines.push(format!("open orders ({})", store.snapshot.open_orders.len()));
92    let order_lines = store
93        .snapshot
94        .open_orders
95        .iter()
96        .take(12)
97        .flat_map(|(instrument, orders)| {
98            orders.iter().map(move |order| {
99                format!(
100                    "  - {} {:?} side={:?} qty={:.8} filled={:.8} reduce_only={} status={:?}",
101                    instrument.0,
102                    order.market,
103                    order.side,
104                    order.orig_qty,
105                    order.executed_qty,
106                    order.reduce_only,
107                    order.status
108                )
109            })
110        })
111        .collect::<Vec<_>>();
112
113    if order_lines.is_empty() {
114        lines.push("  - none".to_string());
115    } else {
116        lines.extend(order_lines);
117    }
118
119    lines.join("\n")
120}
121
122fn aggregate_visible_balances(
123    store: &PortfolioStateStore,
124) -> BTreeMap<String, crate::domain::balance::BalanceSnapshot> {
125    let mut aggregated = BTreeMap::new();
126
127    for balance in store
128        .snapshot
129        .balances
130        .iter()
131        .filter(|balance| balance.total().abs() > f64::EPSILON)
132    {
133        let entry = aggregated
134            .entry(balance.asset.clone())
135            .or_insert(crate::domain::balance::BalanceSnapshot {
136                asset: balance.asset.clone(),
137                free: 0.0,
138                locked: 0.0,
139            });
140        entry.free += balance.free;
141        entry.locked += balance.locked;
142    }
143
144    aggregated
145}
146
147fn render_execution_summary(event_log: &EventLog) -> String {
148    let Some(last_event) = event_log.records.last() else {
149        return "execution completed\nlast_event=none".to_string();
150    };
151
152    if last_event.kind != "app.execution.completed" {
153        return format!("execution completed\nlast_event={}", last_event.kind);
154    }
155
156    match last_event.payload["command_kind"].as_str() {
157        Some("set_target_exposure") => format!(
158            "execution completed\ncommand=set-target-exposure\ninstrument={}\ntarget={}\noutcome={}",
159            last_event.payload["instrument"].as_str().unwrap_or("unknown"),
160            last_event.payload["target"].as_f64().unwrap_or_default(),
161            last_event.payload["outcome_kind"].as_str().unwrap_or("unknown"),
162        ),
163        Some("close_symbol") => format!(
164            "execution completed\ncommand=close-symbol\ninstrument={}\noutcome={}",
165            last_event.payload["instrument"].as_str().unwrap_or("unknown"),
166            last_event.payload["outcome_kind"].as_str().unwrap_or("unknown"),
167        ),
168        Some("close_all") => format!(
169            "execution completed\ncommand=close-all\nbatch_id={}\nsubmitted={}\nskipped={}\nrejected={}\noutcome={}",
170            last_event.payload["batch_id"].as_u64().unwrap_or_default(),
171            last_event.payload["submitted"].as_u64().unwrap_or_default(),
172            last_event.payload["skipped"].as_u64().unwrap_or_default(),
173            last_event.payload["rejected"].as_u64().unwrap_or_default(),
174            last_event.payload["outcome_kind"].as_str().unwrap_or("unknown"),
175        ),
176        _ => format!("execution completed\nlast_event={}", last_event.kind),
177    }
178}