1use 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#[derive(Clone, Copy, Debug)]
12pub struct PriceFlash {
13 pub until: Instant,
14 pub up: bool,
15 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
34pub 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 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 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 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 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 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), 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 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}