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}