Skip to main content

rhood_core/endpoints/
orders.rs

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
15/// Validates a stock order request for logical consistency.
16///
17/// Returns `Ok(())` if valid, or `Err(RhoodError::InvalidOrder)` with a
18/// descriptive message if the request contains contradictory parameters.
19pub 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    /// Fetches all stock orders, including completed, cancelled, and pending.
61    ///
62    /// # Errors
63    ///
64    /// Returns an error if the HTTP request fails or the response cannot be
65    /// deserialized.
66    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), &params)
72            .await
73    }
74
75    /// Fetches only open (cancellable) stock orders.
76    ///
77    /// Filters the full order list to those with a non-null cancel URL.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if the underlying orders request fails.
82    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    /// Cancels a pending stock order by its order ID.
91    ///
92    /// Requires writable mode (`read_only = false`).
93    ///
94    /// # Errors
95    ///
96    /// Returns [`RhoodError::ReadOnlyMode`] if the client is in read-only
97    /// mode. Also returns an error on HTTP failures.
98    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    /// Places a stock order (buy or sell) based on the given request parameters.
105    ///
106    /// Resolves the symbol to its instrument URL and the authenticated
107    /// account URL before submitting. Requires writable mode
108    /// (`read_only = false`).
109    ///
110    /// # Errors
111    ///
112    /// Returns [`RhoodError::ReadOnlyMode`] if the client is in read-only
113    /// mode. Returns [`RhoodError::InvalidSymbol`] if the symbol cannot be
114    /// resolved. Also returns an error on HTTP or deserialization failures.
115    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    /// Fetches all option orders, including completed, cancelled, and pending.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if the HTTP request fails or the response cannot be
163    /// deserialized.
164    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), &params)
170            .await
171    }
172
173    /// Fetches only open (cancellable) option orders.
174    ///
175    /// Filters the full order list to those with a non-null cancel URL.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if the underlying orders request fails.
180    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    /// Cancels a pending option order by its order ID.
189    ///
190    /// Requires writable mode (`read_only = false`).
191    ///
192    /// # Errors
193    ///
194    /// Returns [`RhoodError::ReadOnlyMode`] if the client is in read-only
195    /// mode. Also returns an error on HTTP failures.
196    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    /// Places an option order based on the given request parameters.
203    ///
204    /// Resolves the symbol, expiration date, strike price, and option type
205    /// to a specific option contract before submitting. Requires writable
206    /// mode (`read_only = false`).
207    ///
208    /// # Errors
209    ///
210    /// Returns [`RhoodError::ReadOnlyMode`] if the client is in read-only
211    /// mode. Returns [`RhoodError::InvalidSymbol`] if the option contract
212    /// cannot be found. Also returns an error on HTTP or deserialization
213    /// failures.
214    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    /// Cancels all open (cancellable) stock orders.
263    ///
264    /// Fetches open orders, then attempts to cancel each one. Requires writable
265    /// mode. Returns the IDs cancelled successfully and every per-order failure.
266    /// An open order without an ID is returned as a failure because it cannot be
267    /// cancelled by ID.
268    ///
269    /// # Errors
270    ///
271    /// Returns [`RhoodError::ReadOnlyMode`] if the client is in read-only mode.
272    /// Returns an error if fetching the open orders fails.
273    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    /// Cancels all open (cancellable) option orders.
297    ///
298    /// Fetches open orders, then attempts to cancel each one. Requires writable
299    /// mode. Returns the IDs cancelled successfully and every per-order failure.
300    /// An open order without an ID is returned as a failure because it cannot be
301    /// cancelled by ID.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`RhoodError::ReadOnlyMode`] if the client is in read-only mode.
306    /// Returns an error if fetching the open orders fails.
307    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    /// Fills in missing `symbol` fields on stock orders by resolving their
331    /// `instrument` URLs. Caches instrument lookups so each unique URL is
332    /// fetched at most once.
333    ///
334    /// Orders that already have a symbol or lack an instrument URL are skipped.
335    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}