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<f64>,
55 pub strike_price_lte: Option<f64>,
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 crate::Error;
245
246 use super::{
247 BarsRequest, ChainRequest, ConditionCodesRequest, ContractType, LatestQuotesRequest,
248 LatestTradesRequest, OptionsFeed, SnapshotsRequest, Sort, TickType, TimeFrame,
249 TradesRequest,
250 };
251
252 #[test]
253 fn bars_request_serializes_official_query_words() {
254 let query = BarsRequest {
255 symbols: vec!["AAPL260406C00180000".into(), "AAPL260406C00185000".into()],
256 timeframe: TimeFrame::from("1Day"),
257 start: Some("2026-04-01T00:00:00Z".into()),
258 end: Some("2026-04-03T00:00:00Z".into()),
259 limit: Some(2),
260 sort: Some(Sort::Asc),
261 page_token: Some("page-2".into()),
262 }
263 .to_query();
264
265 assert_eq!(
266 query,
267 vec![
268 (
269 "symbols".to_string(),
270 "AAPL260406C00180000,AAPL260406C00185000".to_string(),
271 ),
272 ("timeframe".to_string(), "1Day".to_string()),
273 ("start".to_string(), "2026-04-01T00:00:00Z".to_string()),
274 ("end".to_string(), "2026-04-03T00:00:00Z".to_string()),
275 ("limit".to_string(), "2".to_string()),
276 ("page_token".to_string(), "page-2".to_string()),
277 ("sort".to_string(), "asc".to_string()),
278 ]
279 );
280 }
281
282 #[test]
283 fn trades_request_serializes_official_query_words() {
284 let query = TradesRequest {
285 symbols: vec!["AAPL260406C00180000".into()],
286 start: Some("2026-04-02T13:39:00Z".into()),
287 end: Some("2026-04-02T13:40:00Z".into()),
288 limit: Some(1),
289 sort: Some(Sort::Desc),
290 page_token: Some("page-3".into()),
291 }
292 .to_query();
293
294 assert_eq!(
295 query,
296 vec![
297 ("symbols".to_string(), "AAPL260406C00180000".to_string()),
298 ("start".to_string(), "2026-04-02T13:39:00Z".to_string()),
299 ("end".to_string(), "2026-04-02T13:40:00Z".to_string()),
300 ("limit".to_string(), "1".to_string()),
301 ("page_token".to_string(), "page-3".to_string()),
302 ("sort".to_string(), "desc".to_string()),
303 ]
304 );
305 }
306
307 #[test]
308 fn latest_requests_serialize_official_query_words() {
309 let quotes_query = LatestQuotesRequest {
310 symbols: vec!["AAPL260406C00180000".into()],
311 feed: Some(OptionsFeed::Indicative),
312 }
313 .to_query();
314 assert_eq!(
315 quotes_query,
316 vec![
317 ("symbols".to_string(), "AAPL260406C00180000".to_string()),
318 ("feed".to_string(), "indicative".to_string()),
319 ]
320 );
321
322 let trades_query = LatestTradesRequest {
323 symbols: vec!["AAPL260406C00180000".into(), "AAPL260406C00185000".into()],
324 feed: Some(OptionsFeed::Opra),
325 }
326 .to_query();
327 assert_eq!(
328 trades_query,
329 vec![
330 (
331 "symbols".to_string(),
332 "AAPL260406C00180000,AAPL260406C00185000".to_string(),
333 ),
334 ("feed".to_string(), "opra".to_string()),
335 ]
336 );
337 }
338
339 #[test]
340 fn snapshot_requests_serialize_official_query_words() {
341 let query = SnapshotsRequest {
342 symbols: vec!["AAPL260406C00180000".into(), "AAPL260406C00185000".into()],
343 feed: Some(OptionsFeed::Indicative),
344 limit: Some(2),
345 page_token: Some("page-2".into()),
346 }
347 .to_query();
348
349 assert_eq!(
350 query,
351 vec![
352 (
353 "symbols".to_string(),
354 "AAPL260406C00180000,AAPL260406C00185000".to_string(),
355 ),
356 ("feed".to_string(), "indicative".to_string()),
357 ("limit".to_string(), "2".to_string()),
358 ("page_token".to_string(), "page-2".to_string()),
359 ]
360 );
361 }
362
363 #[test]
364 fn chain_request_serializes_official_query_words() {
365 let query = ChainRequest {
366 underlying_symbol: "AAPL".into(),
367 feed: Some(OptionsFeed::Indicative),
368 r#type: Some(ContractType::Call),
369 strike_price_gte: Some(180.0),
370 strike_price_lte: Some(200.0),
371 expiration_date: Some("2026-04-06".into()),
372 expiration_date_gte: Some("2026-04-06".into()),
373 expiration_date_lte: Some("2026-04-13".into()),
374 root_symbol: Some("AAPL".into()),
375 updated_since: Some("2026-04-02T19:30:00Z".into()),
376 limit: Some(3),
377 page_token: Some("page-3".into()),
378 }
379 .to_query();
380
381 assert_eq!(
382 query,
383 vec![
384 ("feed".to_string(), "indicative".to_string()),
385 ("type".to_string(), "call".to_string()),
386 ("strike_price_gte".to_string(), "180".to_string()),
387 ("strike_price_lte".to_string(), "200".to_string()),
388 ("expiration_date".to_string(), "2026-04-06".to_string()),
389 ("expiration_date_gte".to_string(), "2026-04-06".to_string()),
390 ("expiration_date_lte".to_string(), "2026-04-13".to_string()),
391 ("root_symbol".to_string(), "AAPL".to_string()),
392 (
393 "updated_since".to_string(),
394 "2026-04-02T19:30:00Z".to_string()
395 ),
396 ("limit".to_string(), "3".to_string()),
397 ("page_token".to_string(), "page-3".to_string()),
398 ]
399 );
400 }
401
402 #[test]
403 fn condition_codes_request_uses_official_ticktype_word() {
404 let trade = ConditionCodesRequest {
405 ticktype: TickType::Trade,
406 };
407 assert_eq!(trade.ticktype(), "trade");
408
409 let quote = ConditionCodesRequest {
410 ticktype: TickType::Quote,
411 };
412 assert_eq!(quote.ticktype(), "quote");
413 }
414
415 #[test]
416 fn requests_reject_empty_or_oversized_symbol_lists() {
417 let empty_errors = [
418 BarsRequest::default()
419 .validate()
420 .expect_err("bars symbols must be required"),
421 TradesRequest::default()
422 .validate()
423 .expect_err("trades symbols must be required"),
424 LatestQuotesRequest::default()
425 .validate()
426 .expect_err("latest quotes symbols must be required"),
427 LatestTradesRequest::default()
428 .validate()
429 .expect_err("latest trades symbols must be required"),
430 SnapshotsRequest::default()
431 .validate()
432 .expect_err("snapshots symbols must be required"),
433 ];
434
435 for error in empty_errors {
436 assert!(matches!(
437 error,
438 Error::InvalidRequest(message)
439 if message.contains("symbols") && message.contains("empty")
440 ));
441 }
442
443 let symbols = (0..101)
444 .map(|index| format!("AAPL260406C{:08}", index))
445 .collect::<Vec<_>>();
446
447 let oversized_errors = [
448 BarsRequest {
449 symbols: symbols.clone(),
450 ..BarsRequest::default()
451 }
452 .validate()
453 .expect_err("bars symbols over one hundred must fail"),
454 LatestQuotesRequest {
455 symbols: symbols.clone(),
456 ..LatestQuotesRequest::default()
457 }
458 .validate()
459 .expect_err("latest quotes symbols over one hundred must fail"),
460 SnapshotsRequest {
461 symbols,
462 ..SnapshotsRequest::default()
463 }
464 .validate()
465 .expect_err("snapshots symbols over one hundred must fail"),
466 ];
467
468 for error in oversized_errors {
469 assert!(matches!(
470 error,
471 Error::InvalidRequest(message)
472 if message.contains("symbols") && message.contains("100")
473 ));
474 }
475 }
476
477 #[test]
478 fn oversized_symbol_lists_still_win_before_blank_entry_errors() {
479 let mut symbols = (0..101)
480 .map(|index| format!("AAPL260406C{:08}", index))
481 .collect::<Vec<_>>();
482 symbols[100] = " ".into();
483
484 let error = LatestQuotesRequest {
485 symbols,
486 ..LatestQuotesRequest::default()
487 }
488 .validate()
489 .expect_err("mixed invalid symbol lists should still report the options cap first");
490
491 assert!(matches!(
492 error,
493 Error::InvalidRequest(message)
494 if message.contains("symbols") && message.contains("100")
495 ));
496 }
497
498 #[test]
499 fn chain_request_rejects_blank_underlying_symbols() {
500 let errors = [
501 ChainRequest::default()
502 .validate()
503 .expect_err("chain underlying symbol must be required"),
504 ChainRequest {
505 underlying_symbol: " ".into(),
506 ..ChainRequest::default()
507 }
508 .validate()
509 .expect_err("chain underlying symbol must reject whitespace-only input"),
510 ];
511
512 for error in errors {
513 assert!(matches!(
514 error,
515 Error::InvalidRequest(message)
516 if message.contains("underlying_symbol") && message.contains("invalid")
517 ));
518 }
519 }
520
521 #[test]
522 fn requests_reject_limits_outside_documented_ranges() {
523 let errors = [
524 BarsRequest {
525 symbols: vec!["AAPL260406C00180000".into()],
526 limit: Some(0),
527 ..BarsRequest::default()
528 }
529 .validate()
530 .expect_err("bars limit below one must fail"),
531 TradesRequest {
532 symbols: vec!["AAPL260406C00180000".into()],
533 limit: Some(10_001),
534 ..TradesRequest::default()
535 }
536 .validate()
537 .expect_err("trades limit above ten thousand must fail"),
538 SnapshotsRequest {
539 symbols: vec!["AAPL260406C00180000".into()],
540 limit: Some(0),
541 ..SnapshotsRequest::default()
542 }
543 .validate()
544 .expect_err("snapshots limit below one must fail"),
545 ChainRequest {
546 underlying_symbol: "AAPL".into(),
547 limit: Some(1_001),
548 ..ChainRequest::default()
549 }
550 .validate()
551 .expect_err("chain limit above one thousand must fail"),
552 ];
553
554 let expected_maxima = ["10000", "10000", "1000", "1000"];
555 for (error, expected_max) in errors.into_iter().zip(expected_maxima) {
556 assert!(matches!(
557 error,
558 Error::InvalidRequest(message)
559 if message.contains("limit") && message.contains(expected_max)
560 ));
561 }
562 }
563}