Skip to main content

tvdata_rs/scanner/
field.rs

1use std::borrow::Cow;
2use std::fmt;
3
4use serde::{Serialize, Serializer};
5
6use crate::scanner::filter::{
7    FilterCondition, FilterOperator, IntoFilterValue, SortOrder, SortSpec,
8};
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)]
11pub struct Column(Cow<'static, str>);
12
13impl Column {
14    pub const fn from_static(name: &'static str) -> Self {
15        Self(Cow::Borrowed(name))
16    }
17
18    pub fn new(name: impl Into<Cow<'static, str>>) -> Self {
19        Self(name.into())
20    }
21
22    pub fn as_str(&self) -> &str {
23        self.0.as_ref()
24    }
25
26    pub fn with_interval(&self, interval: &str) -> Self {
27        Self::new(format!("{}|{interval}", self.as_str()))
28    }
29
30    pub fn with_history(&self, periods: u16) -> Self {
31        Self::new(format!("{}[{periods}]", self.as_str()))
32    }
33
34    pub fn recommendation(&self) -> Self {
35        Self::new(format!("Rec.{}", self.as_str()))
36    }
37
38    pub fn gt(self, value: impl IntoFilterValue) -> FilterCondition {
39        FilterCondition::new(self, FilterOperator::Greater, value.into_filter_value())
40    }
41
42    pub fn ge(self, value: impl IntoFilterValue) -> FilterCondition {
43        FilterCondition::new(self, FilterOperator::EGreater, value.into_filter_value())
44    }
45
46    pub fn lt(self, value: impl IntoFilterValue) -> FilterCondition {
47        FilterCondition::new(self, FilterOperator::Less, value.into_filter_value())
48    }
49
50    pub fn le(self, value: impl IntoFilterValue) -> FilterCondition {
51        FilterCondition::new(self, FilterOperator::ELess, value.into_filter_value())
52    }
53
54    pub fn eq(self, value: impl IntoFilterValue) -> FilterCondition {
55        FilterCondition::new(self, FilterOperator::Equal, value.into_filter_value())
56    }
57
58    pub fn ne(self, value: impl IntoFilterValue) -> FilterCondition {
59        FilterCondition::new(self, FilterOperator::NotEqual, value.into_filter_value())
60    }
61
62    pub fn between(
63        self,
64        lower: impl IntoFilterValue,
65        upper: impl IntoFilterValue,
66    ) -> FilterCondition {
67        FilterCondition::new(
68            self,
69            FilterOperator::InRange,
70            vec![lower.into_filter_value(), upper.into_filter_value()].into_filter_value(),
71        )
72    }
73
74    pub fn not_between(
75        self,
76        lower: impl IntoFilterValue,
77        upper: impl IntoFilterValue,
78    ) -> FilterCondition {
79        FilterCondition::new(
80            self,
81            FilterOperator::NotInRange,
82            vec![lower.into_filter_value(), upper.into_filter_value()].into_filter_value(),
83        )
84    }
85
86    pub fn isin<I, V>(self, values: I) -> FilterCondition
87    where
88        I: IntoIterator<Item = V>,
89        V: IntoFilterValue,
90    {
91        FilterCondition::new(
92            self,
93            FilterOperator::InRange,
94            values
95                .into_iter()
96                .map(IntoFilterValue::into_filter_value)
97                .collect::<Vec<_>>()
98                .into_filter_value(),
99        )
100    }
101
102    pub fn not_in<I, V>(self, values: I) -> FilterCondition
103    where
104        I: IntoIterator<Item = V>,
105        V: IntoFilterValue,
106    {
107        FilterCondition::new(
108            self,
109            FilterOperator::NotInRange,
110            values
111                .into_iter()
112                .map(IntoFilterValue::into_filter_value)
113                .collect::<Vec<_>>()
114                .into_filter_value(),
115        )
116    }
117
118    pub fn crosses(self, value: impl IntoFilterValue) -> FilterCondition {
119        FilterCondition::new(self, FilterOperator::Crosses, value.into_filter_value())
120    }
121
122    pub fn crosses_above(self, value: impl IntoFilterValue) -> FilterCondition {
123        FilterCondition::new(
124            self,
125            FilterOperator::CrossesAbove,
126            value.into_filter_value(),
127        )
128    }
129
130    pub fn crosses_below(self, value: impl IntoFilterValue) -> FilterCondition {
131        FilterCondition::new(
132            self,
133            FilterOperator::CrossesBelow,
134            value.into_filter_value(),
135        )
136    }
137
138    pub fn matches(self, value: impl IntoFilterValue) -> FilterCondition {
139        FilterCondition::new(self, FilterOperator::Match, value.into_filter_value())
140    }
141
142    pub fn empty(self) -> FilterCondition {
143        FilterCondition::new(self, FilterOperator::Empty, serde_json::Value::Null)
144    }
145
146    pub fn not_empty(self) -> FilterCondition {
147        FilterCondition::new(self, FilterOperator::NotEmpty, serde_json::Value::Null)
148    }
149
150    pub fn above_pct(
151        self,
152        base: impl IntoFilterValue,
153        pct: impl IntoFilterValue,
154    ) -> FilterCondition {
155        FilterCondition::new(
156            self,
157            FilterOperator::AbovePercent,
158            vec![base.into_filter_value(), pct.into_filter_value()].into_filter_value(),
159        )
160    }
161
162    pub fn below_pct(
163        self,
164        base: impl IntoFilterValue,
165        pct: impl IntoFilterValue,
166    ) -> FilterCondition {
167        FilterCondition::new(
168            self,
169            FilterOperator::BelowPercent,
170            vec![base.into_filter_value(), pct.into_filter_value()].into_filter_value(),
171        )
172    }
173
174    pub fn sort(self, order: SortOrder) -> SortSpec {
175        SortSpec::new(self, order)
176    }
177}
178
179impl fmt::Display for Column {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        f.write_str(self.as_str())
182    }
183}
184
185impl Serialize for Column {
186    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
187    where
188        S: Serializer,
189    {
190        serializer.serialize_str(self.as_str())
191    }
192}
193
194impl From<&'static str> for Column {
195    fn from(value: &'static str) -> Self {
196        Self::from_static(value)
197    }
198}
199
200impl From<String> for Column {
201    fn from(value: String) -> Self {
202        Self::new(value)
203    }
204}
205
206impl From<&String> for Column {
207    fn from(value: &String) -> Self {
208        Self::new(value.clone())
209    }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Hash)]
213pub struct Market(Cow<'static, str>);
214
215impl Market {
216    pub const fn from_static(name: &'static str) -> Self {
217        Self(Cow::Borrowed(name))
218    }
219
220    pub fn new(name: impl Into<Cow<'static, str>>) -> Self {
221        Self(name.into())
222    }
223
224    pub fn as_str(&self) -> &str {
225        self.0.as_ref()
226    }
227}
228
229impl Serialize for Market {
230    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
231    where
232        S: Serializer,
233    {
234        serializer.serialize_str(self.as_str())
235    }
236}
237
238impl From<&'static str> for Market {
239    fn from(value: &'static str) -> Self {
240        Self::from_static(value)
241    }
242}
243
244impl From<String> for Market {
245    fn from(value: String) -> Self {
246        Self::new(value)
247    }
248}
249
250pub trait SymbolNormalizer {
251    fn normalize(&self, instrument: &InstrumentRef) -> InstrumentRef;
252}
253
254#[derive(Debug, Clone, Copy, Default)]
255pub struct HeuristicSymbolNormalizer;
256
257impl HeuristicSymbolNormalizer {
258    fn normalize_symbol_for_exchange(exchange: &str, symbol: &str) -> String {
259        let uppercase = symbol.to_ascii_uppercase();
260
261        match exchange {
262            "FX" | "FX_IDC" | "FOREX" | "OANDA" | "FOREXCOM" => uppercase
263                .chars()
264                .filter(|ch| ch.is_ascii_alphanumeric())
265                .collect(),
266            "NYSE" | "NASDAQ" | "AMEX" | "ARCA" | "BATS" | "TSX" => uppercase.replace('-', "."),
267            _ => uppercase,
268        }
269    }
270}
271
272impl SymbolNormalizer for HeuristicSymbolNormalizer {
273    fn normalize(&self, instrument: &InstrumentRef) -> InstrumentRef {
274        InstrumentRef {
275            exchange: instrument.exchange.trim().to_ascii_uppercase(),
276            symbol: Self::normalize_symbol_for_exchange(&instrument.exchange, &instrument.symbol),
277        }
278    }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)]
282pub struct InstrumentRef {
283    pub exchange: String,
284    pub symbol: String,
285}
286
287impl InstrumentRef {
288    pub fn new(exchange: impl Into<String>, symbol: impl Into<String>) -> Self {
289        Self {
290            exchange: exchange.into().trim().to_ascii_uppercase(),
291            symbol: symbol.into().trim().to_owned(),
292        }
293    }
294
295    /// Heuristic convenience constructor that normalizes common exchange-specific symbol shapes.
296    ///
297    /// Prefer [`InstrumentRef::new`] plus [`InstrumentRef::to_ticker`] when you want raw,
298    /// non-opinionated construction.
299    pub fn from_exchange_symbol(exchange: impl Into<String>, symbol: impl Into<String>) -> Self {
300        Self::from_exchange_symbol_normalized(exchange, symbol)
301    }
302
303    pub fn from_exchange_symbol_normalized(
304        exchange: impl Into<String>,
305        symbol: impl Into<String>,
306    ) -> Self {
307        Self::new(exchange, symbol).normalized_with(&HeuristicSymbolNormalizer)
308    }
309
310    pub fn from_internal_us_equity(exchange: impl Into<String>, symbol: impl Into<String>) -> Self {
311        let exchange = exchange.into().trim().to_ascii_uppercase();
312        let symbol = symbol.into().trim().to_ascii_uppercase().replace('-', ".");
313        Self { exchange, symbol }
314    }
315
316    pub fn to_ticker(&self) -> Ticker {
317        Ticker::from_parts(&self.exchange, &self.symbol)
318    }
319
320    pub fn normalized_with<N>(&self, normalizer: &N) -> Self
321    where
322        N: SymbolNormalizer + ?Sized,
323    {
324        normalizer.normalize(self)
325    }
326
327    pub fn to_ticker_with<N>(&self, normalizer: &N) -> Ticker
328    where
329        N: SymbolNormalizer + ?Sized,
330    {
331        self.normalized_with(normalizer).to_ticker()
332    }
333
334    pub fn to_normalized_ticker(&self) -> Ticker {
335        self.to_ticker_with(&HeuristicSymbolNormalizer)
336    }
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)]
340pub struct Ticker(Cow<'static, str>);
341
342impl Ticker {
343    pub fn from_parts(exchange: &str, symbol: &str) -> Self {
344        Self(Cow::Owned(format!("{exchange}:{symbol}")))
345    }
346
347    /// Heuristic convenience constructor that normalizes common exchange-specific symbol shapes.
348    pub fn from_exchange_symbol(exchange: &str, symbol: &str) -> Self {
349        Self::from_exchange_symbol_normalized(exchange, symbol)
350    }
351
352    pub fn from_exchange_symbol_normalized(exchange: &str, symbol: &str) -> Self {
353        InstrumentRef::from_exchange_symbol_normalized(exchange, symbol).to_ticker()
354    }
355
356    pub fn from_exchange_symbol_with<N>(exchange: &str, symbol: &str, normalizer: &N) -> Self
357    where
358        N: SymbolNormalizer + ?Sized,
359    {
360        InstrumentRef::new(exchange, symbol).to_ticker_with(normalizer)
361    }
362
363    pub const fn from_static(raw: &'static str) -> Self {
364        Self(Cow::Borrowed(raw))
365    }
366
367    pub fn new(raw: impl Into<Cow<'static, str>>) -> Self {
368        Self(raw.into())
369    }
370
371    pub fn as_str(&self) -> &str {
372        self.0.as_ref()
373    }
374
375    pub fn split(&self) -> Option<(&str, &str)> {
376        self.as_str().split_once(':')
377    }
378
379    pub fn exchange(&self) -> Option<&str> {
380        self.split().map(|(exchange, _)| exchange)
381    }
382
383    pub fn symbol(&self) -> Option<&str> {
384        self.split().map(|(_, symbol)| symbol)
385    }
386}
387
388impl fmt::Display for Ticker {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        f.write_str(self.as_str())
391    }
392}
393
394impl Serialize for Ticker {
395    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
396    where
397        S: Serializer,
398    {
399        serializer.serialize_str(self.as_str())
400    }
401}
402
403impl From<&'static str> for Ticker {
404    fn from(value: &'static str) -> Self {
405        Self::from_static(value)
406    }
407}
408
409impl From<String> for Ticker {
410    fn from(value: String) -> Self {
411        Self::new(value)
412    }
413}
414
415impl From<InstrumentRef> for Ticker {
416    fn from(value: InstrumentRef) -> Self {
417        value.to_ticker()
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[derive(Debug, Clone, Copy)]
426    struct CustomNormalizer;
427
428    impl SymbolNormalizer for CustomNormalizer {
429        fn normalize(&self, instrument: &InstrumentRef) -> InstrumentRef {
430            InstrumentRef::new(&instrument.exchange, instrument.symbol.replace('/', "-"))
431        }
432    }
433
434    #[test]
435    fn raw_ticker_construction_preserves_symbol_shape() {
436        let instrument = InstrumentRef::new("NYSE", "BRK-B");
437        assert_eq!(instrument.to_ticker().as_str(), "NYSE:BRK-B");
438    }
439
440    #[test]
441    fn heuristic_normalizer_handles_common_us_equity_symbols() {
442        let ticker = InstrumentRef::new("NYSE", "BRK-B").to_normalized_ticker();
443        assert_eq!(ticker.as_str(), "NYSE:BRK.B");
444    }
445
446    #[test]
447    fn heuristic_normalizer_handles_common_forex_pairs() {
448        let instrument = InstrumentRef::new("FX", "eur/usd");
449        assert_eq!(instrument.to_normalized_ticker().as_str(), "FX:EURUSD");
450    }
451
452    #[test]
453    fn custom_normalizer_can_override_symbol_rules() {
454        let ticker = Ticker::from_exchange_symbol_with("FX", "eur/usd", &CustomNormalizer);
455        assert_eq!(ticker.as_str(), "FX:eur-usd");
456    }
457}