Skip to main content

egui_charts/services/
search.rs

1//! Symbol search.
2
3mod expression;
4
5pub use expression::{ExpressionError, SymbolExpression};
6
7use crate::data::{MaybeSend, Reply};
8use crate::model::{Instrument, SymbolType};
9
10/// Finds instruments for the symbol search dialog, from a source that need not be the feed's.
11///
12/// The terminal calls [`search`](Self::search) on the UI thread as the user types, and shows the
13/// answer to the newest query only. Return at once and answer once through `reply`, from any
14/// thread, runtime or browser task; an answer to a query the user has since changed is ignored,
15/// so there is nothing to cancel. The symbol the user picks is loaded from the
16/// [`Datafeed`](crate::data::Datafeed), so answer with [`symbol`](Instrument::symbol)s as the feed
17/// knows them. A fixed list of instruments is a service on its own: see the
18/// implementation for `Vec<Instrument>`.
19///
20/// ```
21/// use egui_charts::data::{FeedError, Reply};
22/// use egui_charts::model::Instrument;
23/// use egui_charts::services::{SymbolQuery, SymbolSearch};
24///
25/// /// Asks a reference-data service from a worker thread.
26/// struct ReferenceData;
27///
28/// impl SymbolSearch for ReferenceData {
29///     fn search(&self, query: SymbolQuery, reply: Reply<Vec<Instrument>>) {
30///         std::thread::spawn(move || reply.send(lookup(&query)));
31///     }
32/// }
33///
34/// /// Stands in for the request to the service.
35/// fn lookup(query: &SymbolQuery) -> Result<Vec<Instrument>, FeedError> {
36///     let apple = Instrument::new("AAPL")
37///         .with_description("Apple Inc.")
38///         .with_exchange("NASDAQ");
39///     Ok([apple].into_iter().skip(query.offset).take(query.limit).collect())
40/// }
41/// ```
42pub trait SymbolSearch: MaybeSend + 'static {
43    /// Up to `query.limit` instruments matching `query`, best first.
44    fn search(&self, query: SymbolQuery, reply: Reply<Vec<Instrument>>);
45}
46
47/// What the user typed into the symbol search dialog, and the filters chosen there.
48#[derive(Clone, Debug, PartialEq)]
49#[non_exhaustive]
50pub struct SymbolQuery {
51    /// The typed text, as typed. Empty asks for any instrument that passes the filters.
52    pub text: String,
53    /// Only instruments of this type, when set.
54    pub symbol_type: Option<SymbolType>,
55    /// Only instruments of this exchange, when set.
56    pub exchange: Option<String>,
57    /// Matching results to skip, for the next page.
58    pub offset: usize,
59    /// Results wanted at most.
60    pub limit: usize,
61}
62
63#[cfg(feature = "http")]
64impl SymbolSearch for crate::data::http::HttpDatafeed {
65    fn search(&self, query: SymbolQuery, reply: Reply<Vec<Instrument>>) {
66        self.search_symbols(
67            query.text,
68            query.symbol_type,
69            query.exchange,
70            query.offset,
71            query.limit,
72            reply,
73        );
74    }
75}
76
77/// A fixed list, answered inline: the instruments whose symbol or description contains the text,
78/// ignoring case, and whose type and exchange equal the filters that are set. An instrument whose
79/// symbol equals the text comes first, then those whose symbol starts with it, then the rest, each
80/// group in list order.
81impl SymbolSearch for Vec<Instrument> {
82    fn search(&self, query: SymbolQuery, reply: Reply<Vec<Instrument>>) {
83        let text = query.text.to_lowercase();
84        let mut found: Vec<_> = self
85            .iter()
86            .filter(|instrument| {
87                (query.symbol_type.is_none() || query.symbol_type == instrument.symbol_type)
88                    && (query.exchange.is_none() || query.exchange == instrument.exchange)
89            })
90            .filter_map(|instrument| {
91                let symbol = instrument.symbol.to_lowercase();
92                let described = || {
93                    let description = instrument.description.as_deref().unwrap_or_default();
94                    description.to_lowercase().contains(&text)
95                };
96                let rank = if symbol == text {
97                    0
98                } else if symbol.starts_with(&text) {
99                    1
100                } else if symbol.contains(&text) || described() {
101                    2
102                } else {
103                    return None;
104                };
105                Some((rank, instrument))
106            })
107            .collect();
108        found.sort_by_key(|&(rank, _)| rank);
109        let found = found.into_iter().skip(query.offset).take(query.limit);
110        reply.send(Ok(found
111            .map(|(_, instrument)| instrument.clone())
112            .collect()));
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::data::FeedError;
120    use crate::data::inbox::Inbox;
121    use std::sync::Arc;
122
123    /// Guards the fallback's contract with the dialog: matching ignores case and reaches the
124    /// description, each filter excludes what it should (also an instrument without the field),
125    /// an exact symbol outranks a longer one listed first, a prefix outranks a match elsewhere
126    /// listed first, and pagination cuts after ranking.
127    #[test]
128    fn list_search_matches_and_filters() {
129        let list = vec![
130            Instrument::new("BTCUSD").with_symbol_type(SymbolType::Crypto),
131            Instrument::new("SPYG")
132                .with_description("SPDR Portfolio S&P 500 Growth ETF")
133                .with_symbol_type(SymbolType::Fund)
134                .with_exchange("NYSE"),
135            Instrument::new("SPY")
136                .with_description("SPDR S&P 500 ETF Trust")
137                .with_symbol_type(SymbolType::Fund)
138                .with_exchange("NYSE"),
139            Instrument::new("AAPL")
140                .with_description("Apple Inc.")
141                .with_symbol_type(SymbolType::Stock)
142                .with_exchange("NASDAQ"),
143        ];
144        let query = |text: &str, symbol_type, exchange: Option<&str>, offset, limit| SymbolQuery {
145            text: text.into(),
146            symbol_type,
147            exchange: exchange.map(Into::into),
148            limit,
149            offset,
150        };
151        let cases = [
152            (query("aapl", None, None, 0, 10), &["AAPL"][..]),
153            (query("APPLE", None, None, 0, 10), &["AAPL"]),
154            (query("s&p 500", None, None, 0, 10), &["SPYG", "SPY"]),
155            (query("s&p 500", None, None, 1, 10), &["SPY"]),
156            (query("spy", None, None, 2, 10), &[]),
157            (query("spy", None, None, 0, 10), &["SPY", "SPYG"]),
158            (query("spy", None, None, 0, 1), &["SPY"]),
159            (
160                query("", Some(SymbolType::Crypto), None, 0, 10),
161                &["BTCUSD"],
162            ),
163            (query("", None, Some("NASDAQ"), 0, 10), &["AAPL"]),
164            (query("s", None, None, 0, 10), &["SPYG", "SPY", "BTCUSD"]),
165            (query("xyz", None, None, 0, 10), &[]),
166        ];
167        for (query, expected) in cases {
168            let inbox = Arc::new(Inbox::default());
169            list.search(query.clone(), inbox.reply(0));
170            let answers = inbox.drain().answers;
171            assert_eq!(answers.len(), 1, "{query:?} not answered inline");
172            let (_, answer) = answers.into_iter().next().unwrap();
173            let found = answer
174                .downcast::<Result<Vec<Instrument>, FeedError>>()
175                .unwrap();
176            let symbols: Vec<_> = found.unwrap().into_iter().map(|i| i.symbol).collect();
177            assert_eq!(symbols, expected, "{query:?}");
178        }
179    }
180}