Skip to main content

dhan_rs/types/
data.rs

1#![allow(missing_docs)]
2//! Types for rolling expired-options, technical metrics, market movers, and
3//! company-information Data APIs.
4
5use std::collections::HashMap;
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9macro_rules! string_wire_enum {
10    ($name:ident { $($variant:ident => $wire:literal,)+ }) => {
11        #[derive(Debug, Clone, PartialEq, Eq, Hash)]
12        pub enum $name {
13            $($variant,)+
14            /// Forward-compatible value not known by this crate version.
15            Other(String),
16        }
17
18        impl $name {
19            pub fn as_str(&self) -> &str {
20                match self {
21                    $(Self::$variant => $wire,)+
22                    Self::Other(value) => value,
23                }
24            }
25
26            fn from_wire(value: String) -> Self {
27                match value.as_str() {
28                    $($wire => Self::$variant,)+
29                    _ => Self::Other(value),
30                }
31            }
32        }
33
34        impl Serialize for $name {
35            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
36            where
37                S: Serializer,
38            {
39                serializer.serialize_str(self.as_str())
40            }
41        }
42
43        impl<'de> Deserialize<'de> for $name {
44            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
45            where
46                D: Deserializer<'de>,
47            {
48                String::deserialize(deserializer).map(Self::from_wire)
49            }
50        }
51    };
52}
53
54fn null_to_default<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
55where
56    D: Deserializer<'de>,
57    T: Deserialize<'de> + Default,
58{
59    Option::<T>::deserialize(deserializer).map(Option::unwrap_or_default)
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63pub enum RollingExchangeSegment {
64    #[serde(rename = "NSE_EQ")]
65    NseEq,
66    #[serde(rename = "NSE_FNO")]
67    NseFno,
68    #[serde(rename = "BSE_EQ")]
69    BseEq,
70    #[serde(rename = "BSE_FNO")]
71    BseFno,
72    #[serde(rename = "MCX_COMM")]
73    McxComm,
74    #[serde(rename = "IDX_I")]
75    Index,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79pub enum RollingInterval {
80    #[serde(rename = "1")]
81    OneMinute,
82    #[serde(rename = "5")]
83    FiveMinutes,
84    #[serde(rename = "15")]
85    FifteenMinutes,
86    #[serde(rename = "25")]
87    TwentyFiveMinutes,
88    #[serde(rename = "60")]
89    SixtyMinutes,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
94pub enum RollingInstrument {
95    Index,
96    Futidx,
97    Optidx,
98    Equity,
99    Futstk,
100    Optstk,
101    Futcom,
102    Optfut,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
107pub enum RollingExpiryFlag {
108    Month,
109    Week,
110}
111
112/// Near-to-far rolling expiry selector. Dhan encodes this as a JSON number.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114#[repr(u8)]
115pub enum RollingExpiryCode {
116    First = 1,
117    Second = 2,
118    Third = 3,
119}
120
121impl Serialize for RollingExpiryCode {
122    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
123    where
124        S: Serializer,
125    {
126        serializer.serialize_u8(*self as u8)
127    }
128}
129
130impl<'de> Deserialize<'de> for RollingExpiryCode {
131    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
132    where
133        D: Deserializer<'de>,
134    {
135        match u8::deserialize(deserializer)? {
136            1 => Ok(Self::First),
137            2 => Ok(Self::Second),
138            3 => Ok(Self::Third),
139            value => Err(serde::de::Error::custom(format!(
140                "invalid rolling expiry code {value}; expected 1, 2, or 3"
141            ))),
142        }
143    }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
148pub enum RollingOptionType {
149    Call,
150    Put,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub enum RollingDataField {
156    Open,
157    High,
158    Low,
159    Close,
160    Iv,
161    Volume,
162    Strike,
163    Oi,
164    Spot,
165}
166
167/// Request for `POST /v2/charts/rollingoption`.
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct RollingOptionRequest {
171    pub exchange_segment: RollingExchangeSegment,
172    pub interval: RollingInterval,
173    pub security_id: u64,
174    pub instrument: RollingInstrument,
175    pub expiry_flag: RollingExpiryFlag,
176    pub expiry_code: RollingExpiryCode,
177    /// Relative strike expression such as `ATM` or `ATM+1`.
178    pub strike: String,
179    pub drv_option_type: RollingOptionType,
180    pub required_data: Vec<RollingDataField>,
181    /// Start date in `YYYY-MM-DD` format.
182    pub from_date: String,
183    /// End date in `YYYY-MM-DD` format.
184    pub to_date: String,
185}
186
187impl RollingOptionRequest {
188    pub(crate) fn validate(&self) -> std::result::Result<(), &'static str> {
189        if self.required_data.is_empty() {
190            return Err("rolling option required_data cannot be empty");
191        }
192        if self.strike.trim().is_empty()
193            || self.from_date.trim().is_empty()
194            || self.to_date.trim().is_empty()
195        {
196            return Err("rolling option strike and date fields cannot be empty");
197        }
198        Ok(())
199    }
200}
201
202/// Parallel arrays returned for one side of a rolling option chart.
203#[derive(Debug, Clone, PartialEq, Deserialize)]
204pub struct RollingOptionSeries {
205    #[serde(default, deserialize_with = "null_to_default")]
206    pub iv: Vec<f64>,
207    #[serde(default, deserialize_with = "null_to_default")]
208    pub oi: Vec<i64>,
209    #[serde(default, deserialize_with = "null_to_default")]
210    pub strike: Vec<f64>,
211    #[serde(default, deserialize_with = "null_to_default")]
212    pub spot: Vec<f64>,
213    #[serde(default, deserialize_with = "null_to_default")]
214    pub open: Vec<f64>,
215    #[serde(default, deserialize_with = "null_to_default")]
216    pub high: Vec<f64>,
217    #[serde(default, deserialize_with = "null_to_default")]
218    pub low: Vec<f64>,
219    #[serde(default, deserialize_with = "null_to_default")]
220    pub close: Vec<f64>,
221    #[serde(default, deserialize_with = "null_to_default")]
222    pub volume: Vec<i64>,
223    #[serde(default, deserialize_with = "null_to_default")]
224    pub timestamp: Vec<i64>,
225}
226
227#[derive(Debug, Clone, PartialEq, Deserialize)]
228pub struct RollingOptionData {
229    #[serde(default)]
230    pub ce: Option<RollingOptionSeries>,
231    #[serde(default)]
232    pub pe: Option<RollingOptionSeries>,
233}
234
235#[derive(Debug, Clone, PartialEq, Deserialize)]
236pub struct RollingOptionResponse {
237    pub data: RollingOptionData,
238    #[serde(default)]
239    pub status: Option<String>,
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243pub enum TechnicalExchangeSegment {
244    #[serde(rename = "NSE_EQ")]
245    NseEq,
246    #[serde(rename = "IDX_I")]
247    Index,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
252pub enum TechnicalInstrument {
253    Index,
254    Equity,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
258pub enum TechnicalTimeframe {
259    #[serde(rename = "1")]
260    OneMinute,
261    #[serde(rename = "5")]
262    FiveMinutes,
263    #[serde(rename = "15")]
264    FifteenMinutes,
265    #[serde(rename = "D")]
266    Daily,
267}
268
269string_wire_enum!(TechnicalIndicator {
270    Sma5 => "SMA_5",
271    Sma10 => "SMA_10",
272    Sma20 => "SMA_20",
273    Sma50 => "SMA_50",
274    Sma100 => "SMA_100",
275    Sma200 => "SMA_200",
276    Ema5 => "EMA_5",
277    Ema10 => "EMA_10",
278    Ema20 => "EMA_20",
279    Ema50 => "EMA_50",
280    Ema100 => "EMA_100",
281    Ema200 => "EMA_200",
282    Rsi14 => "RSI_14",
283    MacdHist => "MACD_HIST",
284    Stoch => "STOCH",
285    StochRsi14 => "STOCHRSI_14",
286    Atr14 => "ATR_14",
287    Adx14 => "ADX_14",
288    UltimateOscillator => "UO",
289    RateOfChange => "ROC",
290    WilliamsR => "WILLR",
291    PivotClassic => "PIVOT_CLASSIC",
292    PivotFibonacci => "PIVOT_FIBONACCI",
293    PivotCamarilla => "PIVOT_CAMARILLA",
294});
295
296/// Request for point-in-time technical metrics.
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct TechnicalMetricsRequest {
300    pub security_id: String,
301    pub exchange_segment: TechnicalExchangeSegment,
302    pub instrument: TechnicalInstrument,
303    pub timeframe: TechnicalTimeframe,
304    pub indicators: Vec<TechnicalIndicator>,
305}
306
307impl TechnicalMetricsRequest {
308    pub(crate) fn validate(&self) -> std::result::Result<(), &'static str> {
309        if self.security_id.trim().is_empty() {
310            return Err("technical metrics security_id cannot be empty");
311        }
312        if self.indicators.is_empty()
313            || self
314                .indicators
315                .iter()
316                .any(|value| value.as_str().trim().is_empty())
317        {
318            return Err("technical metrics indicators must contain non-empty values");
319        }
320        Ok(())
321    }
322}
323
324/// Technical metrics are keyed by requested indicator and intentionally use a
325/// dynamic value because each indicator has a distinct documented shape.
326#[derive(Debug, Clone, PartialEq, Deserialize)]
327#[serde(rename_all = "camelCase")]
328pub struct TechnicalMetricsResponse {
329    pub security_id: String,
330    pub timeframe: String,
331    #[serde(default)]
332    pub data: HashMap<String, serde_json::Value>,
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
336pub enum MarketMoverExchangeSegment {
337    #[serde(rename = "NSE_FNO")]
338    NseFno,
339    #[serde(rename = "BSE_FNO")]
340    BseFno,
341    #[serde(rename = "NSE_COMM")]
342    NseComm,
343    #[serde(rename = "MCX_COMM")]
344    McxComm,
345    #[serde(rename = "NSE_EQ")]
346    NseEq,
347    #[serde(rename = "BSE_EQ")]
348    BseEq,
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
352#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
353pub enum MarketMoverInstrument {
354    Optidx,
355    Optstk,
356    Optfut,
357    Futidx,
358    Futstk,
359    Futcom,
360    Equity,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
364#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
365pub enum MarketMoverCategory {
366    HighestOi,
367    OiGainers,
368    OiLosers,
369    TopVolume,
370    PriceGainers,
371    PriceLosers,
372}
373
374string_wire_enum!(MarketMoverUniverse {
375    All => "ALL",
376    FnoStocks => "FNO_STOCKS",
377    Nifty50 => "NIFTY_50",
378    NiftyBank => "NIFTY_BANK",
379    Finnifty => "FINNIFTY",
380    IndiaVix => "INDIA_VIX",
381    NiftyMidcap => "NIFTY_MIDCAP",
382    NiftyNext50 => "NIFTY_NEXT_50",
383    NiftySmallcap50 => "NIFTY_SMALLCAP_50",
384    NiftyMidCap50 => "NIFTY_MID_CAP_50",
385    Nifty100 => "NIFTY_100",
386    Nifty200 => "NIFTY_200",
387    Nifty500 => "NIFTY_500",
388    NiftyMidcap100 => "NIFTY_MIDCAP_100",
389    NiftyMidcap150 => "NIFTY_MIDCAP_150",
390    NiftySmallcap100 => "NIFTY_SMALLCAP_100",
391    NiftySmallcap250 => "NIFTY_SMALLCAP_250",
392    NiftyMicrocap250 => "NIFTY_MICROCAP_250",
393    NiftyAuto => "NIFTY_AUTO",
394    NiftyPrivateBank => "NIFTY_PRIVATE_BANK",
395    NiftyFmcg => "NIFTY_FMCG",
396    NiftyEnergy => "NIFTY_ENERGY",
397    NiftyInfra => "NIFTY_INFRA",
398    NiftyIt => "NIFTY_IT",
399    NiftyMedia => "NIFTY_MEDIA",
400    NiftyMetal => "NIFTY_METAL",
401    NiftyMnc => "NIFTY_MNC",
402    NiftyPharma => "NIFTY_PHARMA",
403    NiftyPsuBank => "NIFTY_PSU_BANK",
404    NiftyRealty => "NIFTY_REALTY",
405    NiftyServiceSector => "NIFTY_SERVICE_SECTOR",
406    NiftyConsumption => "NIFTY_CUNSUMPTION",
407    GiftNifty => "GIFT_NIFTY",
408    Sensex => "SENSEX",
409    Bse100 => "BSE_100",
410    Bse200 => "BSE_200",
411    Bse500 => "BSE_500",
412    Bse150Midcap => "BSE_150_MIDCAP",
413    Bse250Smallcap => "BSE_250_SMALLCAP",
414    Bse250LargeMid => "BSE_250_LARGE_MID",
415    Bse400MidSmall => "BSE_400_MID_SMALL",
416    BseBankex => "BSE_BANKEX",
417    BseAuto => "BSE_AUTO",
418    BseCapitalGoods => "BSE_CAPITAL_GOODS",
419    BseConsumerDurables => "BSE_CONSUMER_DURABLES",
420    BseEnergy => "BSE_ENERGY",
421    BseFinance => "BSE_FINANCE",
422    BseFmcg => "BSE_FMCG",
423    BseHealthcare => "BSE_HEALTHCARE",
424    BseIndiaMfg => "BSE_INDIA_MFG",
425    BseIndustrials => "BSE_INDUSTRIALS",
426    BseIpo => "BSE_IPO",
427    BseIt => "BSE_IT",
428    BseMetals => "BSE_METALS",
429    BseOilAndGas => "BSE_OIL_AND_GAS",
430    BsePower => "BSE_POWER",
431    BsePsu => "BSE_PSU",
432    BseTelecom => "BSE_TELECOM",
433});
434
435/// Request for ranked market movers.
436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
437#[serde(rename_all = "camelCase")]
438pub struct MarketMoversRequest {
439    pub exchange_segment: MarketMoverExchangeSegment,
440    pub instrument: Vec<MarketMoverInstrument>,
441    pub category: MarketMoverCategory,
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub expiry: Option<String>,
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub universe: Option<MarketMoverUniverse>,
446    /// Documented range: 1 through 100.
447    pub limit: u8,
448}
449
450impl MarketMoversRequest {
451    pub(crate) fn validate(&self) -> std::result::Result<(), &'static str> {
452        if self.instrument.is_empty() {
453            return Err("market movers instrument list cannot be empty");
454        }
455        if !(1..=100).contains(&self.limit) {
456            return Err("market movers limit must be between 1 and 100");
457        }
458        let group = self.instrument[0].group();
459        if self.instrument.iter().any(|value| value.group() != group) {
460            return Err("market movers instrument values must belong to one instrument group");
461        }
462        match group {
463            MarketMoverInstrumentGroup::Equity if self.universe.is_none() => {
464                return Err("market movers equity requests require universe");
465            }
466            MarketMoverInstrumentGroup::Options | MarketMoverInstrumentGroup::Futures
467                if self
468                    .expiry
469                    .as_deref()
470                    .is_none_or(|value| value.trim().is_empty()) =>
471            {
472                return Err("market movers derivative requests require expiry");
473            }
474            _ => {}
475        }
476        Ok(())
477    }
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481enum MarketMoverInstrumentGroup {
482    Options,
483    Futures,
484    Equity,
485}
486
487impl MarketMoverInstrument {
488    fn group(self) -> MarketMoverInstrumentGroup {
489        match self {
490            Self::Optidx | Self::Optstk | Self::Optfut => MarketMoverInstrumentGroup::Options,
491            Self::Futidx | Self::Futstk | Self::Futcom => MarketMoverInstrumentGroup::Futures,
492            Self::Equity => MarketMoverInstrumentGroup::Equity,
493        }
494    }
495}
496
497#[derive(Debug, Clone, PartialEq, Deserialize)]
498#[serde(rename_all = "camelCase")]
499pub struct MarketMoverInstrumentDetail {
500    #[serde(default)]
501    pub security_id: Option<String>,
502    #[serde(default)]
503    pub exchange_segment: Option<String>,
504    #[serde(default)]
505    pub trading_symbol: Option<String>,
506    #[serde(default)]
507    pub display_name: Option<String>,
508    #[serde(default)]
509    pub instrument: Option<String>,
510    #[serde(default)]
511    pub expiry: Option<String>,
512    #[serde(default)]
513    pub strike_price: Option<f64>,
514    #[serde(default)]
515    pub tick_size: Option<f64>,
516    #[serde(default)]
517    pub lot_size: Option<i32>,
518    #[serde(default)]
519    pub ltp: Option<f64>,
520    #[serde(default)]
521    pub change: Option<f64>,
522    #[serde(default)]
523    pub change_percent: Option<f64>,
524    #[serde(default)]
525    pub volume: Option<i64>,
526    #[serde(default)]
527    pub traded_value: Option<f64>,
528    #[serde(default)]
529    pub underlying_security_id: Option<String>,
530    #[serde(default)]
531    pub underlying_ltp: Option<f64>,
532    #[serde(default)]
533    pub premium_discount: Option<f64>,
534    #[serde(default)]
535    pub premium_discount_percent: Option<f64>,
536    #[serde(default)]
537    pub open_interest: Option<i64>,
538    #[serde(default)]
539    pub open_interest_change: Option<i64>,
540    #[serde(default)]
541    pub open_interest_change_percent: Option<f64>,
542    #[serde(default)]
543    pub put_call_ratio: Option<f64>,
544}
545
546#[derive(Debug, Clone, PartialEq, Deserialize)]
547#[serde(rename_all = "camelCase")]
548pub struct MarketMoversResponse {
549    pub exchange_segment: String,
550    pub category: String,
551    #[serde(default)]
552    pub data: Vec<MarketMoverInstrumentDetail>,
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
556pub enum FundamentalExchangeSegment {
557    #[serde(rename = "NSE_EQ")]
558    NseEq,
559    #[serde(rename = "BSE_EQ")]
560    BseEq,
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
564#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
565pub enum FundamentalMetricSection {
566    Co,
567    Ratios,
568    Shp,
569}
570
571#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
572#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
573pub enum CompanyInstrument {
574    Equity,
575}
576
577/// Request for company overview, ratios, or shareholding data.
578#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
579#[serde(rename_all = "camelCase")]
580pub struct CompanyInfoRequest {
581    pub security_id: String,
582    pub exchange_segment: FundamentalExchangeSegment,
583    pub instrument: CompanyInstrument,
584    pub metrics: Vec<FundamentalMetricSection>,
585}
586
587impl CompanyInfoRequest {
588    pub(crate) fn validate(&self) -> std::result::Result<(), &'static str> {
589        if self.security_id.trim().is_empty() {
590            return Err("company info security_id cannot be empty");
591        }
592        if self.metrics.is_empty() {
593            return Err("company info metrics cannot be empty");
594        }
595        Ok(())
596    }
597}
598
599/// Company metric sections contain heterogeneous values and are therefore
600/// retained as section-keyed JSON objects without losing newly added fields.
601#[derive(Debug, Clone, PartialEq, Deserialize)]
602#[serde(rename_all = "camelCase")]
603pub struct CompanyInfoResponse {
604    pub security_id: String,
605    #[serde(default)]
606    pub data: HashMap<String, serde_json::Value>,
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn rolling_option_request_matches_documented_wire_names() {
615        let request = RollingOptionRequest {
616            exchange_segment: RollingExchangeSegment::NseFno,
617            interval: RollingInterval::OneMinute,
618            security_id: 13,
619            instrument: RollingInstrument::Optidx,
620            expiry_flag: RollingExpiryFlag::Month,
621            expiry_code: RollingExpiryCode::First,
622            strike: "ATM".into(),
623            drv_option_type: RollingOptionType::Call,
624            required_data: vec![RollingDataField::Open, RollingDataField::Iv],
625            from_date: "2026-01-01".into(),
626            to_date: "2026-01-31".into(),
627        };
628
629        let value = serde_json::to_value(request).unwrap();
630        assert_eq!(value["exchangeSegment"], "NSE_FNO");
631        assert_eq!(value["interval"], "1");
632        assert_eq!(value["instrument"], "OPTIDX");
633        assert_eq!(value["expiryFlag"], "MONTH");
634        assert_eq!(value["drvOptionType"], "CALL");
635        assert_eq!(value["requiredData"], serde_json::json!(["open", "iv"]));
636    }
637
638    #[test]
639    fn rolling_series_tolerates_omitted_and_null_arrays() {
640        let response: RollingOptionResponse = serde_json::from_value(serde_json::json!({
641            "data": {
642                "ce": { "open": [1.0], "iv": null },
643                "pe": null
644            }
645        }))
646        .unwrap();
647
648        let ce = response.data.ce.unwrap();
649        assert_eq!(ce.open, vec![1.0]);
650        assert!(ce.iv.is_empty());
651        assert!(ce.close.is_empty());
652        assert!(response.data.pe.is_none());
653    }
654
655    #[test]
656    fn data_requests_serialize_exact_enums() {
657        let technical = TechnicalMetricsRequest {
658            security_id: "1333".into(),
659            exchange_segment: TechnicalExchangeSegment::NseEq,
660            instrument: TechnicalInstrument::Equity,
661            timeframe: TechnicalTimeframe::Daily,
662            indicators: vec![TechnicalIndicator::Rsi14],
663        };
664        assert_eq!(
665            serde_json::to_value(technical).unwrap(),
666            serde_json::json!({
667                "securityId": "1333",
668                "exchangeSegment": "NSE_EQ",
669                "instrument": "EQUITY",
670                "timeframe": "D",
671                "indicators": ["RSI_14"]
672            })
673        );
674
675        let movers = MarketMoversRequest {
676            exchange_segment: MarketMoverExchangeSegment::NseFno,
677            instrument: vec![MarketMoverInstrument::Optidx],
678            category: MarketMoverCategory::HighestOi,
679            expiry: Some("2026-08-27".into()),
680            universe: Some(MarketMoverUniverse::Nifty50),
681            limit: 20,
682        };
683        let movers = serde_json::to_value(movers).unwrap();
684        assert_eq!(movers["category"], "HIGHEST_OI");
685        assert_eq!(movers["instrument"], serde_json::json!(["OPTIDX"]));
686        assert_eq!(movers["expiry"], "2026-08-27");
687    }
688
689    #[test]
690    fn request_validation_rejects_documented_limit_violations() {
691        let invalid = MarketMoversRequest {
692            exchange_segment: MarketMoverExchangeSegment::NseEq,
693            instrument: vec![MarketMoverInstrument::Equity],
694            category: MarketMoverCategory::TopVolume,
695            expiry: None,
696            universe: Some(MarketMoverUniverse::All),
697            limit: 0,
698        };
699        assert!(invalid.validate().is_err());
700
701        let invalid = TechnicalMetricsRequest {
702            security_id: "1333".into(),
703            exchange_segment: TechnicalExchangeSegment::NseEq,
704            instrument: TechnicalInstrument::Equity,
705            timeframe: TechnicalTimeframe::Daily,
706            indicators: Vec::new(),
707        };
708        assert!(invalid.validate().is_err());
709    }
710}