1use std::path::{Path, PathBuf};
2
3use anyhow::{anyhow, Result};
4use chrono::SecondsFormat;
5use rust_decimal::Decimal;
6use serde_json::to_string_pretty;
7use tesser_config::PersistenceEngine;
8use tesser_core::{CashBook, Position, Side, Symbol};
9use tesser_journal::LmdbJournal;
10use tesser_portfolio::{LiveState, PortfolioState, SqliteStateRepository, StateRepository};
11
12const MAX_ORDER_ROWS: usize = 5;
13const MAX_PRICE_ROWS: usize = 8;
14
15pub async fn inspect_state(path: PathBuf, engine: PersistenceEngine, raw: bool) -> Result<()> {
16 let (state, resolved_path) = match engine {
17 PersistenceEngine::Sqlite => {
18 let repo = SqliteStateRepository::new(path.clone());
19 let state = tokio::task::spawn_blocking(move || repo.load())
20 .await
21 .map_err(|err| anyhow!("state inspection task failed: {err}"))?
22 .map_err(|err| anyhow!(err.to_string()))?;
23 (state, path)
24 }
25 PersistenceEngine::Lmdb => {
26 let journal = LmdbJournal::open(&path)
27 .map_err(|err| anyhow!("failed to open LMDB journal: {err}"))?;
28 let repo = journal.state_repo();
29 let state = tokio::task::spawn_blocking(move || repo.load())
30 .await
31 .map_err(|err| anyhow!("state inspection task failed: {err}"))?
32 .map_err(|err| anyhow!(err.to_string()))?;
33 (state, journal.path().to_path_buf())
34 }
35 };
36 if raw {
37 println!("{}", to_string_pretty(&state)?);
38 } else {
39 print_summary(&resolved_path, &state);
40 }
41 Ok(())
42}
43
44fn print_summary(path: &Path, state: &LiveState) {
45 println!("State database: {}", path.display());
46 if let Some(ts) = state.last_candle_ts {
47 println!(
48 "Last candle timestamp: {}",
49 ts.to_rfc3339_opts(SecondsFormat::Secs, true)
50 );
51 } else {
52 println!("Last candle timestamp: <none>");
53 }
54 if let Some(portfolio) = &state.portfolio {
55 print_portfolio(portfolio);
56 } else {
57 println!("Portfolio snapshot: <empty>");
58 }
59
60 println!("Open orders ({} total):", state.open_orders.len());
61 if state.open_orders.is_empty() {
62 println!(" none");
63 } else {
64 for order in state.open_orders.iter().take(MAX_ORDER_ROWS) {
65 println!(
66 " {} {} {} @ {:?} status={:?} filled={:.4}",
67 order.id,
68 order.request.symbol,
69 format_side(Some(order.request.side)),
70 order.request.price,
71 order.status,
72 order.filled_quantity
73 );
74 }
75 if state.open_orders.len() > MAX_ORDER_ROWS {
76 println!(
77 " ... {} additional order(s) omitted",
78 state.open_orders.len() - MAX_ORDER_ROWS
79 );
80 }
81 }
82
83 println!("Last price cache ({} symbol(s)):", state.last_prices.len());
84 if state.last_prices.is_empty() {
85 println!(" none");
86 } else {
87 let mut entries: Vec<_> = state.last_prices.iter().collect();
88 entries.sort_by_key(|(symbol, _)| (symbol.exchange.as_raw(), symbol.market_id));
89 for (symbol, price) in entries.into_iter().take(MAX_PRICE_ROWS) {
90 println!(" {symbol}: {price}");
91 }
92 if state.last_prices.len() > MAX_PRICE_ROWS {
93 println!(
94 " ... {} additional symbol(s) omitted",
95 state.last_prices.len() - MAX_PRICE_ROWS
96 );
97 }
98 }
99}
100
101fn print_portfolio(portfolio: &PortfolioState) {
102 let unrealized: Decimal = portfolio
103 .positions
104 .values()
105 .map(|pos| pos.unrealized_pnl)
106 .sum();
107 let cash_value = portfolio.balances.total_value();
108 let equity = cash_value + unrealized;
109 let realized = equity - portfolio.initial_equity - unrealized;
110 let reporting_cash = portfolio
111 .balances
112 .get(portfolio.reporting_currency)
113 .map(|cash| cash.quantity)
114 .unwrap_or_default();
115 println!("Portfolio snapshot:");
116 println!(
117 " Cash ({}): {:.2}",
118 portfolio.reporting_currency, reporting_cash
119 );
120 println!(" Realized PnL: {:.2}", realized);
121 println!(" Equity: {:.2}", equity);
122 println!(
123 " Peak equity: {:.2} (liquidate_only={})",
124 portfolio.peak_equity, portfolio.liquidate_only
125 );
126 if let Some(limit) = portfolio.drawdown_limit {
127 println!(" Drawdown limit: {:.2}%", limit * Decimal::from(100));
128 }
129
130 if !portfolio.sub_accounts.is_empty() {
131 println!(" Venue breakdown:");
132 let mut venues: Vec<_> = portfolio.sub_accounts.values().collect();
133 venues.sort_by_key(|acct| acct.exchange.as_raw());
134 for account in venues {
135 let unrealized: Decimal = account
136 .positions
137 .values()
138 .map(|pos| pos.unrealized_pnl)
139 .sum();
140 let equity = account.balances.total_value() + unrealized;
141 println!(
142 " {:<12} equity={:.2} balances={} positions={}",
143 account.exchange,
144 equity,
145 account.balances.iter().count(),
146 account.positions.len()
147 );
148 print_balances(" Balances", &account.balances);
149 print_positions(" Positions", &account.positions);
150 }
151 }
152
153 print_balances("Balances", &portfolio.balances);
154 print_positions("Positions", &portfolio.positions);
155}
156
157fn format_side(side: Option<Side>) -> &'static str {
158 match side {
159 Some(Side::Buy) => "Buy",
160 Some(Side::Sell) => "Sell",
161 None => "Flat",
162 }
163}
164
165fn print_balances(label: &str, balances: &CashBook) {
166 if balances.iter().any(|(_, cash)| !cash.quantity.is_zero()) {
167 println!(" {label}:");
168 let mut entries: Vec<_> = balances.iter().collect();
169 entries.sort_by_key(|(asset, _)| (asset.exchange.as_raw(), asset.asset_id));
170 for (currency, cash) in entries {
171 println!(
172 " {:<8} qty={:.6} rate={:.6}",
173 currency, cash.quantity, cash.conversion_rate
174 );
175 }
176 }
177}
178
179fn print_positions(label: &str, positions: &std::collections::HashMap<Symbol, Position>) {
180 if positions.is_empty() {
181 println!(" {label}: none");
182 return;
183 }
184 println!(" {label}:");
185 let mut entries: Vec<_> = positions.iter().collect();
186 entries.sort_by_key(|(symbol, _)| (symbol.exchange.as_raw(), symbol.market_id));
187 for (symbol, position) in entries {
188 println!(
189 " {:<12} side={} qty={:.4} entry={:.4?} unrealized={:.2} updated={}",
190 symbol,
191 format_side(position.side),
192 position.quantity,
193 position.entry_price,
194 position.unrealized_pnl,
195 position
196 .updated_at
197 .to_rfc3339_opts(SecondsFormat::Secs, true)
198 );
199 }
200}