sandbox_quant/app/
output.rs1use 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}