cosmic-cinder 0.1.12

Rust terminal UI for Phoenix perpetuals on Solana
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
//! TuiState: the top-level mutable runtime state for the TUI.

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>,
    /// Most-recent `MarketStatsUpdate` per symbol. Populated for every market
    /// the stats stream emits, not just the active one, so a market switch can
    /// seed the header instantly instead of flashing "Waiting for market
    /// data…" until the next push for the new market arrives.
    pub market_stats_cache: HashMap<String, phoenix_rise::MarketStatsUpdate>,
    /// Last Phoenix CLOB L2 snapshot (bids best-first, asks best-first) for the
    /// active market. Poller filters by symbol before writing these, so
    /// stale rows don't appear during market switches.
    pub clob_bids: Vec<ClobLevel>,
    pub clob_asks: Vec<ClobLevel>,
    /// Spline+CLOB merged view, rebuilt whenever either source updates. This is
    /// what the book table renders.
    pub merged_book: MergedBook,
    pub last_parsed: Option<ParsedSplineData>,
    pub last_slot: u64,
    /// Chart corner `HH:MM:SS`; updated only on the 1s timer in `poller` so
    /// seconds don't drift with feed FPS.
    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,
    /// Top-N largest positions across the protocol (on-chain ActiveTraderBuffer
    /// scan).
    pub top_positions_view: TopPositionsView,
    /// Live liquidation feed: most-recent `LiquidationEvent`s decoded from
    /// inner instructions on Phoenix Eternal txs.
    pub liquidation_feed_view: LiquidationFeedView,
    /// One chart marker per active-market open order, keyed by `(symbol,
    /// subaccount_index, order_sequence_number)`. Kept separate from `orders_view` because it
    /// tracks chart-geometry state (x-coordinate that scrolls with
    /// `price_history`), whereas `orders_view` is pure snapshot data.
    pub order_chart_markers: HashMap<(String, u8, u64), OrderChartMarker>,
    /// Set to the target symbol while a market switch is in-flight. The old
    /// chart/book data stays visible until the first WSS payload arrives for
    /// the new market, at which point this is cleared.
    pub switching_to: Option<String>,
    // Rebuilt only when price_history mutates; avoids per-frame allocation/scan.
    chart_data_cache: Vec<(f64, f64)>,
    price_bounds_cache: (f64, f64),
    // Running min/max over `price_history` (no margin). Sentinel ±inf means "empty".
    // Lets `push_price` fold new samples in O(1) and rescan only when the popped sample
    // was an extremum.
    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,
        }
    }

    /// Marks a market switch in-flight. Old chart/book data stays visible
    /// until [`complete_market_switch`](Self::complete_market_switch) is called
    /// when the first WSS payload for the new market arrives.
    ///
    /// `merged_book` is intentionally not reset — the user has asked to keep
    /// the previous market's order book visible during the switch rather than
    /// flashing an empty book. `clob_*` are cleared so the next rebuild after
    /// the switch completes doesn't carry stale CLOB rows from the prior
    /// market alongside fresh spline rows; while `switching_to` is set,
    /// [`rebuild_merged_book`](Self::rebuild_merged_book) is a no-op so any
    /// CLOB writes that arrive mid-switch don't pollute the stale view.
    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();
        // Seed the header from the cache so the user doesn't see the old
        // market's numbers (or "Waiting for market data…") under the new
        // symbol. Cache miss → None, which is the same fallback as before.
        self.market_stats = self.market_stats_cache.get(target_symbol).cloned();
        // Clear per-market trading state that shouldn't carry over.
        self.trading.position = None;
        self.trading.order_kind = super::super::trading::OrderKind::Market;
    }

    /// Rebuild [`merged_book`](Self::merged_book) from `last_parsed` (splines)
    /// and `clob_*` (CLOB L2). Call after either source changes. Rows are
    /// sorted best-first on each side. When `show_clob` is false, CLOB rows
    /// are omitted and only spline rows are included.
    ///
    /// `gti_cache` resolves each spline's trader PDA to its wallet authority
    /// prefix so spline rows share the same identity namespace as CLOB rows
    /// (where the user recognises their wallet). Rows whose PDA isn't in the
    /// cache yet fall back to the PDA prefix until the next refresh.
    pub fn rebuild_merged_book(
        &mut self,
        symbol: &str,
        show_clob: bool,
        gti_cache: Option<&GtiCache>,
        price_decimals: usize,
    ) {
        // Mid-switch: keep the prior merged book on screen. CLOB writes that
        // arrive before the first spline payload (or vice versa) would
        // otherwise produce a mixed-source book under the new symbol — visibly
        // wrong. `complete_market_switch` clears `switching_to`, after which
        // the next call here renders fresh data.
        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),
            }
        };

        // Per-side raw quotes before grouping. Splines collapse to a single
        // point at their most aggressive price (price_start of the region) so
        // the rendered book reads like a normal CLOB.
        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,
        };
    }

    /// Called once the first new-market data arrives. Flushes stale chart
    /// data so the new market starts with a clean slate.
    pub fn complete_market_switch(&mut self) {
        self.switching_to = None;
        self.price_history.clear();
        self.trade_markers.clear();
        // Old market's order markers don't belong on the new chart (and their x would
        // be stale).
        self.order_chart_markers.clear();
        self.last_parsed = None;
        // Note: `market_stats` is intentionally not cleared here — it was
        // already seeded from the cache in `begin_market_switch` and live
        // stat updates keep refreshing it. Clearing here would cause a
        // visible "Waiting for market data…" flash between the first spline
        // payload and the next stats push.
        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);
            // Order markers scroll with the chart too, but we do NOT prune them when x < 0:
            // the order is still live on the book and needs its square/letter re-rendered
            // if the y-range shifts back into view. Chart widget clips
            // out-of-bound x internally.
            for marker in self.order_chart_markers.values_mut() {
                marker.x -= 1.0;
            }
            // Rebuild from price_history (already contains new price) rather than
            // shifting all y-values left in place.
            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));
        }

        // Rescan only when the popped sample was an extremum (removing it may have
        // widened the band) or when running bounds are uninitialized. Otherwise
        // fold `mid` in O(1).
        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);
        }
    }

    /// Reconcile `order_chart_markers` against the current WS snapshot for
    /// `active_symbol`. New (symbol, subaccount, seq) keys are inserted at the current
    /// right-edge x; keys no longer present in the snapshot are removed
    /// (fill / cancel). Price is refreshed from the snapshot in
    /// case the order was amended.
    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
    }
}

/// Collapse `(price, size, trader, source)` quotes into one [`BookRow`] per
/// distinct price, summing sizes and concatenating traders. Sorted best-first
/// (descending for bids, ascending for asks).
///
/// Grouping key is the price rounded to `price_decimals` (the market's tick
/// precision). f64 bit equality isn't enough on its own: a spline price is
/// computed as `mid - ticks_to_price(offset)`, which can differ in the last
/// ULP from the same tick reached directly via `ticks_to_price`, so a CLOB
/// and a spline quote at the same tick would otherwise render as two rows.
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
}

/// Sets `has_hidden_fill` (and `iceberg_trader_prefix`) on each row whose
/// price matches one of the supplied markers. Marker prices are computed at
/// `price_at_offset(end_offset)` for each spline region with a hidden iceberg,
/// which is one tick further from mid than the region's worst visible tick.
/// They typically coincide with the next-outer region's worst tick; markers
/// with no matching row are silently dropped.
///
/// Matching uses integer tick keys (rounded to `price_decimals`) so spline
/// prices computed from different mid values still collide cleanly with the
/// row prices produced by `group_by_price`.
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;
            // First marker wins on collisions — if two splines project icebergs
            // onto the same row we keep the earlier owner rather than overwriting.
            if row.iceberg_trader_prefix.is_none() {
                row.iceberg_trader_prefix = Some(trader_prefix.clone());
            }
        }
    }
}

#[cfg(test)]
#[path = "tui_tests.rs"]
mod tests;