use std::collections::{HashMap, VecDeque};
use chrono::Utc;
use super::super::constants::{MAX_PRICE_HISTORY, MIN_SOL_SPREAD_USD, SOL_SYMBOL};
use super::super::data::GtiCache;
use super::super::data::ParsedSplineData;
use super::super::format::pubkey_trader_prefix;
use super::book::{BookRow, ClobLevel, MergedBook, RowSource};
use super::liquidation_feed_view::LiquidationFeedView;
use super::markers::{OrderChartMarker, TradeMarker};
use super::market::{MarketInfo, MarketSelector};
use super::orders_view::OrdersView;
use super::position_leaderboard_view::TopPositionsView;
use super::positions_view::PositionsView;
use super::trade_panel::TradingState;
pub struct TuiState {
pub price_history: VecDeque<f64>,
pub market_stats: Option<phoenix_rise::MarketStatsUpdate>,
pub market_stats_cache: HashMap<String, phoenix_rise::MarketStatsUpdate>,
pub clob_bids: Vec<ClobLevel>,
pub clob_asks: Vec<ClobLevel>,
pub merged_book: MergedBook,
pub last_parsed: Option<ParsedSplineData>,
pub last_slot: u64,
pub chart_clock_hms: String,
pub trading: TradingState,
pub trade_markers: Vec<TradeMarker>,
pub market_selector: MarketSelector,
pub positions_view: PositionsView,
pub orders_view: OrdersView,
pub top_positions_view: TopPositionsView,
pub liquidation_feed_view: LiquidationFeedView,
pub order_chart_markers: HashMap<(String, u8, u64), OrderChartMarker>,
pub switching_to: Option<String>,
chart_data_cache: Vec<(f64, f64)>,
price_bounds_cache: (f64, f64),
chart_min: f64,
chart_max: f64,
}
impl TuiState {
pub fn new(market_list: Vec<MarketInfo>) -> Self {
Self {
price_history: VecDeque::with_capacity(MAX_PRICE_HISTORY),
market_stats: None,
market_stats_cache: HashMap::new(),
clob_bids: Vec::new(),
clob_asks: Vec::new(),
merged_book: MergedBook::default(),
last_parsed: None,
last_slot: 0,
chart_clock_hms: Utc::now().format("%H:%M:%S").to_string(),
trading: TradingState::new(),
trade_markers: Vec::new(),
market_selector: MarketSelector::new(market_list),
positions_view: PositionsView::new(),
orders_view: OrdersView::new(),
top_positions_view: TopPositionsView::new(),
liquidation_feed_view: LiquidationFeedView::new(),
order_chart_markers: HashMap::new(),
switching_to: None,
chart_data_cache: Vec::with_capacity(MAX_PRICE_HISTORY),
price_bounds_cache: (0.0, 1.0),
chart_min: f64::INFINITY,
chart_max: f64::NEG_INFINITY,
}
}
pub fn begin_market_switch(&mut self, target_symbol: &str) {
self.switching_to = Some(target_symbol.to_string());
self.clob_bids.clear();
self.clob_asks.clear();
self.market_stats = self.market_stats_cache.get(target_symbol).cloned();
self.trading.position = None;
self.trading.order_kind = super::super::trading::OrderKind::Market;
}
pub fn rebuild_merged_book(
&mut self,
symbol: &str,
show_clob: bool,
gti_cache: Option<&GtiCache>,
price_decimals: usize,
) {
if self.switching_to.is_some() {
return;
}
let resolve_spline_trader = |pda: &solana_pubkey::Pubkey| -> String {
let authority = gti_cache.and_then(|c| c.resolve_pda(pda));
match authority {
Some(auth) => pubkey_trader_prefix(&auth),
None => pubkey_trader_prefix(pda),
}
};
let mut raw_bids: Vec<(f64, f64, String, RowSource)> = Vec::new();
let mut raw_asks: Vec<(f64, f64, String, RowSource)> = Vec::new();
let mut bid_iceberg_markers: Vec<(f64, String)> = Vec::new();
let mut ask_iceberg_markers: Vec<(f64, String)> = Vec::new();
if let Some(parsed) = self.last_parsed.as_ref() {
for r in &parsed.bid_rows {
raw_bids.push((r.1, r.2, resolve_spline_trader(&r.0), RowSource::Spline));
}
for r in &parsed.ask_rows {
raw_asks.push((r.1, r.2, resolve_spline_trader(&r.0), RowSource::Spline));
}
bid_iceberg_markers.extend(
parsed
.bid_iceberg_markers
.iter()
.map(|(p, t)| (*p, resolve_spline_trader(t))),
);
ask_iceberg_markers.extend(
parsed
.ask_iceberg_markers
.iter()
.map(|(p, t)| (*p, resolve_spline_trader(t))),
);
}
if show_clob {
for (price, qty, trader) in &self.clob_bids {
raw_bids.push((*price, *qty, trader.clone(), RowSource::Clob));
}
for (price, qty, trader) in &self.clob_asks {
raw_asks.push((*price, *qty, trader.clone(), RowSource::Clob));
}
}
let mut bid_rows = group_by_price(raw_bids, true, price_decimals);
let mut ask_rows = group_by_price(raw_asks, false, price_decimals);
apply_iceberg_markers(&mut bid_rows, &bid_iceberg_markers, price_decimals);
apply_iceberg_markers(&mut ask_rows, &ask_iceberg_markers, price_decimals);
let best_bid = bid_rows.first().map(|r| r.price);
let best_ask = ask_rows.first().map(|r| r.price);
let spread = match (best_bid, best_ask) {
(Some(b), Some(a)) => {
let raw = (a - b).max(0.0);
Some(if symbol == SOL_SYMBOL {
raw.max(MIN_SOL_SPREAD_USD)
} else {
raw
})
}
_ => None,
};
self.merged_book = MergedBook {
bid_rows,
ask_rows,
best_bid,
best_ask,
spread,
};
}
pub fn complete_market_switch(&mut self) {
self.switching_to = None;
self.price_history.clear();
self.trade_markers.clear();
self.order_chart_markers.clear();
self.last_parsed = None;
self.last_slot = 0;
self.chart_clock_hms = Utc::now().format("%H:%M:%S").to_string();
self.chart_data_cache.clear();
self.price_bounds_cache = (0.0, 1.0);
self.chart_min = f64::INFINITY;
self.chart_max = f64::NEG_INFINITY;
}
pub fn push_price(&mut self, mid: f64) {
let popped = if self.price_history.len() >= MAX_PRICE_HISTORY {
self.price_history.pop_front()
} else {
None
};
self.price_history.push_back(mid);
if popped.is_some() {
for m in &mut self.trade_markers {
m.x -= 1.0;
}
self.trade_markers.retain(|m| m.x >= 0.0);
for marker in self.order_chart_markers.values_mut() {
marker.x -= 1.0;
}
self.chart_data_cache.clear();
self.chart_data_cache.extend(
self.price_history
.iter()
.enumerate()
.map(|(i, &y)| (i as f64, y)),
);
} else {
let new_x = self.price_history.len().saturating_sub(1) as f64;
self.chart_data_cache.push((new_x, mid));
}
let rescan = matches!(popped, Some(p) if p <= self.chart_min || p >= self.chart_max)
|| !self.chart_min.is_finite()
|| !self.chart_max.is_finite();
if rescan {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for &p in &self.price_history {
if p < min {
min = p;
}
if p > max {
max = p;
}
}
self.chart_min = min;
self.chart_max = max;
} else {
if mid < self.chart_min {
self.chart_min = mid;
}
if mid > self.chart_max {
self.chart_max = mid;
}
}
if self.price_history.is_empty() {
self.price_bounds_cache = (0.0, 1.0);
} else {
let range = self.chart_max - self.chart_min;
let mid_val = (self.chart_min + self.chart_max) / 2.0;
let margin = if range > 0.0 {
range * 0.05
} else {
mid_val.abs() * 0.0005
};
self.price_bounds_cache = (self.chart_min - margin, self.chart_max + margin);
}
}
pub fn sync_order_chart_markers(&mut self, active_symbol: &str) {
let current_x = self.price_history.len().saturating_sub(1) as f64;
let mut seen = std::collections::HashSet::<(u8, u64)>::new();
for o in self
.orders_view
.orders
.iter()
.filter(|o| o.symbol == active_symbol && o.price_usd > 0.0)
{
let marker_id = (o.subaccount_index, o.order_sequence_number);
seen.insert(marker_id);
let key = (
o.symbol.clone(),
o.subaccount_index,
o.order_sequence_number,
);
self.order_chart_markers
.entry(key)
.and_modify(|m| m.price = o.price_usd)
.or_insert(OrderChartMarker {
x: current_x,
price: o.price_usd,
});
}
self.order_chart_markers
.retain(|key, _| key.0 != active_symbol || seen.contains(&(key.1, key.2)));
}
pub fn add_trade_marker(&mut self, is_buy: bool) {
let x = self.price_history.len().saturating_sub(1) as f64;
if let Some(&y) = self.price_history.back() {
self.trade_markers.push(TradeMarker { x, y, is_buy });
}
}
pub fn chart_data(&self) -> &[(f64, f64)] {
&self.chart_data_cache
}
pub fn price_bounds(&self) -> (f64, f64) {
self.price_bounds_cache
}
}
fn group_by_price(
raw: Vec<(f64, f64, String, RowSource)>,
is_bid: bool,
price_decimals: usize,
) -> Vec<BookRow> {
let scale = 10_f64.powi(price_decimals as i32);
let mut by_price: Vec<(i64, BookRow)> = Vec::new();
for (price, size, trader, source) in raw {
let key = (price * scale).round() as i64;
match by_price.iter_mut().find(|(k, _)| *k == key) {
Some((_, row)) => {
row.size += size;
row.traders.push((trader, source));
}
None => {
by_price.push((
key,
BookRow {
price,
size,
traders: vec![(trader, source)],
has_hidden_fill: false,
iceberg_trader_prefix: None,
},
));
}
}
}
let mut rows: Vec<BookRow> = by_price.into_iter().map(|(_, r)| r).collect();
if is_bid {
rows.sort_by(|a, b| {
b.price
.partial_cmp(&a.price)
.unwrap_or(std::cmp::Ordering::Equal)
});
} else {
rows.sort_by(|a, b| {
a.price
.partial_cmp(&b.price)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
rows
}
fn apply_iceberg_markers(rows: &mut [BookRow], markers: &[(f64, String)], price_decimals: usize) {
if markers.is_empty() {
return;
}
let scale = 10_f64.powi(price_decimals as i32);
let key_of = |p: f64| (p * scale).round() as i64;
for (price, trader_prefix) in markers {
let key = key_of(*price);
if let Some(row) = rows.iter_mut().find(|r| key_of(r.price) == key) {
row.has_hidden_fill = true;
if row.iceberg_trader_prefix.is_none() {
row.iceberg_trader_prefix = Some(trader_prefix.clone());
}
}
}
}
#[cfg(test)]
#[path = "tui_tests.rs"]
mod tests;