Skip to main content

flowsurface_data/
tickers_table.rs

1use exchange::{
2    Ticker, TickerStats,
3    adapter::{Exchange, MarketKind, Venue},
4    unit::{MinTicksize, price::Price},
5};
6use serde::{Deserialize, Serialize};
7use std::cmp::Ordering;
8
9#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
10pub struct Settings {
11    pub favorited_tickers: Vec<Ticker>,
12    pub show_favorites: bool,
13    pub selected_sort_option: SortOptions,
14    pub selected_exchanges: Vec<Venue>,
15    pub selected_markets: Vec<MarketKind>,
16}
17
18impl Default for Settings {
19    fn default() -> Self {
20        Self {
21            favorited_tickers: vec![],
22            show_favorites: false,
23            selected_sort_option: SortOptions::VolumeDesc,
24            selected_exchanges: Venue::ALL.to_vec(),
25            selected_markets: MarketKind::ALL.into_iter().collect(),
26        }
27    }
28}
29
30#[derive(Default, Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
31pub enum SortOptions {
32    #[default]
33    VolumeAsc,
34    VolumeDesc,
35    ChangeAsc,
36    ChangeDesc,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum PriceChange {
41    Increased,
42    Decreased,
43}
44
45#[derive(Clone, Copy)]
46pub struct TickerRowData {
47    pub exchange: Exchange,
48    pub ticker: Ticker,
49    pub stats: TickerStats,
50    pub previous_stats: Option<TickerStats>,
51    pub is_favorited: bool,
52}
53
54#[derive(Clone)]
55pub struct TickerDisplayData {
56    pub display_ticker: String,
57    pub daily_change_pct: String,
58    pub volume_display: String,
59    pub mark_price_display: Option<String>,
60    pub price_unchanged_part: Option<String>,
61    pub price_changed_part: Option<String>,
62    pub price_change: Option<PriceChange>,
63    pub card_color_alpha: f32,
64}
65
66pub fn compare_ticker_rows_by_sort(
67    a: &TickerRowData,
68    b: &TickerRowData,
69    selected_sort_option: SortOptions,
70) -> Ordering {
71    match selected_sort_option {
72        SortOptions::VolumeDesc => b.stats.daily_volume.cmp(&a.stats.daily_volume),
73        SortOptions::VolumeAsc => a.stats.daily_volume.cmp(&b.stats.daily_volume),
74        SortOptions::ChangeDesc => b.stats.daily_price_chg.total_cmp(&a.stats.daily_price_chg),
75        SortOptions::ChangeAsc => a.stats.daily_price_chg.total_cmp(&b.stats.daily_price_chg),
76    }
77}
78
79/// Rank for search matching (lower = better).
80///
81/// Bucket match kind first, then apply selected sort as the primary tiebreaker:
82/// exact > prefix > suffix > substring > (no match)
83///
84/// Length is only used as a last-resort tiebreak (after sort), to avoid
85/// "shortest label wins" outcomes for queries like "USDTP".
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub struct SearchRank {
88    pub bucket: u8,
89    pub pos: u16,
90    pub len: u16,
91}
92
93/// Calculates a search rank for matching (lower = better match).
94pub fn calc_search_rank(ticker: &Ticker, query: &str) -> Option<SearchRank> {
95    if query.is_empty() {
96        return Some(SearchRank {
97            bucket: 0,
98            pos: 0,
99            len: 0,
100        });
101    }
102
103    let (mut display_str, _) = ticker.display_symbol_and_type();
104    let (mut raw_str, _) = ticker.to_full_symbol_and_type();
105
106    display_str.make_ascii_uppercase();
107    raw_str.make_ascii_uppercase();
108
109    let suffix = market_suffix(ticker.market_type());
110    let is_perp = !suffix.is_empty();
111
112    let display_suffixed = format!("{display_str}{suffix}");
113    let raw_suffixed = format!("{raw_str}{suffix}");
114
115    // For perps: do NOT allow "exact match" on the unsuffixed candidates, since the UI
116    // label is effectively suffixed (e.g., "...P") and unsuffixed exact hits are misleading.
117    let score_candidate = |cand: &str, allow_exact: bool| -> Option<SearchRank> {
118        let (bucket, pos) = if allow_exact && cand == query {
119            (0_u8, 0_usize) // exact
120        } else if cand.starts_with(query) {
121            (1_u8, 0_usize) // prefix
122        } else if cand.ends_with(query) {
123            (2_u8, 0_usize) // suffix
124        } else if let Some(p) = cand.find(query) {
125            (3_u8, p) // substring
126        } else {
127            return None;
128        };
129
130        Some(SearchRank {
131            bucket,
132            pos: (pos.min(u16::MAX as usize)) as u16,
133            len: (cand.len().min(u16::MAX as usize)) as u16,
134        })
135    };
136
137    let mut best: Option<SearchRank> = None;
138
139    // consider both "display" and "raw" representations, but with
140    // explicit match-kind bucketing + a perp exact-match rule.
141    for (cand, allow_exact) in [
142        (display_str.as_str(), !is_perp),
143        (display_suffixed.as_str(), true),
144        (raw_str.as_str(), !is_perp),
145        (raw_suffixed.as_str(), true),
146    ] {
147        let Some(rank) = score_candidate(cand, allow_exact) else {
148            continue;
149        };
150
151        best = Some(match best {
152            None => rank,
153            Some(cur) => {
154                // Lower bucket wins; then earlier position; then shorter candidate.
155                if (rank.bucket, rank.pos, rank.len) < (cur.bucket, cur.pos, cur.len) {
156                    rank
157                } else {
158                    cur
159                }
160            }
161        });
162    }
163
164    best
165}
166
167pub fn market_suffix(market: MarketKind) -> &'static str {
168    match market {
169        MarketKind::Spot => "",
170        MarketKind::LinearPerps | MarketKind::InversePerps => "P",
171    }
172}
173
174pub fn compute_display_data(
175    ticker: &Ticker,
176    stats: &TickerStats,
177    previous_price: Option<Price>,
178    precision: Option<MinTicksize>,
179) -> TickerDisplayData {
180    let (display_ticker, _market) = ticker.display_symbol_and_type();
181
182    let current_price = stats.mark_price;
183    let current_price_display = price_to_display_string(current_price, precision);
184    let price_parts = previous_price
185        .and_then(|prev_price| split_price_changes(prev_price, current_price, precision))
186        .or_else(|| {
187            current_price_display
188                .clone()
189                .map(|price| (price, String::new(), None))
190        });
191
192    let (price_unchanged_part, price_changed_part, price_change) = price_parts
193        .map(|(unchanged, changed, change)| (Some(unchanged), Some(changed), change))
194        .unwrap_or((None, None, None));
195
196    TickerDisplayData {
197        display_ticker,
198        daily_change_pct: super::util::pct_change(stats.daily_price_chg),
199        volume_display: super::util::currency_abbr(stats.daily_volume.to_f32_lossy()),
200        mark_price_display: current_price_display,
201        price_unchanged_part,
202        price_changed_part,
203        price_change,
204        card_color_alpha: { (stats.daily_price_chg / 8.0).clamp(-1.0, 1.0) },
205    }
206}
207
208fn split_price_changes(
209    previous_price: Price,
210    current_price: Price,
211    precision: Option<MinTicksize>,
212) -> Option<(String, String, Option<PriceChange>)> {
213    let curr_str = price_to_display_string(current_price, precision)?;
214
215    if previous_price == current_price {
216        return Some((curr_str, String::new(), None));
217    }
218
219    let prev_str = price_to_display_string(previous_price, precision)?;
220
221    if prev_str == curr_str {
222        return Some((curr_str, String::new(), None));
223    }
224
225    let direction = Some(if current_price > previous_price {
226        PriceChange::Increased
227    } else {
228        PriceChange::Decreased
229    });
230
231    let split_index = prev_str
232        .bytes()
233        .zip(curr_str.bytes())
234        .position(|(prev, curr)| prev != curr)
235        .unwrap_or_else(|| prev_str.len().min(curr_str.len()));
236
237    let unchanged_part = curr_str[..split_index].to_string();
238    let changed_part = curr_str[split_index..].to_string();
239
240    Some((unchanged_part, changed_part, direction))
241}
242
243fn price_to_display_string(price: Price, precision: Option<MinTicksize>) -> Option<String> {
244    precision.map(|precision| price.to_string(precision))
245}