tesser_cli/
state.rs

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}