1use 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 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 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 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 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 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), 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 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}