Skip to main content

digdigdig3_station/
normalize.rs

1//! STATION-side normalization over the RAW `SymbolInfo` core emits.
2//!
3//! The core connector layer is raw + complete: `get_exchange_info` returns ALL
4//! symbols with the venue-native `status` string verbatim (`"online"`,
5//! `"Trading"`, `"live"`, `"tradable"`, `"1"`, `"active"`, ...) and never
6//! filters. The raw↔normalized boundary lives HERE, opt-in: a consumer that
7//! wants a canonical status or an active-only universe calls these helpers; one
8//! that wants the raw truth ignores them. Nothing here mutates or loses the raw
9//! `SymbolInfo` — `status`/`instrument_type`/`extra` stay intact.
10
11use digdigdig3::core::types::SymbolInfo;
12
13/// Canonical, exchange-agnostic trading status — a STATION normalization of the
14/// many venue-native `status` strings into one vocabulary. Opt-in; the raw
15/// `SymbolInfo.status` is always still available for callers who want it.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SymbolStatus {
18    /// Open for trading (online / Trading / live / tradable / active / 1 / ...).
19    Trading,
20    /// Temporarily not trading (suspend / halt / break / cancel-only /
21    /// post-only / limit-only / reduce-only / paused / CAUTION).
22    Halted,
23    /// Listed but not yet live (pre-launch / pre-trading / pre-open / preopen).
24    PreLaunch,
25    /// Permanently gone or settled (delisted / delisting / closed / settled /
26    /// expired / inactive / offline / disabled / 0).
27    Closed,
28    /// No native status, or an unrecognized token.
29    Unknown,
30}
31
32/// Map a raw `SymbolInfo`'s native `status` string to a [`SymbolStatus`].
33///
34/// Token-based union over all venues' vocabularies (case-insensitive). This is
35/// normalization (many→few), so it's STATION's job — never core's.
36pub fn canonical_status(sym: &SymbolInfo) -> SymbolStatus {
37    let s = sym.status.trim().to_ascii_lowercase();
38    if s.is_empty() {
39        return SymbolStatus::Unknown;
40    }
41    // Numeric statuses (MEXC / BingX): exact match — "1" = active, "0" = inactive.
42    if s == "1" {
43        return SymbolStatus::Trading;
44    }
45    if s == "0" {
46        return SymbolStatus::Closed;
47    }
48    // MOEX single-letter board status: "A" admitted, "S" suspended (exact —
49    // a `contains` would mis-match any string with these letters).
50    if s == "a" {
51        return SymbolStatus::Trading;
52    }
53    if s == "s" {
54        return SymbolStatus::Halted;
55    }
56    // Pre-launch first (some venues say "preopen"/"prelaunch" which would else
57    // partial-match nothing).
58    const PRELAUNCH: &[&str] = &["prelaunch", "pre-launch", "pretrading", "pre_trading", "preopen", "pre-open", "pre_open"];
59    const HALTED: &[&str] = &[
60        "suspend", "suspended", "halt", "halted", "break", "cancel-only", "cancelonly",
61        "post-only", "postonly", "post_only", "limit-only", "limitonly", "limit_only",
62        "reduce-only", "reduceonly", "reduce_only", "paused", "pause", "caution", "untradable",
63        "not_available_for_trading", "not-available-for-trading",
64    ];
65    const CLOSED: &[&str] = &[
66        "delist", "delisted", "delisting", "closed", "close", "settled", "settle",
67        "expired", "expire", "inactive", "offline", "disabled", "disable", "unlisted", "end_of_day",
68    ];
69    const TRADING: &[&str] = &[
70        "trading", "online", "live", "tradable", "active", "open", "normal", "enabled",
71        "enabletrading", "security_trading_status_normal_trading",
72    ];
73
74    if PRELAUNCH.iter().any(|t| s.contains(t)) {
75        SymbolStatus::PreLaunch
76    } else if HALTED.iter().any(|t| s.contains(t)) {
77        SymbolStatus::Halted
78    } else if CLOSED.iter().any(|t| s.contains(t)) {
79        SymbolStatus::Closed
80    } else if TRADING.iter().any(|t| s == *t || s.contains(t)) {
81        SymbolStatus::Trading
82    } else {
83        SymbolStatus::Unknown
84    }
85}
86
87/// Opt-in active-only filter — keep only currently-`Trading` symbols. This is
88/// the restriction core used to apply inline (and which we removed to keep core
89/// raw); it now lives here as a consumer choice.
90pub fn active_only(symbols: Vec<SymbolInfo>) -> Vec<SymbolInfo> {
91    symbols
92        .into_iter()
93        .filter(|s| canonical_status(s) == SymbolStatus::Trading)
94        .collect()
95}
96
97/// Borrowing variant — count/inspect without consuming.
98pub fn is_active(sym: &SymbolInfo) -> bool {
99    canonical_status(sym) == SymbolStatus::Trading
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use digdigdig3::core::types::SymbolInfo;
106
107    fn sym(status: &str) -> SymbolInfo {
108        SymbolInfo { status: status.to_string(), ..Default::default() }
109    }
110
111    #[test]
112    fn maps_native_statuses() {
113        // Trading vocab across venues.
114        for t in ["TRADING", "online", "live", "tradable", "active", "open", "Normal", "1", "Trading"] {
115            assert_eq!(canonical_status(&sym(t)), SymbolStatus::Trading, "{t}");
116        }
117        // Halted (incl Tinkoff's verbose enum).
118        for t in ["suspend", "PostOnly", "cancel-only", "CAUTION", "untradable", "SECURITY_TRADING_STATUS_NOT_AVAILABLE_FOR_TRADING"] {
119            assert_eq!(canonical_status(&sym(t)), SymbolStatus::Halted, "{t}");
120        }
121        // Pre-launch.
122        for t in ["PreLaunch", "preopen", "PreTrading"] {
123            assert_eq!(canonical_status(&sym(t)), SymbolStatus::PreLaunch, "{t}");
124        }
125        // Closed/gone.
126        for t in ["delisted", "delisting", "closed", "Settled", "expired", "offline", "Disabled", "0"] {
127            assert_eq!(canonical_status(&sym(t)), SymbolStatus::Closed, "{t}");
128        }
129        // Unknown / absent.
130        assert_eq!(canonical_status(&sym("")), SymbolStatus::Unknown);
131        assert_eq!(canonical_status(&sym("wat")), SymbolStatus::Unknown);
132    }
133
134    #[test]
135    fn active_only_filters() {
136        let v = vec![sym("Trading"), sym("delisted"), sym("PreLaunch"), sym("online"), sym("")];
137        let out = active_only(v);
138        assert_eq!(out.len(), 2); // Trading + online
139        assert!(out.iter().all(|s| is_active(s)));
140    }
141}