1use std::collections::HashMap;
2
3use crate::api::paths;
4use crate::client::RobinhoodClient;
5use crate::models::account::AccountProfile;
6use crate::models::order::{
7 CancelAllFailure, CancelAllOutcome, DollarBasedAmount, MarketHours, OptionLeg, OptionOrder,
8 OptionOrderPayload, OptionOrderRequest, OrderAmount, OrderType, Side, StockOrder,
9 StockOrderPayload, StockOrderRequest, Trigger,
10};
11use crate::models::stock::Instrument;
12use crate::pagination::ResultsResponse;
13use crate::{Result, RhoodError};
14
15pub fn validate_stock_order(req: &StockOrderRequest) -> Result<()> {
20 if req.trigger == Trigger::Stop && req.stop_price.is_none() {
21 return Err(RhoodError::InvalidOrder(
22 "stop_price is required when trigger is Stop".into(),
23 ));
24 }
25 if req.trigger == Trigger::Immediate && req.stop_price.is_some() {
26 return Err(RhoodError::InvalidOrder(
27 "stop_price must not be set when trigger is Immediate".into(),
28 ));
29 }
30 if let OrderAmount::DollarAmount(_) = req.amount {
31 if req.side != Side::Buy {
32 return Err(RhoodError::InvalidOrder(
33 "Dollar-based orders are only valid for buy orders".into(),
34 ));
35 }
36 if req.order_type != OrderType::Market {
37 return Err(RhoodError::InvalidOrder(
38 "Dollar-based orders require market order type".into(),
39 ));
40 }
41 if req.trigger != Trigger::Immediate {
42 return Err(RhoodError::InvalidOrder(
43 "Dollar-based orders require immediate trigger".into(),
44 ));
45 }
46 }
47 if matches!(
48 req.market_hours,
49 MarketHours::ExtendedHours | MarketHours::AllDayHours
50 ) && req.order_type != OrderType::Limit
51 {
52 return Err(RhoodError::InvalidOrder(
53 "Extended and all-day hours require limit orders".into(),
54 ));
55 }
56 Ok(())
57}
58
59impl RobinhoodClient {
60 pub async fn get_all_stock_orders(&self, since: Option<&str>) -> Result<Vec<StockOrder>> {
67 let mut params: Vec<(&str, &str)> = Vec::new();
68 if let Some(date) = since {
69 params.push(("updated_at[gte]", date));
70 }
71 self.get_paginated(&self.api_url(paths::STOCK_ORDERS), ¶ms)
72 .await
73 }
74
75 pub async fn get_open_stock_orders(&self) -> Result<Vec<StockOrder>> {
83 let orders = self.get_all_stock_orders(None).await?;
84 Ok(orders
85 .into_iter()
86 .filter(|order| order.cancel.is_some())
87 .collect())
88 }
89
90 pub async fn cancel_stock_order(&self, order_id: &str) -> Result<()> {
99 self.require_writable()?;
100 let url = format!("{}{order_id}/cancel/", self.api_url(paths::STOCK_ORDERS));
101 self.post_empty(&url).await
102 }
103
104 pub async fn place_stock_order(&self, req: &StockOrderRequest) -> Result<StockOrder> {
116 self.require_writable()?;
117 validate_stock_order(req)?;
118 let instrument = self
119 .cached_instrument(&req.symbol)
120 .await?
121 .ok_or_else(|| RhoodError::InvalidSymbol(req.symbol.clone()))?;
122 let instrument_url = instrument
123 .url
124 .clone()
125 .ok_or_else(|| RhoodError::InvalidSymbol(req.symbol.clone()))?;
126 let account_url = self.get_account_url().await?;
127
128 let (quantity, dollar_based_amount) = match req.amount {
129 OrderAmount::Quantity(q) => (format!("{q}"), None),
130 OrderAmount::DollarAmount(d) => (
131 "0".to_string(),
132 Some(DollarBasedAmount {
133 amount: format!("{d:.2}"),
134 currency_code: "USD".to_string(),
135 }),
136 ),
137 };
138
139 let payload = StockOrderPayload {
140 account: account_url,
141 instrument: instrument_url,
142 symbol: req.symbol.to_uppercase(),
143 quantity,
144 side: req.side,
145 order_type: req.order_type,
146 time_in_force: req.time_in_force,
147 trigger: req.trigger,
148 market_hours: req.market_hours,
149 price: req.limit_price.map(|price| format!("{price:.2}")),
150 stop_price: req.stop_price.map(|price| format!("{price:.2}")),
151 dollar_based_amount,
152 };
153
154 self.post_form(&self.api_url(paths::STOCK_ORDERS), &payload)
155 .await
156 }
157
158 pub async fn get_all_option_orders(&self, since: Option<&str>) -> Result<Vec<OptionOrder>> {
165 let mut params: Vec<(&str, &str)> = Vec::new();
166 if let Some(date) = since {
167 params.push(("updated_at[gte]", date));
168 }
169 self.get_paginated(&self.api_url(paths::OPTION_ORDERS), ¶ms)
170 .await
171 }
172
173 pub async fn get_open_option_orders(&self) -> Result<Vec<OptionOrder>> {
181 let orders = self.get_all_option_orders(None).await?;
182 Ok(orders
183 .into_iter()
184 .filter(|order| order.cancel_url.is_some())
185 .collect())
186 }
187
188 pub async fn cancel_option_order(&self, order_id: &str) -> Result<()> {
197 self.require_writable()?;
198 let url = format!("{}{order_id}/cancel/", self.api_url(paths::OPTION_ORDERS));
199 self.post_empty(&url).await
200 }
201
202 pub async fn place_option_order(&self, req: &OptionOrderRequest) -> Result<OptionOrder> {
215 self.require_writable()?;
216 let options = self
217 .find_options(
218 &req.symbol,
219 &req.expiration_date,
220 &req.option_type,
221 Some(&format!("{:.4}", req.strike_price)),
222 )
223 .await?;
224 let option = options.first().ok_or_else(|| {
225 RhoodError::InvalidSymbol(format!(
226 "{} {} {} {}",
227 req.symbol, req.expiration_date, req.strike_price, req.option_type
228 ))
229 })?;
230 let option_url = option
231 .url
232 .as_deref()
233 .ok_or_else(|| RhoodError::InvalidSymbol(req.symbol.clone()))?;
234 let account_url = self.get_account_url().await?;
235
236 let payload = OptionOrderPayload {
237 account: account_url,
238 direction: match req.side {
239 Side::Buy => "debit".to_string(),
240 Side::Sell => "credit".to_string(),
241 },
242 time_in_force: req.time_in_force,
243 legs: vec![OptionLeg {
244 position_effect: req.position_effect,
245 side: req.side,
246 ratio_quantity: 1,
247 option: option_url.to_string(),
248 }],
249 order_type: "limit",
250 trigger: "immediate",
251 price: format!("{:.2}", req.limit_price),
252 quantity: format!("{}", req.quantity),
253 override_day_trade_checks: false,
254 override_dtbp_checks: false,
255 ref_id: uuid::Uuid::new_v4().to_string(),
256 };
257
258 self.post_json(&self.api_url(paths::OPTION_ORDERS), &payload)
259 .await
260 }
261
262 pub async fn cancel_all_stock_orders(&self) -> Result<CancelAllOutcome> {
274 self.require_writable()?;
275 let open = self.get_open_stock_orders().await?;
276 let mut outcome = CancelAllOutcome::default();
277 for order in open {
278 let Some(id) = order.id else {
279 outcome.failed.push(CancelAllFailure {
280 order_id: None,
281 error: "open order is missing an ID".to_string(),
282 });
283 continue;
284 };
285 match self.cancel_stock_order(&id).await {
286 Ok(()) => outcome.cancelled.push(id),
287 Err(error) => outcome.failed.push(CancelAllFailure {
288 order_id: Some(id),
289 error: error.to_string(),
290 }),
291 }
292 }
293 Ok(outcome)
294 }
295
296 pub async fn cancel_all_option_orders(&self) -> Result<CancelAllOutcome> {
308 self.require_writable()?;
309 let open = self.get_open_option_orders().await?;
310 let mut outcome = CancelAllOutcome::default();
311 for order in open {
312 let Some(id) = order.id else {
313 outcome.failed.push(CancelAllFailure {
314 order_id: None,
315 error: "open order is missing an ID".to_string(),
316 });
317 continue;
318 };
319 match self.cancel_option_order(&id).await {
320 Ok(()) => outcome.cancelled.push(id),
321 Err(error) => outcome.failed.push(CancelAllFailure {
322 order_id: Some(id),
323 error: error.to_string(),
324 }),
325 }
326 }
327 Ok(outcome)
328 }
329
330 pub async fn enrich_order_symbols(&self, orders: &mut [StockOrder]) -> Result<()> {
336 let mut cache: HashMap<String, Option<String>> = HashMap::new();
337 for order in orders.iter_mut() {
338 if order.symbol.is_some() {
339 continue;
340 }
341 let Some(instrument_url) = &order.instrument else {
342 continue;
343 };
344 let symbol = if let Some(cached) = cache.get(instrument_url) {
345 cached.clone()
346 } else {
347 let result: std::result::Result<Instrument, _> = self.get(instrument_url).await;
348 let resolved = result.ok().and_then(|inst| inst.symbol);
349 cache.insert(instrument_url.clone(), resolved.clone());
350 resolved
351 };
352 order.symbol = symbol;
353 }
354 Ok(())
355 }
356
357 async fn get_account_url(&self) -> Result<String> {
358 let resp: ResultsResponse<AccountProfile> = self
359 .get_with_params(
360 &self.api_url(paths::ACCOUNTS),
361 &[("default_to_all_accounts", "true")],
362 )
363 .await?;
364 resp.results
365 .first()
366 .and_then(|account| account.url.clone())
367 .ok_or(RhoodError::NotAuthenticated)
368 }
369}
370
371#[cfg(test)]
372#[expect(
373 clippy::assertions_on_result_states,
374 reason = "these validation tests intentionally assert Result state without unwrapping"
375)]
376mod tests {
377 use super::*;
378 use crate::RhoodError;
379 use crate::models::order::{
380 MarketHours as OrderMarketHours, OrderAmount, OrderType, Side, StockOrder,
381 StockOrderRequest, TimeInForce, Trigger,
382 };
383
384 #[test]
385 fn stock_order_request_market_no_limit_price() {
386 let req = StockOrderRequest {
387 symbol: "AAPL".to_string(),
388 amount: OrderAmount::Quantity(1.0),
389 side: Side::Buy,
390 order_type: OrderType::Market,
391 limit_price: None,
392 trigger: Trigger::Immediate,
393 stop_price: None,
394 time_in_force: TimeInForce::Gtc,
395 market_hours: OrderMarketHours::RegularHours,
396 };
397 assert_eq!(req.order_type, OrderType::Market);
398 assert!(req.limit_price.is_none());
399 }
400
401 #[test]
402 fn stock_order_request_limit_requires_price() {
403 let req = StockOrderRequest {
404 symbol: "AAPL".to_string(),
405 amount: OrderAmount::Quantity(1.0),
406 side: Side::Sell,
407 order_type: OrderType::Limit,
408 limit_price: Some(150.00),
409 trigger: Trigger::Immediate,
410 stop_price: None,
411 time_in_force: TimeInForce::Gtc,
412 market_hours: OrderMarketHours::RegularHours,
413 };
414 assert_eq!(req.order_type, OrderType::Limit);
415 assert!(req.limit_price.is_some());
416 }
417
418 #[test]
419 fn invalid_order_error_display() {
420 let err = RhoodError::InvalidOrder("stop_price required for stop orders".into());
421 let msg = err.to_string();
422 assert!(msg.contains("Invalid order"));
423 assert!(msg.contains("stop_price required"));
424 }
425
426 #[test]
427 fn trigger_serializes_lowercase() {
428 assert_eq!(
429 serde_json::to_string(&Trigger::Immediate).unwrap(),
430 r#""immediate""#
431 );
432 assert_eq!(serde_json::to_string(&Trigger::Stop).unwrap(), r#""stop""#);
433 }
434
435 #[test]
436 fn market_hours_serializes_snake_case() {
437 assert_eq!(
438 serde_json::to_string(&OrderMarketHours::RegularHours).unwrap(),
439 r#""regular_hours""#
440 );
441 assert_eq!(
442 serde_json::to_string(&OrderMarketHours::ExtendedHours).unwrap(),
443 r#""extended_hours""#
444 );
445 assert_eq!(
446 serde_json::to_string(&OrderMarketHours::AllDayHours).unwrap(),
447 r#""all_day_hours""#
448 );
449 }
450
451 #[test]
452 fn order_amount_quantity_variant() {
453 let amount = OrderAmount::Quantity(10.5);
454 match amount {
455 OrderAmount::Quantity(q) => assert!((q - 10.5).abs() < f64::EPSILON),
456 _ => panic!("Expected Quantity variant"),
457 }
458 }
459
460 #[test]
461 fn order_amount_dollar_variant() {
462 let amount = OrderAmount::DollarAmount(50.0);
463 match amount {
464 OrderAmount::DollarAmount(d) => assert!((d - 50.0).abs() < f64::EPSILON),
465 _ => panic!("Expected DollarAmount variant"),
466 }
467 }
468
469 #[test]
470 fn validate_stop_requires_stop_price() {
471 let req = StockOrderRequest {
472 symbol: "AAPL".to_string(),
473 amount: OrderAmount::Quantity(10.0),
474 side: Side::Buy,
475 order_type: OrderType::Limit,
476 limit_price: Some(150.0),
477 trigger: Trigger::Stop,
478 stop_price: None,
479 time_in_force: TimeInForce::Gtc,
480 market_hours: OrderMarketHours::RegularHours,
481 };
482 let err = validate_stock_order(&req);
483 assert!(err.is_err());
484 assert!(err.unwrap_err().to_string().contains("stop_price"));
485 }
486
487 #[test]
488 fn validate_immediate_rejects_stop_price() {
489 let req = StockOrderRequest {
490 symbol: "AAPL".to_string(),
491 amount: OrderAmount::Quantity(10.0),
492 side: Side::Buy,
493 order_type: OrderType::Market,
494 limit_price: None,
495 trigger: Trigger::Immediate,
496 stop_price: Some(145.0),
497 time_in_force: TimeInForce::Gtc,
498 market_hours: OrderMarketHours::RegularHours,
499 };
500 let err = validate_stock_order(&req);
501 assert!(err.is_err());
502 }
503
504 #[test]
505 fn validate_dollar_amount_requires_buy() {
506 let req = StockOrderRequest {
507 symbol: "AAPL".to_string(),
508 amount: OrderAmount::DollarAmount(50.0),
509 side: Side::Sell,
510 order_type: OrderType::Market,
511 limit_price: None,
512 trigger: Trigger::Immediate,
513 stop_price: None,
514 time_in_force: TimeInForce::Gtc,
515 market_hours: OrderMarketHours::RegularHours,
516 };
517 let err = validate_stock_order(&req);
518 assert!(err.is_err());
519 assert!(err.unwrap_err().to_string().contains("buy"));
520 }
521
522 #[test]
523 fn validate_dollar_amount_requires_market_immediate() {
524 let req = StockOrderRequest {
525 symbol: "AAPL".to_string(),
526 amount: OrderAmount::DollarAmount(50.0),
527 side: Side::Buy,
528 order_type: OrderType::Limit,
529 limit_price: Some(150.0),
530 trigger: Trigger::Immediate,
531 stop_price: None,
532 time_in_force: TimeInForce::Gtc,
533 market_hours: OrderMarketHours::RegularHours,
534 };
535 let err = validate_stock_order(&req);
536 assert!(err.is_err());
537 }
538
539 #[test]
540 fn validate_extended_hours_requires_limit() {
541 let req = StockOrderRequest {
542 symbol: "AAPL".to_string(),
543 amount: OrderAmount::Quantity(10.0),
544 side: Side::Buy,
545 order_type: OrderType::Market,
546 limit_price: None,
547 trigger: Trigger::Immediate,
548 stop_price: None,
549 time_in_force: TimeInForce::Gtc,
550 market_hours: OrderMarketHours::ExtendedHours,
551 };
552 let err = validate_stock_order(&req);
553 assert!(err.is_err());
554 }
555
556 #[test]
557 fn validate_all_day_hours_requires_limit() {
558 let req = StockOrderRequest {
559 symbol: "AAPL".to_string(),
560 amount: OrderAmount::Quantity(10.0),
561 side: Side::Buy,
562 order_type: OrderType::Market,
563 limit_price: None,
564 trigger: Trigger::Immediate,
565 stop_price: None,
566 time_in_force: TimeInForce::Gtc,
567 market_hours: OrderMarketHours::AllDayHours,
568 };
569 let err = validate_stock_order(&req);
570 assert!(err.is_err());
571 }
572
573 #[test]
574 fn validate_valid_market_order_passes() {
575 let req = StockOrderRequest {
576 symbol: "AAPL".to_string(),
577 amount: OrderAmount::Quantity(10.0),
578 side: Side::Buy,
579 order_type: OrderType::Market,
580 limit_price: None,
581 trigger: Trigger::Immediate,
582 stop_price: None,
583 time_in_force: TimeInForce::Gtc,
584 market_hours: OrderMarketHours::RegularHours,
585 };
586 assert!(validate_stock_order(&req).is_ok());
587 }
588
589 #[test]
590 fn validate_valid_stop_limit_passes() {
591 let req = StockOrderRequest {
592 symbol: "AAPL".to_string(),
593 amount: OrderAmount::Quantity(10.0),
594 side: Side::Buy,
595 order_type: OrderType::Limit,
596 limit_price: Some(150.0),
597 trigger: Trigger::Stop,
598 stop_price: Some(145.0),
599 time_in_force: TimeInForce::Gtc,
600 market_hours: OrderMarketHours::RegularHours,
601 };
602 assert!(validate_stock_order(&req).is_ok());
603 }
604
605 #[test]
606 fn stock_order_request_stop_limit() {
607 let req = StockOrderRequest {
608 symbol: "AAPL".to_string(),
609 amount: OrderAmount::Quantity(10.0),
610 side: Side::Buy,
611 order_type: OrderType::Limit,
612 limit_price: Some(150.00),
613 trigger: Trigger::Stop,
614 stop_price: Some(145.00),
615 time_in_force: TimeInForce::Gtc,
616 market_hours: OrderMarketHours::RegularHours,
617 };
618 assert_eq!(req.trigger, Trigger::Stop);
619 assert!(req.stop_price.is_some());
620 }
621
622 #[test]
623 fn stock_order_request_dollar_amount() {
624 let req = StockOrderRequest {
625 symbol: "AAPL".to_string(),
626 amount: OrderAmount::DollarAmount(50.0),
627 side: Side::Buy,
628 order_type: OrderType::Market,
629 limit_price: None,
630 trigger: Trigger::Immediate,
631 stop_price: None,
632 time_in_force: TimeInForce::Gtc,
633 market_hours: OrderMarketHours::RegularHours,
634 };
635 assert!(matches!(req.amount, OrderAmount::DollarAmount(_)));
636 }
637
638 #[test]
639 fn stock_order_request_all_day_hours() {
640 let req = StockOrderRequest {
641 symbol: "TSLA".to_string(),
642 amount: OrderAmount::Quantity(5.0),
643 side: Side::Buy,
644 order_type: OrderType::Limit,
645 limit_price: Some(200.0),
646 trigger: Trigger::Immediate,
647 stop_price: None,
648 time_in_force: TimeInForce::Gfd,
649 market_hours: OrderMarketHours::AllDayHours,
650 };
651 assert_eq!(req.market_hours, OrderMarketHours::AllDayHours);
652 }
653
654 #[test]
655 fn stock_order_response_deserializes_with_new_fields() {
656 let json = r#"{
657 "id": "order-stop-001",
658 "symbol": "AAPL",
659 "side": "buy",
660 "quantity": "10",
661 "state": "queued",
662 "type": "limit",
663 "trigger": "stop",
664 "stop_price": "145.00",
665 "time_in_force": "gtc",
666 "created_at": "2025-01-01T00:00:00Z"
667 }"#;
668 let order: StockOrder = serde_json::from_str(json).unwrap();
669 assert_eq!(order.trigger.as_deref(), Some("stop"));
670 assert_eq!(order.stop_price.as_deref(), Some("145.00"));
671 }
672
673 #[test]
674 fn stock_order_deserializes_full_snapshot() {
675 let json = r#"{
676 "id": "order-001",
677 "symbol": "AAPL",
678 "side": "buy",
679 "quantity": "10.0000",
680 "price": "150.00",
681 "average_price": "149.50",
682 "cumulative_quantity": "10.0000",
683 "state": "filled",
684 "type": "limit",
685 "time_in_force": "gtc",
686 "cancel": null,
687 "created_at": "2026-03-31T10:00:00Z",
688 "updated_at": "2026-03-31T10:01:00Z"
689 }"#;
690 let order: StockOrder = serde_json::from_str(json).unwrap();
691 assert_eq!(order.id.as_deref(), Some("order-001"));
692 assert_eq!(order.symbol.as_deref(), Some("AAPL"));
693 assert_eq!(order.side.as_deref(), Some("buy"));
694 assert_eq!(order.state.as_deref(), Some("filled"));
695 assert!(order.cancel.is_none());
696 }
697
698 #[test]
699 fn stock_order_open_has_cancel_url() {
700 let json = r#"{
701 "id": "order-002",
702 "symbol": "TSLA",
703 "side": "sell",
704 "state": "queued",
705 "cancel": "https://api.robinhood.com/orders/order-002/cancel/"
706 }"#;
707 let order: StockOrder = serde_json::from_str(json).unwrap();
708 assert!(order.cancel.is_some());
709 }
710}
711
712#[cfg(test)]
713mod endpoint_tests {
714 use crate::client::RobinhoodClient;
715 use crate::config::RhoodConfig;
716 use crate::models::order::{
717 CancelAllFailure, OptionOrderRequest, OptionPositionEffect, Side, TimeInForce,
718 };
719 use secrecy::SecretString;
720 use wiremock::matchers::{body_partial_json, method, path, query_param};
721 use wiremock::{Mock, MockServer, ResponseTemplate};
722
723 async fn client_for_server(base_url: &str) -> (tempfile::TempDir, RobinhoodClient) {
724 let dir = tempfile::tempdir().unwrap();
725 let mut config = RhoodConfig {
726 read_only: false,
727 ..RhoodConfig::default()
728 };
729 config.auth.token_cache_path = dir
730 .path()
731 .join("nonexistent-token.json")
732 .to_str()
733 .unwrap()
734 .to_string();
735 config.api.base_url = base_url.to_string();
736 config.api.phoenix_url = base_url.to_string();
737 config.api.bonfire_url = base_url.to_string();
738 let client = RobinhoodClient::with_config(config).unwrap();
739 client
740 .inject_test_auth(
741 SecretString::from("access-token"),
742 "Bearer".to_string(),
743 SecretString::from("refresh-token"),
744 )
745 .await;
746 (dir, client)
747 }
748
749 async fn assert_option_order_action(
750 side: Side,
751 position_effect: OptionPositionEffect,
752 expected_direction: &str,
753 ) {
754 let server = MockServer::start().await;
755 let option_url = format!("{}/options/instruments/contract-aapl/", server.uri());
756 let account_url = format!("{}/accounts/account-1/", server.uri());
757
758 Mock::given(method("GET"))
759 .and(path("/instruments/"))
760 .and(query_param("symbol", "AAPL"))
761 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
762 "results": [{"symbol": "AAPL", "tradable_chain_id": "chain-aapl"}]
763 })))
764 .expect(1)
765 .mount(&server)
766 .await;
767 Mock::given(method("GET"))
768 .and(path("/options/instruments/"))
769 .and(query_param("chain_id", "chain-aapl"))
770 .and(query_param("expiration_dates", "2026-09-18"))
771 .and(query_param("type", "call"))
772 .and(query_param("state", "active"))
773 .and(query_param("strike_price", "200.0000"))
774 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
775 "results": [{
776 "chain_id": "chain-aapl",
777 "chain_symbol": "AAPL",
778 "expiration_date": "2026-09-18",
779 "id": "contract-aapl",
780 "state": "active",
781 "strike_price": "200.0000",
782 "type": "call",
783 "url": option_url
784 }],
785 "next": null,
786 "previous": null
787 })))
788 .expect(1)
789 .mount(&server)
790 .await;
791 Mock::given(method("GET"))
792 .and(path("/accounts/"))
793 .and(query_param("default_to_all_accounts", "true"))
794 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
795 "results": [{"url": account_url}]
796 })))
797 .expect(1)
798 .mount(&server)
799 .await;
800 Mock::given(method("POST"))
801 .and(path("/options/orders/"))
802 .and(body_partial_json(serde_json::json!({
803 "direction": expected_direction,
804 "time_in_force": "gtc",
805 "legs": [{
806 "position_effect": position_effect,
807 "side": side,
808 "ratio_quantity": 1,
809 "option": option_url
810 }],
811 "type": "limit",
812 "trigger": "immediate",
813 "price": "2.50",
814 "quantity": "1"
815 })))
816 .respond_with(
817 ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "placed-order"})),
818 )
819 .expect(1)
820 .mount(&server)
821 .await;
822 let (_dir, client) = client_for_server(&server.uri()).await;
823 let request = OptionOrderRequest {
824 symbol: "AAPL".to_string(),
825 expiration_date: "2026-09-18".to_string(),
826 strike_price: 200.0,
827 option_type: "call".to_string(),
828 side,
829 quantity: 1.0,
830 limit_price: 2.5,
831 position_effect,
832 time_in_force: TimeInForce::Gtc,
833 };
834
835 let order = client.place_option_order(&request).await.unwrap();
836
837 assert_eq!(order.id.as_deref(), Some("placed-order"));
838 server.verify().await;
839 }
840
841 #[tokio::test]
842 async fn place_option_order_supports_all_four_position_actions() {
843 for (side, position_effect, expected_direction) in [
844 (Side::Buy, OptionPositionEffect::Open, "debit"),
845 (Side::Sell, OptionPositionEffect::Close, "credit"),
846 (Side::Sell, OptionPositionEffect::Open, "credit"),
847 (Side::Buy, OptionPositionEffect::Close, "debit"),
848 ] {
849 assert_option_order_action(side, position_effect, expected_direction).await;
850 }
851 }
852
853 #[tokio::test]
854 async fn cancel_all_stock_orders_reports_failures_and_attempts_every_identified_order() {
855 let server = MockServer::start().await;
856 Mock::given(method("GET"))
857 .and(path("/orders/"))
858 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
859 "results": [
860 {"id": "stock-success", "cancel": "https://example.test/stock-success/cancel/"},
861 {"id": "stock-failure", "cancel": "https://example.test/stock-failure/cancel/"},
862 {"id": "stock-later", "cancel": "https://example.test/stock-later/cancel/"},
863 {"id": null, "cancel": "https://example.test/missing-id/cancel/"}
864 ],
865 "next": null,
866 "previous": null
867 })))
868 .expect(1)
869 .mount(&server)
870 .await;
871 Mock::given(method("POST"))
872 .and(path("/orders/stock-success/cancel/"))
873 .respond_with(ResponseTemplate::new(200))
874 .expect(1)
875 .mount(&server)
876 .await;
877 Mock::given(method("POST"))
878 .and(path("/orders/stock-failure/cancel/"))
879 .respond_with(
880 ResponseTemplate::new(500)
881 .set_body_string(r#"{"detail":"stock cancellation failed"}"#),
882 )
883 .expect(1)
884 .mount(&server)
885 .await;
886 Mock::given(method("POST"))
887 .and(path("/orders/stock-later/cancel/"))
888 .respond_with(ResponseTemplate::new(200))
889 .expect(1)
890 .mount(&server)
891 .await;
892 let (_dir, client) = client_for_server(&server.uri()).await;
893
894 let outcome = client.cancel_all_stock_orders().await.unwrap();
895
896 assert_eq!(outcome.cancelled, ["stock-success", "stock-later"]);
897 assert_eq!(
898 outcome.failed,
899 [
900 CancelAllFailure {
901 order_id: Some("stock-failure".to_string()),
902 error: "API error (500): stock cancellation failed".to_string(),
903 },
904 CancelAllFailure {
905 order_id: None,
906 error: "open order is missing an ID".to_string(),
907 },
908 ]
909 );
910 server.verify().await;
911 }
912
913 #[tokio::test]
914 async fn cancel_all_option_orders_reports_failures_and_attempts_every_identified_order() {
915 let server = MockServer::start().await;
916 Mock::given(method("GET"))
917 .and(path("/options/orders/"))
918 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
919 "results": [
920 {"id": "option-success", "cancel_url": "https://example.test/option-success/cancel/"},
921 {"id": "option-failure", "cancel_url": "https://example.test/option-failure/cancel/"},
922 {"id": "option-later", "cancel_url": "https://example.test/option-later/cancel/"},
923 {"id": null, "cancel_url": "https://example.test/missing-id/cancel/"}
924 ],
925 "next": null,
926 "previous": null
927 })))
928 .expect(1)
929 .mount(&server)
930 .await;
931 Mock::given(method("POST"))
932 .and(path("/options/orders/option-success/cancel/"))
933 .respond_with(ResponseTemplate::new(200))
934 .expect(1)
935 .mount(&server)
936 .await;
937 Mock::given(method("POST"))
938 .and(path("/options/orders/option-failure/cancel/"))
939 .respond_with(
940 ResponseTemplate::new(500)
941 .set_body_string(r#"{"detail":"option cancellation failed"}"#),
942 )
943 .expect(1)
944 .mount(&server)
945 .await;
946 Mock::given(method("POST"))
947 .and(path("/options/orders/option-later/cancel/"))
948 .respond_with(ResponseTemplate::new(200))
949 .expect(1)
950 .mount(&server)
951 .await;
952 let (_dir, client) = client_for_server(&server.uri()).await;
953
954 let outcome = client.cancel_all_option_orders().await.unwrap();
955
956 assert_eq!(outcome.cancelled, ["option-success", "option-later"]);
957 assert_eq!(
958 outcome.failed,
959 [
960 CancelAllFailure {
961 order_id: Some("option-failure".to_string()),
962 error: "API error (500): option cancellation failed".to_string(),
963 },
964 CancelAllFailure {
965 order_id: None,
966 error: "open order is missing an ID".to_string(),
967 },
968 ]
969 );
970 server.verify().await;
971 }
972}