Skip to main content

tradestation_api/
market_data.rs

1//! Market data endpoints: historical bars, quotes, symbols, crypto, and options.
2//!
3//! Covers TradeStation v3 endpoints:
4//! - `GET /v3/marketdata/barcharts/{symbol}`
5//! - `GET /v3/marketdata/quotes/{symbols}`
6//! - `GET /v3/marketdata/symbols/{symbols}`
7//! - `GET /v3/marketdata/symbollists/cryptopairs/symbolnames`
8//! - `GET /v3/marketdata/options/expirations/{underlying}`
9//! - `GET /v3/marketdata/options/strikes/{underlying}`
10//! - `GET /v3/marketdata/options/spreadtypes`
11//! - `POST /v3/marketdata/options/riskreward`
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16use crate::Client;
17use crate::Error;
18
19/// OHLCV bar from the barcharts endpoint.
20///
21/// All numeric fields are returned as strings by the API. Use [`Bar::ohlcv`]
22/// to parse them into native types.
23///
24/// # Example
25///
26/// ```
27/// # use tradestation_api::Bar;
28/// # let bar = Bar {
29/// #     high: "150.00".into(), low: "148.00".into(),
30/// #     open: "149.00".into(), close: "149.50".into(),
31/// #     time_stamp: "2024-01-15T10:00:00Z".into(), total_volume: "1000".into(),
32/// #     down_ticks: None, down_volume: None, open_interest: None,
33/// #     total_ticks: None, unchanged_ticks: None, unchanged_volume: None,
34/// #     up_ticks: None, up_volume: None,
35/// #     is_realtime: None, is_end_of_history: None, epoch: None, bar_status: None,
36/// # };
37/// if let Some((o, h, l, c, v)) = bar.ohlcv() {
38///     println!("close={c}, volume={v}");
39/// }
40/// ```
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(rename_all = "PascalCase")]
43pub struct Bar {
44    /// Highest price during the bar period.
45    pub high: String,
46    /// Lowest price during the bar period.
47    pub low: String,
48    /// Opening price.
49    pub open: String,
50    /// Closing price.
51    pub close: String,
52    /// Bar timestamp (RFC 3339).
53    pub time_stamp: String,
54    /// Total volume traded during the bar period.
55    pub total_volume: String,
56    /// Number of down ticks.
57    #[serde(default)]
58    pub down_ticks: Option<serde_json::Value>,
59    /// Volume on down ticks.
60    #[serde(default)]
61    pub down_volume: Option<serde_json::Value>,
62    /// Open interest (futures/options).
63    #[serde(default)]
64    pub open_interest: Option<serde_json::Value>,
65    /// Total number of ticks.
66    #[serde(default)]
67    pub total_ticks: Option<serde_json::Value>,
68    /// Number of unchanged ticks.
69    #[serde(default)]
70    pub unchanged_ticks: Option<serde_json::Value>,
71    /// Volume on unchanged ticks.
72    #[serde(default)]
73    pub unchanged_volume: Option<serde_json::Value>,
74    /// Number of up ticks.
75    #[serde(default)]
76    pub up_ticks: Option<serde_json::Value>,
77    /// Volume on up ticks.
78    #[serde(default)]
79    pub up_volume: Option<serde_json::Value>,
80    /// Whether this bar is from real-time data.
81    #[serde(default)]
82    pub is_realtime: Option<bool>,
83    /// Whether this is the last bar in the history.
84    #[serde(default)]
85    pub is_end_of_history: Option<bool>,
86    /// Unix epoch timestamp in milliseconds.
87    #[serde(default)]
88    pub epoch: Option<u64>,
89    /// Bar status (e.g., "Closed", "Open").
90    #[serde(default)]
91    pub bar_status: Option<String>,
92}
93
94impl Bar {
95    /// Parse OHLCV string fields into `(open, high, low, close, volume)`.
96    ///
97    /// Returns `None` if any field fails to parse.
98    pub fn ohlcv(&self) -> Option<(f64, f64, f64, f64, u64)> {
99        Some((
100            self.open.parse().ok()?,
101            self.high.parse().ok()?,
102            self.low.parse().ok()?,
103            self.close.parse().ok()?,
104            self.total_volume.parse().ok()?,
105        ))
106    }
107
108    /// Parse the bar timestamp into a `DateTime<Utc>`.
109    ///
110    /// Returns `None` if the timestamp is not valid RFC 3339.
111    pub fn timestamp(&self) -> Option<DateTime<Utc>> {
112        DateTime::parse_from_rfc3339(&self.time_stamp)
113            .ok()
114            .map(|dt| dt.with_timezone(&Utc))
115    }
116}
117
118/// Response from barcharts endpoint.
119#[derive(Debug, Deserialize)]
120#[serde(rename_all = "PascalCase")]
121pub struct BarChartResponse {
122    pub bars: Vec<Bar>,
123}
124
125/// Quote snapshot for a single symbol.
126///
127/// Returned by [`Client::get_quotes`].
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(rename_all = "PascalCase")]
130pub struct Quote {
131    /// Ticker symbol.
132    pub symbol: String,
133    /// Last traded price.
134    pub last: String,
135    /// Best ask price.
136    pub ask: String,
137    /// Best bid price.
138    pub bid: String,
139    /// Cumulative volume.
140    pub volume: String,
141    /// Previous session close.
142    #[serde(default)]
143    pub close: Option<String>,
144    /// Session high.
145    #[serde(default)]
146    pub high: Option<String>,
147    /// Session low.
148    #[serde(default)]
149    pub low: Option<String>,
150    /// Session open.
151    #[serde(default)]
152    pub open: Option<String>,
153    /// Net change from previous close.
154    #[serde(default)]
155    pub net_change: Option<String>,
156    /// Net change as a percentage.
157    #[serde(default)]
158    pub net_change_pct: Option<String>,
159    /// Time of the last trade.
160    #[serde(rename = "TradeTime", default)]
161    pub trade_time: Option<String>,
162}
163
164/// Response from quotes endpoint.
165#[derive(Debug, Deserialize)]
166#[serde(rename_all = "PascalCase")]
167pub struct QuoteResponse {
168    pub quotes: Vec<Quote>,
169}
170
171/// Symbol definition metadata.
172///
173/// Returned by [`Client::get_symbol_info`].
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(rename_all = "PascalCase")]
176pub struct SymbolInfo {
177    /// Ticker symbol.
178    pub symbol: String,
179    /// Human-readable description.
180    pub description: Option<String>,
181    /// Exchange where the symbol is listed.
182    pub exchange: Option<String>,
183    /// Asset category (e.g., "Stock", "Future").
184    pub category: Option<String>,
185    /// Trading currency.
186    #[serde(default)]
187    pub currency: Option<String>,
188    /// Contract point value (futures/options).
189    #[serde(default)]
190    pub point_value: Option<String>,
191    /// Minimum price movement.
192    #[serde(default)]
193    pub min_move: Option<String>,
194}
195
196/// Response from symbols endpoint.
197#[derive(Debug, Deserialize)]
198#[serde(rename_all = "PascalCase")]
199struct SymbolResponse {
200    definitions: Vec<SymbolInfo>,
201}
202
203/// Query parameters for the barcharts endpoint.
204///
205/// Use the builder constructors [`BarChartQuery::minute_bars`] or
206/// [`BarChartQuery::daily_bars`] for common cases.
207///
208/// # Example
209///
210/// ```
211/// use tradestation_api::BarChartQuery;
212///
213/// let query = BarChartQuery::minute_bars("AAPL", 100);
214/// let daily = BarChartQuery::daily_bars("MSFT", 30);
215/// let range = BarChartQuery::daily_bars("GOOG", 0)
216///     .with_dates("2024-01-01", "2024-03-01");
217/// ```
218pub struct BarChartQuery {
219    /// Target symbol.
220    pub symbol: String,
221    /// Bar interval (e.g., "1", "5", "15").
222    pub interval: String,
223    /// Bar unit (e.g., "Minute", "Daily", "Weekly").
224    pub unit: String,
225    /// Number of bars to fetch (mutually exclusive with date range).
226    pub bars_back: Option<u32>,
227    /// Start date for date-range queries.
228    pub first_date: Option<String>,
229    /// End date for date-range queries.
230    pub last_date: Option<String>,
231    /// Session template (e.g., "Default", "USEQPre").
232    pub session_template: Option<String>,
233}
234
235impl BarChartQuery {
236    /// Create a query for 1-minute bars.
237    pub fn minute_bars(symbol: impl Into<String>, bars_back: u32) -> Self {
238        Self {
239            symbol: symbol.into(),
240            interval: "1".to_string(),
241            unit: "Minute".to_string(),
242            bars_back: Some(bars_back),
243            first_date: None,
244            last_date: None,
245            session_template: None,
246        }
247    }
248
249    /// Create a query for daily bars.
250    pub fn daily_bars(symbol: impl Into<String>, bars_back: u32) -> Self {
251        Self {
252            symbol: symbol.into(),
253            interval: "1".to_string(),
254            unit: "Daily".to_string(),
255            bars_back: Some(bars_back),
256            first_date: None,
257            last_date: None,
258            session_template: None,
259        }
260    }
261
262    /// Set a date range, clearing `bars_back`.
263    pub fn with_dates(mut self, first_date: &str, last_date: &str) -> Self {
264        self.first_date = Some(first_date.to_string());
265        self.last_date = Some(last_date.to_string());
266        self.bars_back = None;
267        self
268    }
269
270    fn to_query_params(&self) -> Vec<(&str, String)> {
271        let mut params = vec![
272            ("interval", self.interval.clone()),
273            ("unit", self.unit.clone()),
274        ];
275        if let Some(bars_back) = self.bars_back {
276            params.push(("barsBack", bars_back.to_string()));
277        }
278        if let Some(first_date) = &self.first_date {
279            params.push(("firstDate", first_date.clone()));
280        }
281        if let Some(last_date) = &self.last_date {
282            params.push(("lastDate", last_date.clone()));
283        }
284        if let Some(session) = &self.session_template {
285            params.push(("sessionTemplate", session.clone()));
286        }
287        params
288    }
289}
290
291/// A cryptocurrency trading pair name from the symbollists endpoint.
292#[derive(Debug, Clone, Serialize, Deserialize)]
293#[serde(rename_all = "PascalCase")]
294pub struct CryptoPair {
295    /// Pair name (e.g., "BTCUSD").
296    pub name: String,
297}
298
299/// Response from crypto pairs endpoint.
300#[derive(Debug, Deserialize)]
301#[serde(rename_all = "PascalCase")]
302struct CryptoPairsResponse {
303    crypto_pairs: Vec<String>,
304}
305
306/// Option expiration date and type.
307///
308/// Returned by [`Client::get_option_expirations`].
309#[derive(Debug, Clone, Serialize, Deserialize)]
310#[serde(rename_all = "PascalCase")]
311pub struct OptionExpiration {
312    /// Expiration date string (e.g., "2024-03-15").
313    pub date: String,
314    /// Expiration type (e.g., "Weekly", "Monthly").
315    #[serde(default, rename = "Type")]
316    pub expiration_type: Option<String>,
317}
318
319/// Response from option expirations endpoint.
320#[derive(Debug, Deserialize)]
321#[serde(rename_all = "PascalCase")]
322struct OptionExpirationsResponse {
323    expirations: Vec<OptionExpiration>,
324}
325
326/// A single option strike price.
327///
328/// Returned by [`Client::get_option_strikes`].
329#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(rename_all = "PascalCase")]
331pub struct OptionStrike {
332    /// The strike price as a string.
333    pub strike_price: String,
334}
335
336/// Response from option strikes endpoint.
337#[derive(Debug, Deserialize)]
338#[serde(rename_all = "PascalCase")]
339struct OptionStrikesResponse {
340    strikes: Vec<OptionStrike>,
341}
342
343/// Option spread type definition.
344///
345/// Returned by [`Client::get_option_spread_types`].
346#[derive(Debug, Clone, Serialize, Deserialize)]
347#[serde(rename_all = "PascalCase")]
348pub struct SpreadType {
349    /// Spread type name (e.g., "Vertical", "Calendar").
350    pub name: String,
351    /// Human-readable description.
352    #[serde(default)]
353    pub description: Option<String>,
354}
355
356/// Response from spread types endpoint.
357#[derive(Debug, Deserialize)]
358#[serde(rename_all = "PascalCase")]
359struct SpreadTypesResponse {
360    spread_types: Vec<SpreadType>,
361}
362
363/// Request body for option risk/reward analysis.
364///
365/// Used with [`Client::get_option_risk_reward`].
366#[derive(Debug, Serialize)]
367#[serde(rename_all = "PascalCase")]
368pub struct RiskRewardRequest {
369    /// Option symbol.
370    pub symbol: String,
371    /// Trade action (e.g., "BUY", "SELL").
372    pub trade_action: String,
373    /// Number of contracts.
374    pub quantity: String,
375    /// Limit price for the analysis.
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub limit_price: Option<String>,
378    /// Stop price for the analysis.
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub stop_price: Option<String>,
381}
382
383/// Response from option risk/reward analysis.
384#[derive(Debug, Clone, Deserialize)]
385#[serde(rename_all = "PascalCase")]
386pub struct RiskRewardResponse {
387    /// Maximum potential reward.
388    #[serde(default)]
389    pub max_reward: Option<String>,
390    /// Maximum potential risk.
391    #[serde(default)]
392    pub max_risk: Option<String>,
393    /// Break-even price.
394    #[serde(default)]
395    pub break_even: Option<String>,
396}
397
398/// Convenience trait for parsing string numeric fields to native types.
399///
400/// TradeStation returns most numeric values as JSON strings. This trait
401/// provides ergonomic parsing on any type with a string field.
402///
403/// # Example
404///
405/// ```
406/// use tradestation_api::market_data::ParseNumeric;
407///
408/// let value = "123.45";
409/// assert_eq!(value.parse_f64(), Some(123.45));
410/// assert_eq!("1000".parse_u64(), Some(1000));
411/// assert_eq!("bad".parse_f64(), None);
412/// ```
413pub trait ParseNumeric {
414    /// Parse this string value as `f64`, returning `None` on failure.
415    fn parse_f64(&self) -> Option<f64>;
416    /// Parse this string value as `u64`, returning `None` on failure.
417    fn parse_u64(&self) -> Option<u64>;
418}
419
420impl ParseNumeric for str {
421    fn parse_f64(&self) -> Option<f64> {
422        self.parse().ok()
423    }
424
425    fn parse_u64(&self) -> Option<u64> {
426        self.parse().ok()
427    }
428}
429
430impl ParseNumeric for String {
431    fn parse_f64(&self) -> Option<f64> {
432        self.parse().ok()
433    }
434
435    fn parse_u64(&self) -> Option<u64> {
436        self.parse().ok()
437    }
438}
439
440impl ParseNumeric for Option<String> {
441    fn parse_f64(&self) -> Option<f64> {
442        self.as_deref()?.parse().ok()
443    }
444
445    fn parse_u64(&self) -> Option<u64> {
446        self.as_deref()?.parse().ok()
447    }
448}
449
450impl Client {
451    /// Fetch historical bar chart data for a symbol.
452    ///
453    /// # Errors
454    ///
455    /// Returns [`Error::Api`] if the API request fails.
456    pub async fn get_bars(&mut self, query: &BarChartQuery) -> Result<Vec<Bar>, Error> {
457        let path = format!("/v3/marketdata/barcharts/{}", query.symbol);
458        let headers = self.auth_headers().await?;
459        let url = format!("{}{}", self.base_url(), &path);
460
461        let mut req = self.http.get(&url).headers(headers);
462        for (key, value) in query.to_query_params() {
463            req = req.query(&[(key, &value)]);
464        }
465
466        let resp = req.send().await?;
467        if !resp.status().is_success() {
468            let status = resp.status().as_u16();
469            let body = resp.text().await.unwrap_or_default();
470            return Err(Error::Api {
471                status,
472                message: body,
473            });
474        }
475
476        let chart_resp: BarChartResponse = resp.json().await?;
477        Ok(chart_resp.bars)
478    }
479
480    /// Fetch quote snapshots for one or more symbols.
481    ///
482    /// Accepts a slice of ticker symbols (e.g., `&["AAPL", "MSFT"]`).
483    pub async fn get_quotes(&mut self, symbols: &[&str]) -> Result<Vec<Quote>, Error> {
484        let symbols_str = symbols.join(",");
485        let path = format!("/v3/marketdata/quotes/{}", symbols_str);
486        let resp = self.get(&path).await?;
487        let quote_resp: QuoteResponse = resp.json().await?;
488        Ok(quote_resp.quotes)
489    }
490
491    /// Fetch symbol definition metadata.
492    pub async fn get_symbol_info(&mut self, symbols: &[&str]) -> Result<Vec<SymbolInfo>, Error> {
493        let symbols_str = symbols.join(",");
494        let path = format!("/v3/marketdata/symbols/{}", symbols_str);
495        let resp = self.get(&path).await?;
496        let symbol_resp: SymbolResponse = resp.json().await?;
497        Ok(symbol_resp.definitions)
498    }
499
500    /// Fetch available cryptocurrency trading pairs.
501    pub async fn get_crypto_pairs(&mut self) -> Result<Vec<String>, Error> {
502        let resp = self
503            .get("/v3/marketdata/symbollists/cryptopairs/symbolnames")
504            .await?;
505        let data: CryptoPairsResponse = resp.json().await?;
506        Ok(data.crypto_pairs)
507    }
508
509    /// Fetch option expiration dates for an underlying symbol.
510    pub async fn get_option_expirations(
511        &mut self,
512        underlying: &str,
513    ) -> Result<Vec<OptionExpiration>, Error> {
514        let path = format!("/v3/marketdata/options/expirations/{}", underlying);
515        let resp = self.get(&path).await?;
516        let data: OptionExpirationsResponse = resp.json().await?;
517        Ok(data.expirations)
518    }
519
520    /// Fetch available strike prices for an underlying symbol.
521    pub async fn get_option_strikes(
522        &mut self,
523        underlying: &str,
524    ) -> Result<Vec<OptionStrike>, Error> {
525        let path = format!("/v3/marketdata/options/strikes/{}", underlying);
526        let resp = self.get(&path).await?;
527        let data: OptionStrikesResponse = resp.json().await?;
528        Ok(data.strikes)
529    }
530
531    /// Fetch available option spread types (e.g., Vertical, Calendar).
532    pub async fn get_option_spread_types(&mut self) -> Result<Vec<SpreadType>, Error> {
533        let resp = self.get("/v3/marketdata/options/spreadtypes").await?;
534        let data: SpreadTypesResponse = resp.json().await?;
535        Ok(data.spread_types)
536    }
537
538    /// Analyze option risk/reward for a given trade.
539    pub async fn get_option_risk_reward(
540        &mut self,
541        request: &RiskRewardRequest,
542    ) -> Result<RiskRewardResponse, Error> {
543        let resp = self
544            .post("/v3/marketdata/options/riskreward", request)
545            .await?;
546        Ok(resp.json().await?)
547    }
548}