Skip to main content

cinder/tui/state/
market.rs

1//! Market info and market selector state.
2
3use std::time::{Duration, Instant};
4
5use phoenix_rise::types::market::MarketStatsUpdate;
6
7use super::super::format::fmt_price;
8use super::super::math::pct_change_24h;
9
10/// Brief highlight on the trailing digits that changed after a mark-price tick.
11#[derive(Clone, Copy, Debug)]
12pub struct PriceFlash {
13    pub until: Instant,
14    pub up: bool,
15    /// Byte index in the `$…` display string where the highlight begins.
16    pub from: usize,
17}
18
19const PRICE_FLASH_DURATION: Duration = Duration::from_secs(1);
20
21#[derive(Clone)]
22pub struct MarketInfo {
23    pub symbol: String,
24    pub price: f64,
25    pub volume_24h: f64,
26    pub open_interest_usd: f64,
27    pub max_leverage: f64,
28    pub change_24h: f64,
29    pub price_decimals: usize,
30    pub isolated_only: bool,
31    pub price_flash: Option<PriceFlash>,
32}
33
34/// Longest shared prefix between two formatted price strings (byte length).
35pub fn price_display_prefix_len(old: &str, new: &str) -> usize {
36    let mut len = 0;
37    for (a, b) in old.chars().zip(new.chars()) {
38        if a != b {
39            break;
40        }
41        len += a.len_utf8();
42    }
43    len
44}
45
46pub struct MarketSelector {
47    pub markets: Vec<MarketInfo>,
48    pub selected_index: usize,
49}
50
51impl MarketSelector {
52    pub fn new(markets: Vec<MarketInfo>) -> Self {
53        Self {
54            markets,
55            selected_index: 0,
56        }
57    }
58
59    pub fn move_up(&mut self) {
60        if self.selected_index > 0 {
61            self.selected_index -= 1;
62        }
63    }
64
65    pub fn move_down(&mut self) {
66        if self.selected_index + 1 < self.markets.len() {
67            self.selected_index += 1;
68        }
69    }
70
71    pub fn selected_symbol(&self) -> Option<&str> {
72        self.markets
73            .get(self.selected_index)
74            .map(|s| s.symbol.as_str())
75    }
76
77    pub fn focus_on(&mut self, symbol: &str) {
78        if let Some(idx) = self.markets.iter().position(|m| m.symbol == symbol) {
79            self.selected_index = idx;
80        }
81    }
82
83    /// Display precision for `symbol`'s mark price (tick-derived), or a
84    /// conservative default if the market list has not loaded that symbol
85    /// yet.
86    pub fn price_decimals_for_symbol(&self, symbol: &str) -> usize {
87        const FALLBACK: usize = 8;
88        self.markets
89            .iter()
90            .find(|m| m.symbol == symbol)
91            .map(|m| m.price_decimals)
92            .unwrap_or(FALLBACK)
93    }
94
95    /// Append newly discovered markets (skips symbols already present).
96    pub fn add_markets(&mut self, new_markets: Vec<MarketInfo>) {
97        for m in new_markets {
98            if !self
99                .markets
100                .iter()
101                .any(|existing| existing.symbol == m.symbol)
102            {
103                self.markets.push(m);
104            }
105        }
106        self.sort_by_volume_desc();
107    }
108
109    /// Ensure `selected_index` stays in bounds after a list mutation.
110    fn clamp_index(&mut self) {
111        if !self.markets.is_empty() {
112            self.selected_index = self.selected_index.min(self.markets.len() - 1);
113        } else {
114            self.selected_index = 0;
115        }
116    }
117
118    /// Re-sort by 24h volume (highest first) and keep the same market selected.
119    fn sort_by_volume_desc(&mut self) {
120        let selected = self.selected_symbol().map(std::string::String::from);
121        self.markets.sort_by(|a, b| {
122            b.volume_24h
123                .partial_cmp(&a.volume_24h)
124                .unwrap_or(std::cmp::Ordering::Equal)
125        });
126        if let Some(sym) = selected {
127            self.focus_on(&sym);
128        }
129        self.clamp_index();
130    }
131
132    /// Update price/volume/change for a single market from a live stat event.
133    ///
134    /// Only resorts when the *new* `volume_24h` would actually change the
135    /// symbol's rank against its neighbours. Stat pushes fire continuously
136    /// across every subscribed market and the previous unconditional sort
137    /// (O(N log N) per push × N markets) was a real CPU drain; in steady
138    /// state most pushes shift volume by tiny amounts and don't change order.
139    pub fn update_stat(&mut self, update: &MarketStatsUpdate) {
140        let Some(idx) = self.markets.iter().position(|m| m.symbol == update.symbol) else {
141            return;
142        };
143        let new_vol = update.day_volume_usd;
144        let prev_vol = self.markets[idx].volume_24h;
145        {
146            let m = &mut self.markets[idx];
147            let prev_price = m.price;
148            let decimals = m.price_decimals;
149            m.price = update.mark_price;
150            m.volume_24h = new_vol;
151            m.open_interest_usd = update.open_interest * update.mark_price;
152            m.change_24h = pct_change_24h(update.mark_price, update.prev_day_mark_price);
153
154            if prev_price > 0.0
155                && update.mark_price > 0.0
156                && (update.mark_price - prev_price).abs() > f64::EPSILON
157            {
158                let old_str = format!("${}", fmt_price(prev_price, decimals));
159                let new_str = format!("${}", fmt_price(update.mark_price, decimals));
160                let from = price_display_prefix_len(&old_str, &new_str);
161                if from < new_str.len() {
162                    m.price_flash = Some(PriceFlash {
163                        until: Instant::now() + PRICE_FLASH_DURATION,
164                        up: update.mark_price > prev_price,
165                        from,
166                    });
167                }
168            }
169        }
170
171        if new_vol == prev_vol {
172            return;
173        }
174        let above_ok = idx == 0 || self.markets[idx - 1].volume_24h >= new_vol;
175        let below_ok = idx + 1 >= self.markets.len() || self.markets[idx + 1].volume_24h <= new_vol;
176        if above_ok && below_ok {
177            return;
178        }
179        self.sort_by_volume_desc();
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn make_market(symbol: &str, volume: f64, price_decimals: usize) -> MarketInfo {
188        MarketInfo {
189            symbol: symbol.to_string(),
190            price: 100.0,
191            volume_24h: volume,
192            open_interest_usd: 0.0,
193            max_leverage: 10.0,
194            change_24h: 0.0,
195            price_decimals,
196            isolated_only: false,
197            price_flash: None,
198        }
199    }
200
201    fn make_stat(symbol: &str, mark: f64, prev: f64, vol: f64, oi: f64) -> MarketStatsUpdate {
202        MarketStatsUpdate {
203            symbol: symbol.to_string(),
204            open_interest: oi,
205            mark_price: mark,
206            mid_price: mark,
207            oracle_price: mark,
208            prev_day_mark_price: prev,
209            day_volume_usd: vol,
210            funding_rate: 0.0,
211        }
212    }
213
214    #[test]
215    fn move_up_at_top_is_a_no_op() {
216        let mut s = MarketSelector::new(vec![make_market("SOL", 1.0, 2)]);
217        s.move_up();
218        assert_eq!(s.selected_index, 0);
219    }
220
221    #[test]
222    fn move_down_stops_at_last_market() {
223        let mut s =
224            MarketSelector::new(vec![make_market("SOL", 2.0, 2), make_market("BTC", 1.0, 1)]);
225        s.move_down();
226        s.move_down();
227        s.move_down();
228        assert_eq!(s.selected_index, 1);
229        assert_eq!(s.selected_symbol(), Some("BTC"));
230    }
231
232    #[test]
233    fn focus_on_jumps_to_known_symbol() {
234        let mut s =
235            MarketSelector::new(vec![make_market("SOL", 2.0, 2), make_market("BTC", 1.0, 1)]);
236        s.focus_on("BTC");
237        assert_eq!(s.selected_symbol(), Some("BTC"));
238    }
239
240    #[test]
241    fn focus_on_unknown_symbol_is_a_no_op() {
242        let mut s = MarketSelector::new(vec![make_market("SOL", 2.0, 2)]);
243        s.focus_on("DOES-NOT-EXIST");
244        assert_eq!(s.selected_index, 0);
245    }
246
247    #[test]
248    fn price_decimals_falls_back_when_symbol_missing() {
249        let s = MarketSelector::new(vec![make_market("SOL", 1.0, 3)]);
250        assert_eq!(s.price_decimals_for_symbol("SOL"), 3);
251        assert_eq!(s.price_decimals_for_symbol("BTC"), 8);
252    }
253
254    #[test]
255    fn add_markets_dedups_and_sorts_by_volume() {
256        let mut s = MarketSelector::new(vec![make_market("SOL", 2.0, 2)]);
257        s.add_markets(vec![
258            make_market("SOL", 99.0, 2), // dup ignored
259            make_market("BTC", 5.0, 1),
260            make_market("ETH", 1.0, 1),
261        ]);
262        let symbols: Vec<&str> = s.markets.iter().map(|m| m.symbol.as_str()).collect();
263        assert_eq!(symbols, vec!["BTC", "SOL", "ETH"]);
264    }
265
266    #[test]
267    fn add_markets_keeps_selection_on_same_symbol() {
268        let mut s =
269            MarketSelector::new(vec![make_market("SOL", 2.0, 2), make_market("ETH", 1.0, 1)]);
270        s.focus_on("ETH");
271        s.add_markets(vec![make_market("BTC", 99.0, 1)]);
272        // BTC sorts first by volume; ETH selection should follow the symbol.
273        assert_eq!(s.selected_symbol(), Some("ETH"));
274    }
275
276    #[test]
277    fn update_stat_writes_fields_and_resorts() {
278        let mut s =
279            MarketSelector::new(vec![make_market("SOL", 1.0, 2), make_market("BTC", 2.0, 1)]);
280        s.update_stat(&make_stat("SOL", 110.0, 100.0, 100.0, 5.0));
281        let symbols: Vec<&str> = s.markets.iter().map(|m| m.symbol.as_str()).collect();
282        assert_eq!(symbols, vec!["SOL", "BTC"]);
283        let sol = s.markets.iter().find(|m| m.symbol == "SOL").unwrap();
284        assert_eq!(sol.price, 110.0);
285        assert_eq!(sol.volume_24h, 100.0);
286        assert!((sol.change_24h - 10.0).abs() < 1e-9);
287        assert!((sol.open_interest_usd - 5.0 * 110.0).abs() < 1e-9);
288    }
289
290    #[test]
291    fn update_stat_is_a_no_op_for_unknown_symbol() {
292        let mut s = MarketSelector::new(vec![make_market("SOL", 1.0, 2)]);
293        s.update_stat(&make_stat("BTC", 99.0, 100.0, 50.0, 1.0));
294        assert_eq!(s.markets[0].price, 100.0);
295    }
296
297    #[test]
298    fn price_display_prefix_len_highlights_trailing_change() {
299        let old = format!("${}", fmt_price(1000.0, 2));
300        let new = format!("${}", fmt_price(1000.10, 2));
301        let from = price_display_prefix_len(&old, &new);
302        assert_eq!(&old[from..], "00");
303        assert_eq!(&new[from..], "10");
304    }
305
306    #[test]
307    fn update_stat_sets_price_flash_on_tick() {
308        let mut s = MarketSelector::new(vec![make_market("SOL", 1.0, 2)]);
309        s.markets[0].price = 1000.0;
310        s.update_stat(&make_stat("SOL", 1000.10, 1000.0, 1.0, 1.0));
311        let flash = s.markets[0].price_flash.expect("flash");
312        assert!(flash.up);
313        let shown = format!("${}", fmt_price(1000.10, 2));
314        assert_eq!(&shown[flash.from..], "10");
315    }
316}