Skip to main content

cinder/tui/state/
market.rs

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