Skip to main content

longbridge/market/
types.rs

1#![allow(missing_docs)]
2
3use rust_decimal::Decimal;
4use serde::{Deserialize, Serialize};
5use serde_repr::{Deserialize_repr, Serialize_repr};
6use strum_macros::{FromRepr, IntoStaticStr};
7use time::OffsetDateTime;
8
9use crate::{types::Market, utils::counter::deserialize_counter_id_as_symbol};
10
11// ── market_status ─────────────────────────────────────────────────
12
13/// Market trading status code.
14#[allow(non_camel_case_types)]
15#[derive(
16    Debug,
17    Clone,
18    Copy,
19    Default,
20    Hash,
21    PartialOrd,
22    Ord,
23    PartialEq,
24    Eq,
25    FromRepr,
26    IntoStaticStr,
27    Serialize_repr,
28    Deserialize_repr,
29)]
30#[repr(i32)]
31pub enum TradeStatus {
32    /// Unknown status
33    #[default]
34    #[serde(other)]
35    UNKNOWN = -1,
36    /// Quote is not registered
37    NO_REGISTER_QUOTE = 0,
38    /// Clearing before the market opens.
39    CLEAN = 101,
40    /// Opening auction.
41    OPEN_BID = 102,
42    /// Morning break, currently used by VIX indexes.
43    MORNING_CLOSING = 103,
44    /// Regular trading.
45    TRADING = 105,
46    /// Midday break.
47    NOON_CLOSING = 106,
48    /// Closing auction.
49    CLOSE_BID = 107,
50    /// Market closed.
51    CLOSING = 108,
52    /// Dark trading waiting to open.
53    DARK_WAIT = 110,
54    /// Dark trading.
55    DARK_TRADING = 111,
56    /// Dark trading closed.
57    DARK_CLOSING = 112,
58    /// After-hours fixed-price trading.
59    AFTER_FIX = 120,
60    /// Half-day market closed. Defined by the market status table but currently
61    /// unused.
62    HALF_CLOSING = 121,
63    /// Not opened because the exchange is waiting to open under special
64    /// conditions.
65    NOT_OPENED = 122,
66    /// Temporary intraday break. The historical variant name is kept for
67    /// compatibility.
68    REALTIME_QUOTE = 123,
69    /// US pre-market.
70    US_PREV = 201,
71    /// US regular trading.
72    US_TRADING = 202,
73    /// US post-market.
74    US_AFTER = 203,
75    /// US closed.
76    US_CLOSING = 204,
77    /// US halted.
78    US_STOP = 205,
79    /// US clearing plus pre-market.
80    US_CLEAN = 206,
81    /// US overnight trading.
82    US_NIGHT = 207,
83    /// US pre-market clearing alias returned by the quote engine.
84    US_PREV_MARKET_CLEAN = 209,
85    /// US post-market clearing alias returned by the quote engine.
86    US_AFTER_MARKET_CLEAN = 210,
87    /// Stock refresh. Deprecated in the status definition.
88    REFRESH = 1000,
89    /// Delisted.
90    DELIST = 1001,
91    /// Preparing to list.
92    PREPARE = 1002,
93    /// Code changed.
94    CODE_CHANGE = 1003,
95    /// Halted.
96    STOP = 1004,
97    /// Waiting to open, typically for a US IPO auction.
98    WILL_OPEN = 1005,
99    /// Split or merge suspended.
100    COMMON_SUSPEND = 1006,
101    /// Expired.
102    EXPIRE = 1007,
103    /// No quote data.
104    NO_QUOTE = 1008,
105    /// Not listed. The historical variant name is kept for compatibility.
106    UNITED = 1009,
107    /// Terminated trading, usually for warrants.
108    TRADING_HALT = 1010,
109    /// Waiting to list, usually for new warrants.
110    WAIT_LISTING = 1011,
111    /// Fuse.
112    FUSE = 2001,
113}
114
115impl From<i32> for TradeStatus {
116    fn from(value: i32) -> Self {
117        Self::from_repr(value).unwrap_or_default()
118    }
119}
120
121impl TradeStatus {
122    /// Converts an isize value to a market trading status.
123    pub fn from_isize(value: isize) -> TradeStatus {
124        (value as i32).into()
125    }
126
127    /// Returns the raw numeric status code.
128    pub fn code(self) -> i32 {
129        self as i32
130    }
131
132    /// Returns the static enum variant name.
133    pub fn as_static(self) -> &'static str {
134        self.into()
135    }
136
137    /// Returns a simplified label for key display states.
138    pub fn label(self) -> &'static str {
139        let status = self.normalize();
140        match status {
141            TradeStatus::US_PREV
142            | TradeStatus::US_TRADING
143            | TradeStatus::US_AFTER
144            | TradeStatus::US_NIGHT
145            | TradeStatus::US_CLOSING
146            | TradeStatus::TRADING
147            | TradeStatus::CLOSING => status.name(),
148            _ => "",
149        }
150    }
151
152    /// Returns the full English status name.
153    pub fn name(self) -> &'static str {
154        match self.normalize() {
155            TradeStatus::UNKNOWN | TradeStatus::NO_REGISTER_QUOTE => "Unknown",
156            TradeStatus::OPEN_BID => "Open Bid",
157            TradeStatus::MORNING_CLOSING => "Morning Break",
158            TradeStatus::TRADING | TradeStatus::US_TRADING | TradeStatus::US_AFTER_MARKET_CLEAN => {
159                "Trading"
160            }
161            TradeStatus::NOON_CLOSING => "Mid-Day Break",
162            TradeStatus::CLOSE_BID => "Close Bid",
163            TradeStatus::CLOSING
164            | TradeStatus::CLEAN
165            | TradeStatus::HALF_CLOSING
166            | TradeStatus::US_CLOSING
167            | TradeStatus::US_PREV_MARKET_CLEAN => "Closed",
168            TradeStatus::DARK_WAIT => "Dark Wait",
169            TradeStatus::DARK_TRADING => "Dark Trading",
170            TradeStatus::DARK_CLOSING => "Closing",
171            TradeStatus::AFTER_FIX => "After Fix",
172            TradeStatus::NOT_OPENED => "Not Open",
173            TradeStatus::REALTIME_QUOTE => "Temporary Break",
174            TradeStatus::US_PREV | TradeStatus::US_CLEAN => "Pre-Market",
175            TradeStatus::US_AFTER => "Post-Market",
176            TradeStatus::US_STOP | TradeStatus::STOP => "Stop",
177            TradeStatus::US_NIGHT => "Overnight",
178            TradeStatus::REFRESH => "Refresh",
179            TradeStatus::DELIST => "Delist",
180            TradeStatus::PREPARE => "Prepare",
181            TradeStatus::CODE_CHANGE => "Code Change",
182            TradeStatus::WILL_OPEN => "Will Open",
183            TradeStatus::COMMON_SUSPEND => "Common Suspend",
184            TradeStatus::EXPIRE => "Expire",
185            TradeStatus::NO_QUOTE => "No Quote",
186            TradeStatus::UNITED => "Not Listed",
187            TradeStatus::TRADING_HALT => "Terminated",
188            TradeStatus::WAIT_LISTING => "Wait Listing",
189            TradeStatus::FUSE => "Fuse",
190        }
191    }
192
193    /// Returns whether this is a US market status.
194    pub fn is_us_market(self) -> bool {
195        self.code() >= 200 && self.code() < 300
196    }
197
198    /// Returns whether this is a US pre/post-market status.
199    pub fn is_us_pre_post(self) -> bool {
200        self.is_us_prev() || self.is_us_after()
201    }
202
203    /// Returns whether this is a US overnight status.
204    pub fn is_us_night(self) -> bool {
205        matches!(self, TradeStatus::US_NIGHT)
206    }
207
208    /// Returns whether this is a US closed status.
209    pub fn is_us_closing(self) -> bool {
210        matches!(
211            self,
212            TradeStatus::US_CLOSING | TradeStatus::US_PREV_MARKET_CLEAN
213        )
214    }
215
216    /// Returns whether this is a closed status.
217    pub fn is_closing(self) -> bool {
218        matches!(
219            self,
220            TradeStatus::US_CLOSING
221                | TradeStatus::US_PREV_MARKET_CLEAN
222                | TradeStatus::CLOSING
223                | TradeStatus::HALF_CLOSING
224        )
225    }
226
227    /// Returns whether this is a US pre-market status.
228    pub fn is_us_prev(self) -> bool {
229        matches!(self, TradeStatus::US_PREV | TradeStatus::US_CLEAN)
230    }
231
232    /// Returns whether this is a US post-market status.
233    pub fn is_us_after(self) -> bool {
234        matches!(self, TradeStatus::US_AFTER)
235    }
236
237    /// Returns whether this is a trading status.
238    pub fn is_trading(self) -> bool {
239        matches!(
240            self,
241            TradeStatus::TRADING | TradeStatus::US_TRADING | TradeStatus::US_AFTER_MARKET_CLEAN
242        )
243    }
244
245    /// Returns whether this is a dark-pool status.
246    pub fn is_dark(self) -> bool {
247        matches!(
248            self,
249            TradeStatus::DARK_WAIT | TradeStatus::DARK_TRADING | TradeStatus::DARK_CLOSING
250        )
251    }
252
253    /// Returns whether this status allows trading.
254    pub fn allow_trading(self) -> bool {
255        matches!(
256            self,
257            TradeStatus::OPEN_BID
258                | TradeStatus::TRADING
259                | TradeStatus::CLOSE_BID
260                | TradeStatus::NOT_OPENED
261                | TradeStatus::NOON_CLOSING
262                | TradeStatus::US_TRADING
263                | TradeStatus::US_AFTER_MARKET_CLEAN
264        )
265    }
266
267    /// Normalizes clearing aliases to their display-equivalent status.
268    #[must_use]
269    pub fn normalize(self) -> Self {
270        match self {
271            TradeStatus::CLEAN => TradeStatus::CLOSING,
272            TradeStatus::US_PREV_MARKET_CLEAN => TradeStatus::US_CLOSING,
273            TradeStatus::US_CLEAN => TradeStatus::US_PREV,
274            TradeStatus::US_AFTER_MARKET_CLEAN => TradeStatus::US_TRADING,
275            _ => self,
276        }
277    }
278
279    /// Returns whether this is a special non-regular status.
280    pub fn is_special(self) -> bool {
281        self.code() < 100 || self == Self::US_STOP || self.code() >= 1000
282    }
283}
284
285/// Response for [`crate::MarketContext::market_status`]
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct MarketStatusResponse {
288    /// Per-market trading status items
289    pub market_time: Vec<MarketTimeItem>,
290}
291
292/// Trading status for one market
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct MarketTimeItem {
295    /// Market code
296    pub market: Market,
297    /// Market trade status. See [`TradeStatus`] for the code table.
298    pub trade_status: TradeStatus,
299    /// Current market time (unix timestamp string)
300    pub timestamp: String,
301    /// Delayed-quote market trade status. See [`TradeStatus`] for the code
302    /// table.
303    pub delay_trade_status: TradeStatus,
304    /// Delayed-quote market time (unix timestamp string)
305    pub delay_timestamp: String,
306    /// Sub-status code
307    pub sub_status: i32,
308    /// Delayed-quote sub-status code
309    pub delay_sub_status: i32,
310}
311
312#[cfg(test)]
313mod tests {
314    use crate::market::TradeStatus;
315
316    #[test]
317    fn market_trade_status_deserializes_numeric_codes() {
318        assert_eq!(
319            serde_json::from_str::<TradeStatus>("202")
320                .expect("202 should deserialize as market trade status"),
321            TradeStatus::US_TRADING
322        );
323        assert_eq!(
324            serde_json::from_str::<TradeStatus>("456")
325                .expect("unknown numeric status should deserialize"),
326            TradeStatus::UNKNOWN
327        );
328    }
329
330    #[test]
331    fn market_trade_status_serializes_as_numeric_code() {
332        let value = serde_json::to_string(&TradeStatus::US_CLEAN)
333            .expect("market trade status should serialize");
334        assert_eq!(value, "206");
335    }
336
337    #[test]
338    fn market_trade_status_normalizes_engine_aliases() {
339        assert_eq!(TradeStatus::CLEAN.normalize(), TradeStatus::CLOSING);
340        assert_eq!(TradeStatus::US_CLEAN.normalize(), TradeStatus::US_PREV);
341        assert_eq!(
342            TradeStatus::US_PREV_MARKET_CLEAN.normalize(),
343            TradeStatus::US_CLOSING
344        );
345        assert_eq!(
346            TradeStatus::US_AFTER_MARKET_CLEAN.normalize(),
347            TradeStatus::US_TRADING
348        );
349    }
350
351    #[test]
352    fn market_trade_status_label_matches_engine_simplified_display() {
353        assert_eq!(TradeStatus::US_PREV.label(), "Pre-Market");
354        assert_eq!(TradeStatus::US_CLEAN.label(), "Pre-Market");
355        assert_eq!(TradeStatus::US_AFTER.label(), "Post-Market");
356        assert_eq!(TradeStatus::US_CLOSING.label(), "Closed");
357        assert_eq!(TradeStatus::US_AFTER_MARKET_CLEAN.label(), "Trading");
358        assert_eq!(TradeStatus::US_TRADING.label(), "Trading");
359        assert_eq!(TradeStatus::TRADING.label(), "Trading");
360        assert_eq!(TradeStatus::CLEAN.label(), "Closed");
361        assert_eq!(TradeStatus::OPEN_BID.label(), "");
362        assert_eq!(TradeStatus::NOON_CLOSING.label(), "");
363    }
364
365    #[test]
366    fn market_trade_status_name_covers_full_status_set() {
367        let cases = [
368            (TradeStatus::MORNING_CLOSING, "Morning Break"),
369            (TradeStatus::NOON_CLOSING, "Mid-Day Break"),
370            (TradeStatus::REALTIME_QUOTE, "Temporary Break"),
371            (TradeStatus::US_STOP, "Stop"),
372            (TradeStatus::TRADING_HALT, "Terminated"),
373            (TradeStatus::WAIT_LISTING, "Wait Listing"),
374            (TradeStatus::FUSE, "Fuse"),
375            (TradeStatus::UNKNOWN, "Unknown"),
376            (TradeStatus::NO_REGISTER_QUOTE, "Unknown"),
377        ];
378
379        for (status, expected) in cases {
380            assert_eq!(status.name(), expected, "status {status:?}");
381        }
382    }
383
384    #[test]
385    fn market_trade_status_codes_match_phase_definition_document() {
386        let codes = [
387            101, 102, 103, 105, 106, 107, 108, 110, 111, 112, 120, 121, 122, 123, 201, 202, 203,
388            204, 206, 207, 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011,
389            2001,
390        ];
391
392        for code in codes {
393            assert_eq!(TradeStatus::from(code).code(), code, "status code {code}");
394        }
395    }
396
397    #[test]
398    fn market_trade_status_names_match_phase_definition_document() {
399        let cases = [
400            (123, "Temporary Break"),
401            (1009, "Not Listed"),
402            (1010, "Terminated"),
403            (2001, "Fuse"),
404        ];
405
406        for (code, expected) in cases {
407            assert_eq!(
408                TradeStatus::from(code).name(),
409                expected,
410                "status code {code}"
411            );
412        }
413    }
414
415    #[test]
416    fn market_time_item_uses_market_trade_status_type() {
417        let item = serde_json::from_str::<crate::market::MarketTimeItem>(
418            r#"{
419                "market": "US",
420                "trade_status": 202,
421                "timestamp": "1717200000",
422                "delay_trade_status": 204,
423                "delay_timestamp": "1717200000",
424                "sub_status": 0,
425                "delay_sub_status": 0
426            }"#,
427        )
428        .expect("market time item should deserialize");
429
430        assert_eq!(item.trade_status, TradeStatus::US_TRADING);
431        assert_eq!(item.delay_trade_status, TradeStatus::US_CLOSING);
432    }
433}
434
435// ── broker_holding ────────────────────────────────────────────────
436
437/// Response for [`crate::MarketContext::broker_holding`]
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct BrokerHoldingTop {
440    /// Top brokers by net buying
441    pub buy: Vec<BrokerHoldingEntry>,
442    /// Top brokers by net selling
443    pub sell: Vec<BrokerHoldingEntry>,
444    /// Last updated (may be empty)
445    #[serde(default)]
446    pub updated_at: String,
447}
448
449/// One broker entry in a top-holding list
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct BrokerHoldingEntry {
452    /// Broker name
453    pub name: String,
454    /// Participant number / broker code
455    pub parti_number: String,
456    /// Net change in shares held
457    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
458    pub chg: Option<Decimal>,
459    /// Whether this is a "strengthening" broker
460    pub strong: bool,
461}
462
463// ── broker_holding_detail ─────────────────────────────────────────
464
465/// Response for [`crate::MarketContext::broker_holding_detail`]
466#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct BrokerHoldingDetail {
468    /// Full list of broker holdings
469    pub list: Vec<BrokerHoldingDetailItem>,
470    /// Last updated (may be empty)
471    #[serde(default)]
472    pub updated_at: String,
473}
474
475/// One broker's full holding detail
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct BrokerHoldingDetailItem {
478    /// Broker name
479    pub name: String,
480    /// Participant number / broker code
481    pub parti_number: String,
482    /// Holding ratio changes over various periods
483    pub ratio: BrokerHoldingChanges,
484    /// Share count changes over various periods
485    pub shares: BrokerHoldingChanges,
486    /// Whether this is a "strengthening" broker
487    pub strong: bool,
488}
489
490/// Changes in broker holding over 1 / 5 / 20 / 60 day periods
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct BrokerHoldingChanges {
493    /// Current value
494    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
495    pub value: Option<Decimal>,
496    /// 1-day change
497    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
498    pub chg_1: Option<Decimal>,
499    /// 5-day change
500    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
501    pub chg_5: Option<Decimal>,
502    /// 20-day change
503    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
504    pub chg_20: Option<Decimal>,
505    /// 60-day change
506    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
507    pub chg_60: Option<Decimal>,
508}
509
510// ── broker_holding_daily ──────────────────────────────────────────
511
512/// Response for [`crate::MarketContext::broker_holding_daily`]
513#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct BrokerHoldingDailyHistory {
515    /// Daily broker holding records
516    pub list: Vec<BrokerHoldingDailyItem>,
517}
518
519/// One day's broker holding record
520#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct BrokerHoldingDailyItem {
522    /// Date in `"2026.05.05"` format
523    pub date: String,
524    /// Total shares held
525    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
526    pub holding: Option<Decimal>,
527    /// Holding ratio as a decimal
528    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
529    pub ratio: Option<Decimal>,
530    /// Change vs previous day
531    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
532    pub chg: Option<Decimal>,
533}
534
535// ── ah_premium ────────────────────────────────────────────────────
536
537/// Response for [`crate::MarketContext::ah_premium`]
538#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct AhPremiumKlines {
540    /// K-line data points
541    pub klines: Vec<AhPremiumKline>,
542}
543
544/// Response for [`crate::MarketContext::ah_premium_intraday`]
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct AhPremiumIntraday {
547    /// Intraday data points (field name is `klines` in the API)
548    pub klines: Vec<AhPremiumKline>,
549}
550
551/// One A/H premium data point
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct AhPremiumKline {
554    /// A-share price
555    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
556    pub aprice: Decimal,
557    /// A-share previous close
558    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
559    pub apreclose: Decimal,
560    /// H-share price
561    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
562    pub hprice: Decimal,
563    /// H-share previous close
564    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
565    pub hpreclose: Decimal,
566    /// CNY/HKD exchange rate
567    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
568    pub currency_rate: Decimal,
569    /// A/H premium rate (negative = H-share at premium)
570    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
571    pub ahpremium_rate: Decimal,
572    /// Price spread
573    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
574    pub price_spread: Decimal,
575    /// Data point timestamp
576    #[serde(deserialize_with = "crate::serde_utils::deserialize_timestamp")]
577    pub timestamp: OffsetDateTime,
578}
579
580// ── trade_stats ───────────────────────────────────────────────────
581
582/// Response for [`crate::MarketContext::trade_stats`]
583#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct TradeStatsResponse {
585    /// Summary statistics
586    pub statistics: TradeStatistics,
587    /// Per-price-level breakdown
588    pub trades: Vec<TradePriceLevel>,
589}
590
591/// Summary trade statistics
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct TradeStatistics {
594    /// Volume-weighted average price
595    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
596    pub avgprice: Decimal,
597    /// Total buy volume (shares)
598    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
599    pub buy: Decimal,
600    /// Total neutral / unknown-direction volume
601    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
602    pub neutral: Decimal,
603    /// Previous close price
604    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
605    pub preclose: Decimal,
606    /// Total sell volume (shares)
607    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
608    pub sell: Decimal,
609    /// Data timestamp (unix timestamp string)
610    pub timestamp: String,
611    /// Total trading volume (shares)
612    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
613    pub total_amount: Decimal,
614    /// Unix timestamps for the last 5 trading days
615    pub trade_date: Vec<String>,
616    /// Total number of trades
617    pub trades_count: String,
618}
619
620/// Trade volume at one price level
621#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct TradePriceLevel {
623    /// Buy volume at this price
624    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
625    pub buy_amount: Decimal,
626    /// Neutral (unknown direction) volume at this price
627    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
628    pub neutral_amount: Decimal,
629    /// Price level
630    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
631    pub price: Decimal,
632    /// Sell volume at this price
633    #[serde(with = "crate::serde_utils::decimal_empty_is_0")]
634    pub sell_amount: Decimal,
635}
636
637// ── anomaly ───────────────────────────────────────────────────────
638
639/// Response for [`crate::MarketContext::anomaly`]
640#[derive(Debug, Clone, Serialize, Deserialize)]
641pub struct AnomalyResponse {
642    /// Whether anomaly alerts are globally disabled
643    pub all_off: bool,
644    /// List of market anomaly events
645    pub changes: Vec<AnomalyItem>,
646}
647
648/// One market anomaly event (e.g. large block trade, margin buying surge)
649#[derive(Debug, Clone, Serialize, Deserialize)]
650pub struct AnomalyItem {
651    /// Security symbol
652    #[serde(
653        rename = "counter_id",
654        deserialize_with = "deserialize_counter_id_as_symbol"
655    )]
656    pub symbol: String,
657    /// Security name
658    pub name: String,
659    /// Anomaly type name, e.g. `"大宗交易"`, `"融资买入"`
660    pub alert_name: String,
661    /// Time of the anomaly (unix timestamp in milliseconds)
662    pub alert_time: i64,
663    /// Change values — items are accessed as strings by the client
664    pub change_values: Vec<String>,
665    /// Sentiment direction: 1 = positive/up, 2 = negative/down
666    pub emotion: i32,
667}
668
669// ── constituent ───────────────────────────────────────────────────
670
671/// Response for [`crate::MarketContext::constituent`]
672#[derive(Debug, Clone, Serialize, Deserialize)]
673pub struct IndexConstituents {
674    /// Number of constituent stocks that fell today
675    pub fall_num: i32,
676    /// Number of constituent stocks unchanged today
677    pub flat_num: i32,
678    /// Number of constituent stocks that rose today
679    pub rise_num: i32,
680    /// Constituent stock details
681    pub stocks: Vec<ConstituentStock>,
682}
683
684/// One constituent stock of an index
685#[derive(Debug, Clone, Serialize, Deserialize)]
686pub struct ConstituentStock {
687    /// Security symbol
688    #[serde(
689        rename = "counter_id",
690        deserialize_with = "deserialize_counter_id_as_symbol"
691    )]
692    pub symbol: String,
693    /// Security name
694    pub name: String,
695    /// Latest price
696    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
697    pub last_done: Option<Decimal>,
698    /// Previous close
699    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
700    pub prev_close: Option<Decimal>,
701    /// Net capital inflow today
702    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
703    pub inflow: Option<Decimal>,
704    /// Turnover amount
705    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
706    pub balance: Option<Decimal>,
707    /// Trading volume (shares)
708    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
709    pub amount: Option<Decimal>,
710    /// Total shares outstanding
711    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
712    pub total_shares: Option<Decimal>,
713    /// Tags, e.g. `["领涨龙头"]`
714    pub tags: Vec<String>,
715    /// Brief description
716    pub intro: String,
717    /// Market, e.g. `"HK"`
718    pub market: String,
719    /// Circulating shares
720    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
721    pub circulating_shares: Option<Decimal>,
722    /// Whether this is a delayed quote
723    pub delay: bool,
724    /// Day change percentage
725    #[serde(with = "crate::serde_utils::decimal_opt_str_is_none")]
726    pub chg: Option<Decimal>,
727    /// Raw trade status code
728    pub trade_status: i32,
729}
730
731// ── top_movers ────────────────────────────────────────────────────
732
733/// Stock information within a top-movers event.
734#[derive(Debug, Clone, Serialize, Deserialize)]
735pub struct TopMoversStock {
736    /// Symbol (converted from counter_id, e.g. `"NVDA.US"`)
737    pub symbol: String,
738    /// Ticker code (e.g. `"NVDA"`)
739    pub code: String,
740    /// Security name
741    pub name: String,
742    /// Full name
743    #[serde(default)]
744    pub full_name: String,
745    /// Price change (decimal ratio)
746    pub change: String,
747    /// Latest price
748    pub last_done: String,
749    /// Market code
750    pub market: String,
751    /// Labels / tags
752    #[serde(default)]
753    pub labels: Vec<String>,
754    /// Logo URL
755    #[serde(default)]
756    pub logo: String,
757}
758
759/// One top-movers event entry.
760#[derive(Debug, Clone, Serialize, Deserialize)]
761pub struct TopMoversEvent {
762    /// Event time (RFC 3339)
763    pub timestamp: String,
764    /// Alert reason description
765    pub alert_reason: String,
766    /// Alert type code
767    pub alert_type: i64,
768    /// Stock information
769    pub stock: TopMoversStock,
770    /// Associated news post (raw JSON, complex structure)
771    pub post: serde_json::Value,
772}
773
774/// Response for [`crate::MarketContext::top_movers`]
775#[derive(Debug, Clone, Serialize, Deserialize)]
776pub struct TopMoversResponse {
777    /// Top-mover events
778    pub events: Vec<TopMoversEvent>,
779    /// Pagination cursor for next page
780    pub next_params: serde_json::Value,
781}
782
783// ── rank_categories ───────────────────────────────────────────────
784
785/// Response for [`crate::MarketContext::rank_categories`]
786///
787/// The raw data contains all available rank category keys and labels.
788/// The exact structure varies so the payload is preserved as raw JSON.
789#[derive(Debug, Clone, Serialize, Deserialize)]
790pub struct RankCategoriesResponse {
791    /// Raw rank category data
792    pub data: serde_json::Value,
793}
794
795// ── rank_list ─────────────────────────────────────────────────────
796
797/// One ranked security item.
798#[derive(Debug, Clone, Serialize, Deserialize)]
799pub struct RankListItem {
800    /// Symbol (converted from counter_id, e.g. `"MU.US"`)
801    pub symbol: String,
802    /// Ticker code (e.g. `"MU"`)
803    pub code: String,
804    /// Security name
805    pub name: String,
806    /// Latest price
807    pub last_done: String,
808    /// Price change ratio (decimal)
809    pub chg: String,
810    /// Absolute price change
811    pub change: String,
812    /// Net inflow
813    pub inflow: String,
814    /// Market cap
815    pub market_cap: String,
816    /// Industry name
817    pub industry: String,
818    /// Pre/post market price
819    #[serde(default)]
820    pub pre_post_price: String,
821    /// Pre/post market change
822    #[serde(default)]
823    pub pre_post_chg: String,
824    /// Amplitude
825    #[serde(default)]
826    pub amplitude: String,
827    /// 5-day change
828    #[serde(default)]
829    pub five_day_chg: String,
830    /// Turnover rate
831    #[serde(default)]
832    pub turnover_rate: String,
833    /// Volume ratio
834    #[serde(default)]
835    pub volume_rate: String,
836    /// P/B ratio (TTM)
837    #[serde(default)]
838    pub pb_ttm: String,
839}
840
841/// Response for [`crate::MarketContext::rank_list`]
842#[derive(Debug, Clone, Serialize, Deserialize)]
843pub struct RankListResponse {
844    /// Whether delayed / BMP data
845    pub bmp: bool,
846    /// Ranked security items
847    pub lists: Vec<RankListItem>,
848}
849
850// ── enums ─────────────────────────────────────────────────────────
851
852/// Broker holding lookback period
853#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
854pub enum BrokerHoldingPeriod {
855    /// 1-day change
856    #[default]
857    #[serde(rename = "rct_1")]
858    Rct1,
859    /// 5-day change
860    #[serde(rename = "rct_5")]
861    Rct5,
862    /// 20-day change
863    #[serde(rename = "rct_20")]
864    Rct20,
865    /// 60-day change
866    #[serde(rename = "rct_60")]
867    Rct60,
868}
869
870/// A/H premium K-line period
871#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)]
872pub enum AhPremiumPeriod {
873    /// 1-minute
874    Min1,
875    /// 5-minute
876    Min5,
877    /// 15-minute
878    Min15,
879    /// 30-minute
880    Min30,
881    /// 60-minute
882    Min60,
883    /// Daily
884    #[default]
885    Day,
886    /// Weekly
887    Week,
888    /// Monthly
889    Month,
890    /// Yearly
891    Year,
892}
893
894impl AhPremiumPeriod {
895    /// Convert to the API's `line_type` parameter value
896    pub(crate) fn to_line_type(self) -> &'static str {
897        match self {
898            AhPremiumPeriod::Min1 => "1",
899            AhPremiumPeriod::Min5 => "5",
900            AhPremiumPeriod::Min15 => "15",
901            AhPremiumPeriod::Min30 => "30",
902            AhPremiumPeriod::Min60 => "60",
903            AhPremiumPeriod::Day => "1000",
904            AhPremiumPeriod::Week => "2000",
905            AhPremiumPeriod::Month => "3000",
906            AhPremiumPeriod::Year => "4000",
907        }
908    }
909}