Skip to main content

tradingview/chart/
options.rs

1use crate::{
2    Error,
3    models::{Interval, MarketAdjustment, SessionType, pine_indicator::ScriptType},
4};
5use bon::Builder;
6use iso_currency::Currency;
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use ustr::Ustr;
10
11/// Configuration for a real-time chart data subscription.
12///
13/// Specifies the instrument, time interval, bar count, replay settings,
14/// and optional study configuration for a TradingView WebSocket chart session.
15///
16/// # Construction
17///
18/// Use the builder pattern via [`bon`]:
19///
20/// ```rust
21/// use tradingview::{ChartOptions, Interval};
22///
23/// let opts = ChartOptions::builder()
24///     .symbol("BTCUSDT")
25///     .exchange("BINANCE")
26///     .interval(Interval::OneHour)
27///     .bar_count(500)
28///     .build()
29///     .unwrap();
30/// ```
31///
32/// Alternatively, pass an instrument string in `"EXCHANGE:SYMBOL"` format:
33///
34/// ```rust
35/// use tradingview::ChartOptions;
36///
37/// let opts = ChartOptions::builder()
38///     .instrument("BINANCE:BTCUSDT")
39///     .build()
40///     .unwrap();
41/// ```
42#[derive(Debug, Clone, Deserialize, Serialize, Copy)]
43pub struct ChartOptions {
44    pub symbol: Option<Ustr>,
45    pub exchange: Option<Ustr>,
46    pub interval: Interval,
47    pub bar_count: u64,
48    pub range: Option<Range>,
49    pub replay_mode: bool,
50    pub replay_from: i64,
51    pub replay_session: Option<Ustr>,
52    pub adjustment: Option<MarketAdjustment>,
53    pub currency: Option<Currency>,
54    pub session_type: Option<SessionType>,
55    pub study_config: Option<StudyOptions>,
56}
57
58/// Data range specifier for chart subscriptions.
59///
60/// Determines how much historical data to request. `FromTo(u64, u64)` specifies
61/// a custom Unix-timestamp range; the named variants are convenience presets.
62#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Copy)]
63pub enum Range {
64    FromTo(u64, u64),
65    OneDay,
66    FiveDays,
67    OneMonth,
68    ThreeMonths,
69    SixMonths,
70    YearToDate,
71    OneYear,
72    FiveYears,
73    All,
74}
75
76impl Range {
77    pub fn from_to(from: u64, to: u64) -> Self {
78        Range::FromTo(from, to)
79    }
80
81    pub fn one_day() -> Self {
82        Range::OneDay
83    }
84
85    pub fn five_days() -> Self {
86        Range::FiveDays
87    }
88
89    pub fn one_month() -> Self {
90        Range::OneMonth
91    }
92
93    pub fn three_months() -> Self {
94        Range::ThreeMonths
95    }
96
97    pub fn six_months() -> Self {
98        Range::SixMonths
99    }
100
101    pub fn year_to_date() -> Self {
102        Range::YearToDate
103    }
104
105    pub fn one_year() -> Self {
106        Range::OneYear
107    }
108
109    pub fn five_years() -> Self {
110        Range::FiveYears
111    }
112
113    pub fn all() -> Self {
114        Range::All
115    }
116}
117
118impl fmt::Display for Range {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        match self {
121            Range::FromTo(from, to) => write!(f, "r,{from}:{to}"),
122            Range::OneDay => write!(f, "1D"),
123            Range::FiveDays => write!(f, "5d"),
124            Range::OneMonth => write!(f, "1M"),
125            Range::ThreeMonths => write!(f, "3M"),
126            Range::SixMonths => write!(f, "6M"),
127            Range::YearToDate => write!(f, "YTD"),
128            Range::OneYear => write!(f, "12M"),
129            Range::FiveYears => write!(f, "60M"),
130            Range::All => write!(f, "ALL"),
131        }
132    }
133}
134
135impl From<Range> for Ustr {
136    fn from(val: Range) -> Self {
137        match val {
138            Range::FromTo(from, to) => Ustr::from(&format!("r,{from}:{to}")),
139            Range::OneDay => Ustr::from("1D"),
140            Range::FiveDays => Ustr::from("5d"),
141            Range::OneMonth => Ustr::from("1M"),
142            Range::ThreeMonths => Ustr::from("3M"),
143            Range::SixMonths => Ustr::from("6M"),
144            Range::YearToDate => Ustr::from("YTD"),
145            Range::OneYear => Ustr::from("12M"),
146            Range::FiveYears => Ustr::from("60M"),
147            Range::All => Ustr::from("ALL"),
148        }
149    }
150}
151
152/// Configuration for a Pine Script study (indicator) within a chart session.
153///
154/// A study is identified by its `script_id` and `script_version`. The
155/// `script_type` determines how the study data is delivered (per-candle or
156/// as a standalone series).
157#[derive(Default, Debug, Clone, Deserialize, Serialize, Builder, Copy)]
158pub struct StudyOptions {
159    pub script_id: Ustr,
160    pub script_version: Ustr,
161    pub script_type: ScriptType,
162}
163
164impl Default for ChartOptions {
165    fn default() -> Self {
166        Self::builder()
167            .build()
168            .expect("Failed to create default ChartOptions")
169    }
170}
171
172#[bon::bon]
173impl ChartOptions {
174    #[builder]
175    pub fn new(
176        instrument: Option<&str>,
177        symbol: Option<&str>,
178        exchange: Option<&str>,
179        #[builder(default = Interval::OneDay)] interval: Interval,
180        #[builder(default = 500_000)] bar_count: u64,
181        range: Option<Range>,
182        #[builder(default = false)] replay_mode: bool,
183        #[builder(default = 0)] replay_from: i64,
184        replay_session: Option<&str>,
185        adjustment: Option<MarketAdjustment>,
186        currency: Option<Currency>,
187        session_type: Option<SessionType>,
188        study_config: Option<StudyOptions>,
189    ) -> Result<Self, Error> {
190        let (validated_exchange, validated_symbol) =
191            Self::validate_instrument(instrument, symbol, exchange)?;
192
193        Ok(Self {
194            symbol: validated_symbol,
195            exchange: validated_exchange,
196            interval,
197            bar_count,
198            range,
199            replay_mode,
200            replay_from,
201            replay_session: replay_session.map(Ustr::from),
202            adjustment,
203            currency,
204            session_type,
205            study_config,
206        })
207    }
208
209    fn validate_instrument(
210        instrument: Option<&str>,
211        symbol: Option<&str>,
212        exchange: Option<&str>,
213    ) -> Result<(Option<Ustr>, Option<Ustr>), String> {
214        match (instrument, symbol, exchange) {
215            // Case 1: Only instrument provided
216            (Some(instrument), None, None) => {
217                if instrument.trim().is_empty() {
218                    return Err("Instrument cannot be empty or whitespace only".to_string());
219                }
220
221                let parts: Vec<&str> = instrument.split(':').collect();
222                if parts.len() != 2 {
223                    return Err("Instrument must be in format 'EXCHANGE:SYMBOL'".to_string());
224                }
225
226                let exchange_part = parts[0].trim();
227                let symbol_part = parts[1].trim();
228
229                if exchange_part.is_empty() || symbol_part.is_empty() {
230                    return Err(
231                        "Both exchange and symbol parts must be non-empty in instrument"
232                            .to_string(),
233                    );
234                }
235
236                // Validate characters (alphanumeric + common trading symbols)
237                if !Self::is_valid_identifier(exchange_part)
238                    || !Self::is_valid_identifier(symbol_part)
239                {
240                    return Err("Exchange and symbol must contain only alphanumeric characters, hyphens, dots, and underscores".to_string());
241                }
242
243                Ok((
244                    Some(Ustr::from(exchange_part)),
245                    Some(Ustr::from(symbol_part)),
246                ))
247            }
248
249            // Case 2: Both symbol and exchange provided
250            (None, Some(symbol), Some(exchange)) => {
251                let symbol = symbol.trim();
252                let exchange = exchange.trim();
253
254                if symbol.is_empty() {
255                    return Err("Symbol cannot be empty or whitespace only".to_string());
256                }
257                if exchange.is_empty() {
258                    return Err("Exchange cannot be empty or whitespace only".to_string());
259                }
260
261                if !Self::is_valid_identifier(symbol) || !Self::is_valid_identifier(exchange) {
262                    return Err("Symbol and exchange must contain only alphanumeric characters, hyphens, dots, and underscores".to_string());
263                }
264
265                Ok((Some(Ustr::from(exchange)), Some(Ustr::from(symbol))))
266            }
267
268            // Case 3: Invalid combinations
269            (None, None, None) => {
270                Err("Either instrument OR both symbol and exchange must be provided".to_string())
271            }
272            (Some(_), Some(_), _) | (Some(_), _, Some(_)) => {
273                Err("Cannot provide instrument together with symbol or exchange".to_string())
274            }
275            (None, Some(_), None) | (None, None, Some(_)) => {
276                Err("Symbol and exchange must be provided together".to_string())
277            }
278        }
279    }
280
281    fn is_valid_identifier(s: &str) -> bool {
282        !s.is_empty()
283            && s.chars().all(|c| {
284                c.is_alphanumeric()
285                    || c == '$'
286                    || c == '%'
287                    || c == '#'
288                    || c == '*'
289                    || c == '('
290                    || c == ')'
291                    || c == ':'
292            })
293    }
294
295    pub fn interval(mut self, interval: Interval) -> Self {
296        self.interval = interval;
297        self
298    }
299
300    pub fn bar_count(mut self, bar_count: u64) -> Self {
301        self.bar_count = bar_count;
302        self
303    }
304
305    pub fn replay_mode(mut self, replay_mode: bool) -> Self {
306        self.replay_mode = replay_mode;
307        self
308    }
309
310    pub fn replay_from(mut self, replay_from: i64) -> Self {
311        self.replay_from = replay_from;
312        self
313    }
314
315    pub fn replay_session_id(mut self, replay_session_id: &str) -> Self {
316        self.replay_session = Some(Ustr::from(replay_session_id));
317        self
318    }
319
320    /// range: |r,1626220800:1628640000|1D|5d|1M|3M|6M|YTD|12M|60M|ALL|
321    pub fn range(mut self, range: Range) -> Self {
322        self.range = Some(range);
323        self
324    }
325
326    pub fn adjustment(mut self, adjustment: MarketAdjustment) -> Self {
327        self.adjustment = Some(adjustment);
328        self
329    }
330
331    pub fn currency(mut self, currency: Currency) -> Self {
332        self.currency = Some(currency);
333        self
334    }
335
336    pub fn session_type(mut self, session_type: SessionType) -> Self {
337        self.session_type = Some(session_type);
338        self
339    }
340
341    pub fn study_config(
342        mut self,
343        script_id: &str,
344        script_version: &str,
345        script_type: ScriptType,
346    ) -> Self {
347        self.study_config = Some(StudyOptions {
348            script_id: Ustr::from(script_id),
349            script_version: Ustr::from(script_version),
350            script_type,
351        });
352        self
353    }
354}