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