1use crate::Error;
2use crate::common::query::QueryWriter;
3use crate::common::validate::{validate_required_symbol, validate_required_symbols};
4use crate::transport::pagination::PaginatedRequest;
5
6use super::{ContractType, OptionsFeed, Sort, TickType, TimeFrame};
7
8#[derive(Clone, Debug, Default)]
9pub struct BarsRequest {
10 pub symbols: Vec<String>,
11 pub timeframe: TimeFrame,
12 pub start: Option<String>,
13 pub end: Option<String>,
14 pub limit: Option<u32>,
15 pub sort: Option<Sort>,
16 pub page_token: Option<String>,
17}
18
19#[derive(Clone, Debug, Default)]
20pub struct TradesRequest {
21 pub symbols: Vec<String>,
22 pub start: Option<String>,
23 pub end: Option<String>,
24 pub limit: Option<u32>,
25 pub sort: Option<Sort>,
26 pub page_token: Option<String>,
27}
28
29#[derive(Clone, Debug, Default)]
30pub struct LatestQuotesRequest {
31 pub symbols: Vec<String>,
32 pub feed: Option<OptionsFeed>,
33}
34
35#[derive(Clone, Debug, Default)]
36pub struct LatestTradesRequest {
37 pub symbols: Vec<String>,
38 pub feed: Option<OptionsFeed>,
39}
40
41#[derive(Clone, Debug, Default)]
42pub struct SnapshotsRequest {
43 pub symbols: Vec<String>,
44 pub feed: Option<OptionsFeed>,
45 pub limit: Option<u32>,
46 pub page_token: Option<String>,
47}
48
49#[derive(Clone, Debug, Default)]
50pub struct ChainRequest {
51 pub underlying_symbol: String,
52 pub feed: Option<OptionsFeed>,
53 pub r#type: Option<ContractType>,
54 pub strike_price_gte: Option<rust_decimal::Decimal>,
55 pub strike_price_lte: Option<rust_decimal::Decimal>,
56 pub expiration_date: Option<String>,
57 pub expiration_date_gte: Option<String>,
58 pub expiration_date_lte: Option<String>,
59 pub root_symbol: Option<String>,
60 pub updated_since: Option<String>,
61 pub limit: Option<u32>,
62 pub page_token: Option<String>,
63}
64
65#[derive(Clone, Debug, Default)]
66pub struct ConditionCodesRequest {
67 pub ticktype: TickType,
68}
69
70impl BarsRequest {
71 pub(crate) fn validate(&self) -> Result<(), Error> {
72 validate_option_symbols(&self.symbols)?;
73 validate_limit(self.limit, 1, 10_000)
74 }
75
76 pub(crate) fn to_query(self) -> Vec<(String, String)> {
77 let mut query = QueryWriter::default();
78 query.push_csv("symbols", self.symbols);
79 query.push_opt("timeframe", Some(self.timeframe));
80 query.push_opt("start", self.start);
81 query.push_opt("end", self.end);
82 query.push_opt("limit", self.limit);
83 query.push_opt("page_token", self.page_token);
84 query.push_opt("sort", self.sort);
85 query.finish()
86 }
87}
88
89impl TradesRequest {
90 pub(crate) fn validate(&self) -> Result<(), Error> {
91 validate_option_symbols(&self.symbols)?;
92 validate_limit(self.limit, 1, 10_000)
93 }
94
95 pub(crate) fn to_query(self) -> Vec<(String, String)> {
96 let mut query = QueryWriter::default();
97 query.push_csv("symbols", self.symbols);
98 query.push_opt("start", self.start);
99 query.push_opt("end", self.end);
100 query.push_opt("limit", self.limit);
101 query.push_opt("page_token", self.page_token);
102 query.push_opt("sort", self.sort);
103 query.finish()
104 }
105}
106
107impl LatestQuotesRequest {
108 pub(crate) fn validate(&self) -> Result<(), Error> {
109 validate_option_symbols(&self.symbols)
110 }
111
112 #[allow(dead_code)]
113 pub(crate) fn to_query(self) -> Vec<(String, String)> {
114 latest_query(self.symbols, self.feed)
115 }
116}
117
118impl LatestTradesRequest {
119 pub(crate) fn validate(&self) -> Result<(), Error> {
120 validate_option_symbols(&self.symbols)
121 }
122
123 #[allow(dead_code)]
124 pub(crate) fn to_query(self) -> Vec<(String, String)> {
125 latest_query(self.symbols, self.feed)
126 }
127}
128
129impl SnapshotsRequest {
130 pub(crate) fn validate(&self) -> Result<(), Error> {
131 validate_option_symbols(&self.symbols)?;
132 validate_limit(self.limit, 1, 1_000)
133 }
134
135 #[allow(dead_code)]
136 pub(crate) fn to_query(self) -> Vec<(String, String)> {
137 let mut query = QueryWriter::default();
138 query.push_csv("symbols", self.symbols);
139 query.push_opt("feed", self.feed);
140 query.push_opt("limit", self.limit);
141 query.push_opt("page_token", self.page_token);
142 query.finish()
143 }
144}
145
146impl ChainRequest {
147 pub(crate) fn validate(&self) -> Result<(), Error> {
148 validate_required_symbol(&self.underlying_symbol, "underlying_symbol")?;
149 validate_limit(self.limit, 1, 1_000)
150 }
151
152 #[allow(dead_code)]
153 pub(crate) fn to_query(self) -> Vec<(String, String)> {
154 let mut query = QueryWriter::default();
155 query.push_opt("feed", self.feed);
156 query.push_opt("type", self.r#type);
157 query.push_opt("strike_price_gte", self.strike_price_gte);
158 query.push_opt("strike_price_lte", self.strike_price_lte);
159 query.push_opt("expiration_date", self.expiration_date);
160 query.push_opt("expiration_date_gte", self.expiration_date_gte);
161 query.push_opt("expiration_date_lte", self.expiration_date_lte);
162 query.push_opt("root_symbol", self.root_symbol);
163 query.push_opt("updated_since", self.updated_since);
164 query.push_opt("limit", self.limit);
165 query.push_opt("page_token", self.page_token);
166 query.finish()
167 }
168}
169
170impl ConditionCodesRequest {
171 pub(crate) fn ticktype(&self) -> &'static str {
172 self.ticktype.as_str()
173 }
174}
175
176impl PaginatedRequest for BarsRequest {
177 fn with_page_token(&self, page_token: Option<String>) -> Self {
178 let mut next = self.clone();
179 next.page_token = page_token;
180 next
181 }
182}
183
184impl PaginatedRequest for TradesRequest {
185 fn with_page_token(&self, page_token: Option<String>) -> Self {
186 let mut next = self.clone();
187 next.page_token = page_token;
188 next
189 }
190}
191
192impl PaginatedRequest for SnapshotsRequest {
193 fn with_page_token(&self, page_token: Option<String>) -> Self {
194 let mut next = self.clone();
195 next.page_token = page_token;
196 next
197 }
198}
199
200impl PaginatedRequest for ChainRequest {
201 fn with_page_token(&self, page_token: Option<String>) -> Self {
202 let mut next = self.clone();
203 next.page_token = page_token;
204 next
205 }
206}
207
208#[allow(dead_code)]
209fn latest_query(symbols: Vec<String>, feed: Option<OptionsFeed>) -> Vec<(String, String)> {
210 let mut query = QueryWriter::default();
211 query.push_csv("symbols", symbols);
212 query.push_opt("feed", feed);
213 query.finish()
214}
215
216fn validate_option_symbols(symbols: &[String]) -> Result<(), Error> {
217 if symbols.is_empty() {
218 return validate_required_symbols(symbols);
219 }
220
221 if symbols.len() > 100 {
222 return Err(Error::InvalidRequest(
223 "symbols must contain at most 100 contract symbols".into(),
224 ));
225 }
226
227 validate_required_symbols(symbols)
228}
229
230fn validate_limit(limit: Option<u32>, min: u32, max: u32) -> Result<(), Error> {
231 if let Some(limit) = limit {
232 if !(min..=max).contains(&limit) {
233 return Err(Error::InvalidRequest(format!(
234 "limit must be between {min} and {max}"
235 )));
236 }
237 }
238
239 Ok(())
240}
241
242#[cfg(test)]
243mod tests {
244 use std::str::FromStr;
245
246 use crate::Error;
247 use rust_decimal::Decimal;
248
249 use super::{
250 BarsRequest, ChainRequest, ConditionCodesRequest, ContractType, LatestQuotesRequest,
251 LatestTradesRequest, OptionsFeed, SnapshotsRequest, Sort, TickType, TimeFrame,
252 TradesRequest,
253 };
254
255 #[test]
256 fn bars_request_serializes_official_query_words() {
257 let query = BarsRequest {
258 symbols: vec!["AAPL260406C00180000".into(), "AAPL260406C00185000".into()],
259 timeframe: TimeFrame::from("1Day"),
260 start: Some("2026-04-01T00:00:00Z".into()),
261 end: Some("2026-04-03T00:00:00Z".into()),
262 limit: Some(2),
263 sort: Some(Sort::Asc),
264 page_token: Some("page-2".into()),
265 }
266 .to_query();
267
268 assert_eq!(
269 query,
270 vec![
271 (
272 "symbols".to_string(),
273 "AAPL260406C00180000,AAPL260406C00185000".to_string(),
274 ),
275 ("timeframe".to_string(), "1Day".to_string()),
276 ("start".to_string(), "2026-04-01T00:00:00Z".to_string()),
277 ("end".to_string(), "2026-04-03T00:00:00Z".to_string()),
278 ("limit".to_string(), "2".to_string()),
279 ("page_token".to_string(), "page-2".to_string()),
280 ("sort".to_string(), "asc".to_string()),
281 ]
282 );
283 }
284
285 #[test]
286 fn trades_request_serializes_official_query_words() {
287 let query = TradesRequest {
288 symbols: vec!["AAPL260406C00180000".into()],
289 start: Some("2026-04-02T13:39:00Z".into()),
290 end: Some("2026-04-02T13:40:00Z".into()),
291 limit: Some(1),
292 sort: Some(Sort::Desc),
293 page_token: Some("page-3".into()),
294 }
295 .to_query();
296
297 assert_eq!(
298 query,
299 vec![
300 ("symbols".to_string(), "AAPL260406C00180000".to_string()),
301 ("start".to_string(), "2026-04-02T13:39:00Z".to_string()),
302 ("end".to_string(), "2026-04-02T13:40:00Z".to_string()),
303 ("limit".to_string(), "1".to_string()),
304 ("page_token".to_string(), "page-3".to_string()),
305 ("sort".to_string(), "desc".to_string()),
306 ]
307 );
308 }
309
310 #[test]
311 fn latest_requests_serialize_official_query_words() {
312 let quotes_query = LatestQuotesRequest {
313 symbols: vec!["AAPL260406C00180000".into()],
314 feed: Some(OptionsFeed::Indicative),
315 }
316 .to_query();
317 assert_eq!(
318 quotes_query,
319 vec![
320 ("symbols".to_string(), "AAPL260406C00180000".to_string()),
321 ("feed".to_string(), "indicative".to_string()),
322 ]
323 );
324
325 let trades_query = LatestTradesRequest {
326 symbols: vec!["AAPL260406C00180000".into(), "AAPL260406C00185000".into()],
327 feed: Some(OptionsFeed::Opra),
328 }
329 .to_query();
330 assert_eq!(
331 trades_query,
332 vec![
333 (
334 "symbols".to_string(),
335 "AAPL260406C00180000,AAPL260406C00185000".to_string(),
336 ),
337 ("feed".to_string(), "opra".to_string()),
338 ]
339 );
340 }
341
342 #[test]
343 fn snapshot_requests_serialize_official_query_words() {
344 let query = SnapshotsRequest {
345 symbols: vec!["AAPL260406C00180000".into(), "AAPL260406C00185000".into()],
346 feed: Some(OptionsFeed::Indicative),
347 limit: Some(2),
348 page_token: Some("page-2".into()),
349 }
350 .to_query();
351
352 assert_eq!(
353 query,
354 vec![
355 (
356 "symbols".to_string(),
357 "AAPL260406C00180000,AAPL260406C00185000".to_string(),
358 ),
359 ("feed".to_string(), "indicative".to_string()),
360 ("limit".to_string(), "2".to_string()),
361 ("page_token".to_string(), "page-2".to_string()),
362 ]
363 );
364 }
365
366 #[test]
367 fn chain_request_serializes_official_query_words() {
368 let query = ChainRequest {
369 underlying_symbol: "AAPL".into(),
370 feed: Some(OptionsFeed::Indicative),
371 r#type: Some(ContractType::Call),
372 strike_price_gte: Some(
373 Decimal::from_str("180.0").expect("decimal literal should parse"),
374 ),
375 strike_price_lte: Some(
376 Decimal::from_str("200.0").expect("decimal literal should parse"),
377 ),
378 expiration_date: Some("2026-04-06".into()),
379 expiration_date_gte: Some("2026-04-06".into()),
380 expiration_date_lte: Some("2026-04-13".into()),
381 root_symbol: Some("AAPL".into()),
382 updated_since: Some("2026-04-02T19:30:00Z".into()),
383 limit: Some(3),
384 page_token: Some("page-3".into()),
385 }
386 .to_query();
387
388 assert_eq!(
389 query,
390 vec![
391 ("feed".to_string(), "indicative".to_string()),
392 ("type".to_string(), "call".to_string()),
393 ("strike_price_gte".to_string(), "180.0".to_string()),
394 ("strike_price_lte".to_string(), "200.0".to_string()),
395 ("expiration_date".to_string(), "2026-04-06".to_string()),
396 ("expiration_date_gte".to_string(), "2026-04-06".to_string()),
397 ("expiration_date_lte".to_string(), "2026-04-13".to_string()),
398 ("root_symbol".to_string(), "AAPL".to_string()),
399 (
400 "updated_since".to_string(),
401 "2026-04-02T19:30:00Z".to_string()
402 ),
403 ("limit".to_string(), "3".to_string()),
404 ("page_token".to_string(), "page-3".to_string()),
405 ]
406 );
407 }
408
409 #[test]
410 fn condition_codes_request_uses_official_ticktype_word() {
411 let trade = ConditionCodesRequest {
412 ticktype: TickType::Trade,
413 };
414 assert_eq!(trade.ticktype(), "trade");
415
416 let quote = ConditionCodesRequest {
417 ticktype: TickType::Quote,
418 };
419 assert_eq!(quote.ticktype(), "quote");
420 }
421
422 #[test]
423 fn requests_reject_empty_or_oversized_symbol_lists() {
424 let empty_errors = [
425 BarsRequest::default()
426 .validate()
427 .expect_err("bars symbols must be required"),
428 TradesRequest::default()
429 .validate()
430 .expect_err("trades symbols must be required"),
431 LatestQuotesRequest::default()
432 .validate()
433 .expect_err("latest quotes symbols must be required"),
434 LatestTradesRequest::default()
435 .validate()
436 .expect_err("latest trades symbols must be required"),
437 SnapshotsRequest::default()
438 .validate()
439 .expect_err("snapshots symbols must be required"),
440 ];
441
442 for error in empty_errors {
443 assert!(matches!(
444 error,
445 Error::InvalidRequest(message)
446 if message.contains("symbols") && message.contains("empty")
447 ));
448 }
449
450 let symbols = (0..101)
451 .map(|index| format!("AAPL260406C{:08}", index))
452 .collect::<Vec<_>>();
453
454 let oversized_errors = [
455 BarsRequest {
456 symbols: symbols.clone(),
457 ..BarsRequest::default()
458 }
459 .validate()
460 .expect_err("bars symbols over one hundred must fail"),
461 LatestQuotesRequest {
462 symbols: symbols.clone(),
463 ..LatestQuotesRequest::default()
464 }
465 .validate()
466 .expect_err("latest quotes symbols over one hundred must fail"),
467 SnapshotsRequest {
468 symbols,
469 ..SnapshotsRequest::default()
470 }
471 .validate()
472 .expect_err("snapshots symbols over one hundred must fail"),
473 ];
474
475 for error in oversized_errors {
476 assert!(matches!(
477 error,
478 Error::InvalidRequest(message)
479 if message.contains("symbols") && message.contains("100")
480 ));
481 }
482 }
483
484 #[test]
485 fn oversized_symbol_lists_still_win_before_blank_entry_errors() {
486 let mut symbols = (0..101)
487 .map(|index| format!("AAPL260406C{:08}", index))
488 .collect::<Vec<_>>();
489 symbols[100] = " ".into();
490
491 let error = LatestQuotesRequest {
492 symbols,
493 ..LatestQuotesRequest::default()
494 }
495 .validate()
496 .expect_err("mixed invalid symbol lists should still report the options cap first");
497
498 assert!(matches!(
499 error,
500 Error::InvalidRequest(message)
501 if message.contains("symbols") && message.contains("100")
502 ));
503 }
504
505 #[test]
506 fn chain_request_rejects_blank_underlying_symbols() {
507 let errors = [
508 ChainRequest::default()
509 .validate()
510 .expect_err("chain underlying symbol must be required"),
511 ChainRequest {
512 underlying_symbol: " ".into(),
513 ..ChainRequest::default()
514 }
515 .validate()
516 .expect_err("chain underlying symbol must reject whitespace-only input"),
517 ];
518
519 for error in errors {
520 assert!(matches!(
521 error,
522 Error::InvalidRequest(message)
523 if message.contains("underlying_symbol") && message.contains("invalid")
524 ));
525 }
526 }
527
528 #[test]
529 fn requests_reject_limits_outside_documented_ranges() {
530 let errors = [
531 BarsRequest {
532 symbols: vec!["AAPL260406C00180000".into()],
533 limit: Some(0),
534 ..BarsRequest::default()
535 }
536 .validate()
537 .expect_err("bars limit below one must fail"),
538 TradesRequest {
539 symbols: vec!["AAPL260406C00180000".into()],
540 limit: Some(10_001),
541 ..TradesRequest::default()
542 }
543 .validate()
544 .expect_err("trades limit above ten thousand must fail"),
545 SnapshotsRequest {
546 symbols: vec!["AAPL260406C00180000".into()],
547 limit: Some(0),
548 ..SnapshotsRequest::default()
549 }
550 .validate()
551 .expect_err("snapshots limit below one must fail"),
552 ChainRequest {
553 underlying_symbol: "AAPL".into(),
554 limit: Some(1_001),
555 ..ChainRequest::default()
556 }
557 .validate()
558 .expect_err("chain limit above one thousand must fail"),
559 ];
560
561 let expected_maxima = ["10000", "10000", "1000", "1000"];
562 for (error, expected_max) in errors.into_iter().zip(expected_maxima) {
563 assert!(matches!(
564 error,
565 Error::InvalidRequest(message)
566 if message.contains("limit") && message.contains(expected_max)
567 ));
568 }
569 }
570}