Skip to main content

tradingview/study/
client.rs

1//! High-level client for fetching TradingView study and fundamental data.
2
3use std::sync::{Arc, Mutex};
4use std::time::Instant;
5
6use serde::Deserialize;
7use serde_json::Value;
8use tracing::{debug, error, instrument, trace, warn};
9
10use crate::Result;
11use crate::chart::{DataPoint, SymbolInfo};
12use crate::error::{Error, TradingViewError};
13use crate::live::handler::command::Command;
14use crate::live::handler::{CommandTx, Handler, HandlerFactory};
15use crate::live::models::{DataServer, TradingViewDataEvent};
16use crate::live::websocket::WebSocketClient;
17use crate::study::request::StudyRequest;
18use crate::study::result::StudyResult;
19use crate::study::state::StudyState;
20use crate::utils::symbol_init;
21
22/// High-level client for fetching TradingView study and fundamental data.
23pub struct StudyClient {
24    pub(crate) auth_token: String,
25    pub(crate) server: DataServer,
26}
27
28impl StudyClient {
29    /// Creates a new [`StudyClient`] with the specified auth token and data server.
30    pub fn new(auth_token: impl Into<String>, server: DataServer) -> Self {
31        Self {
32            auth_token: auth_token.into(),
33            server,
34        }
35    }
36
37    /// Retrieves study or fundamental data according to the given request.
38    #[instrument(skip(self), fields(symbol, exchange))]
39    pub async fn retrieve(&self, request: StudyRequest) -> Result<StudyResult> {
40        let started = Instant::now();
41        let (symbol, exchange) = request.resolve_symbol_exchange()?;
42        debug!(symbol = %symbol, exchange = %exchange, "study retrieval started");
43
44        let study_id = request
45            .study_id
46            .clone()
47            .unwrap_or_else(|| format!("st_{}", crate::utils::gen_id()));
48        let study_sub_id = "st1".to_string();
49
50        let state = Arc::new(Mutex::new(StudyState::with_capacity_and_notify(
51            request.base_bar_count as usize,
52            Arc::new(tokio::sync::Notify::new()),
53        )));
54
55        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel::<Command>(16);
56        let factory = StudyDataHandlerFactory::new(Arc::clone(&state), &study_id);
57        let handler = factory.create(cmd_tx);
58
59        let ws = WebSocketClient::builder()
60            .auth_token(&self.auth_token)
61            .server(self.server)
62            .handler(handler)
63            .build()
64            .await?;
65
66        // ── Protocol sequence ──────────────────────────────────────────
67        // TradingView study retrieval protocol:
68        //   1. chart_create_session → create chart session
69        //   2. resolve_symbol       → resolve instrument and receive SymbolInfo
70        //   3. create_series        → create base price series
71        //   4. create_study         → attach study to chart series (sds_1)
72        //   5. wait for matching study_completed or error
73        let instrument = format!("{exchange}:{symbol}");
74        let chart_session = format!("cs_{}", crate::utils::gen_id());
75        let symbol_series_id = format!("sds_sym_{}", crate::utils::gen_id());
76        let series_identifier = "sds_1".to_string();
77        let series_id = "s1".to_string();
78
79        // 1. Create chart session.
80        ws.create_chart_session(&chart_session).await?;
81        debug!(session = %chart_session, "chart session created");
82
83        // 2. Resolve symbol within the session.
84        let symbol_init_str = symbol_init().instrument(&instrument).call()?;
85        ws.send(
86            "resolve_symbol",
87            &[
88                Value::from(chart_session.as_str()),
89                Value::from(symbol_series_id.as_str()),
90                Value::from(symbol_init_str),
91            ],
92        )
93        .await?;
94        debug!(instrument = %instrument, "symbol resolution requested");
95
96        // 3. Create base data series.
97        let create_series_args = crate::historical::client::build_create_series_args(
98            &chart_session,
99            &series_identifier,
100            &series_id,
101            &symbol_series_id,
102            request.interval,
103            Some(request.base_bar_count),
104            None,
105        );
106        ws.send("create_series", &create_series_args).await?;
107        debug!(
108            interval = ?request.interval,
109            bars = request.base_bar_count,
110            "base series created"
111        );
112
113        // 4. Create study attached to base series reference (sds_1).
114        ws.create_study()
115            .chart_session(&chart_session)
116            .study_ids(&[&study_id, &study_sub_id])
117            .chart_series_id(&series_identifier)
118            .study(request.study)
119            .call()
120            .await?;
121        debug!(study_id = %study_id, "study created");
122
123        Arc::clone(&ws).spawn_reader_task();
124
125        let result = tokio::time::timeout(request.timeout, Self::wait_for_completion(&state)).await;
126
127        let mut state_guard = state.lock().unwrap();
128        let total_points = state_guard.total_points;
129        let data = state_guard.finalize();
130        let elapsed = started.elapsed();
131
132        match result {
133            Ok(_) => {
134                if state_guard.errored {
135                    let msg = state_guard
136                        .error_message
137                        .take()
138                        .unwrap_or_else(|| "study data retrieval failed".to_string());
139                    return Err(Error::Internal(msg.into()));
140                }
141                let symbol_info = state_guard
142                    .symbol_info
143                    .take()
144                    .ok_or_else(|| Error::Internal("no symbol info received".into()))?;
145                Ok(StudyResult {
146                    symbol_info,
147                    data,
148                    study_id,
149                    total_points_received: total_points,
150                    elapsed,
151                })
152            }
153            Err(_) => Err(Error::Timeout("study data retrieval timed out".into())),
154        }
155    }
156
157    pub(crate) async fn wait_for_completion(state: &Arc<Mutex<StudyState>>) {
158        let notify = {
159            let guard = state.lock().unwrap();
160            guard.notify.clone()
161        };
162        loop {
163            // Register as waiter before checking predicate to avoid lost wakeups.
164            let notified = notify.notified();
165            tokio::pin!(notified);
166            notified.as_mut().enable();
167
168            {
169                let guard = state.lock().unwrap();
170                if guard.is_done() {
171                    break;
172                }
173            }
174
175            notified.await;
176        }
177    }
178}
179
180// =============================================================================
181// StudyDataHandler
182// =============================================================================
183
184/// Event handler that accumulates study data points into shared [`StudyState`].
185#[derive(Clone)]
186pub(crate) struct StudyDataHandler {
187    state: Arc<Mutex<StudyState>>,
188    study_id: String,
189    #[allow(dead_code)]
190    cmd_tx: CommandTx,
191}
192
193impl Handler for StudyDataHandler {
194    fn handle_events(&self, event: TradingViewDataEvent, message: &[Value]) {
195        match event {
196            TradingViewDataEvent::OnSymbolResolved => {
197                // resolve_symbol response: [session, symbol_series_id, SymbolInfo]
198                if let Some(sym_info) = message.get(2)
199                    && let Ok(info) = SymbolInfo::deserialize(sym_info)
200                {
201                    debug!(name = %info.name, "symbol resolved");
202                    self.state.lock().unwrap().record_symbol_info(info);
203                }
204            }
205            TradingViewDataEvent::OnChartData | TradingViewDataEvent::OnChartDataUpdate => {
206                // timescale_update or du message:
207                // message[0]: chart_session
208                // message[1]: object with study_id or series_id keys
209                if message.len() < 2 {
210                    return;
211                }
212                if let Some(obj) = message[1].as_object()
213                    && let Some(study_val) = obj.get(&self.study_id)
214                    && let Some(st_arr) = study_val.get("st").and_then(|v| v.as_array())
215                {
216                    let mut points = Vec::with_capacity(st_arr.len());
217                    for v in st_arr {
218                        if let Ok(point) = DataPoint::deserialize(v) {
219                            points.push(point);
220                        }
221                    }
222                    if !points.is_empty() {
223                        let count = points.len();
224                        let mut state = self.state.lock().unwrap();
225                        state.record_points(points, count);
226                    }
227                }
228            }
229            TradingViewDataEvent::OnStudyCompleted => {
230                // study_completed message: [chart_session, study_id, study_sub_id]
231                let completed_id = message.get(1).and_then(|v| v.as_str());
232                if completed_id == Some(&self.study_id) {
233                    debug!(study_id = %self.study_id, "study completed");
234                    self.state.lock().unwrap().complete();
235                } else {
236                    trace!(
237                        completed = ?completed_id,
238                        expected = %self.study_id,
239                        "ignoring study_completed for unrelated study"
240                    );
241                }
242            }
243            TradingViewDataEvent::OnError(tv_error) => {
244                // If the error is a study_error with an explicit study_id not matching ours, ignore it.
245                if tv_error == TradingViewError::StudyError
246                    && let Some(err_study_id) = message.get(1).and_then(|v| v.as_str())
247                    && err_study_id != self.study_id
248                {
249                    warn!(
250                        err_study_id = %err_study_id,
251                        expected = %self.study_id,
252                        "ignoring study_error for unrelated study"
253                    );
254                    return;
255                }
256
257                error!(?tv_error, "tradingview study/protocol error");
258                let err_details = message
259                    .iter()
260                    .filter_map(|v| v.as_str())
261                    .collect::<Vec<_>>()
262                    .join(" ");
263                let msg = if err_details.is_empty() {
264                    format!("tradingview error: {tv_error:?}")
265                } else {
266                    format!("tradingview error: {tv_error:?}: {err_details}")
267                };
268                let mut state = self.state.lock().unwrap();
269                state.fail(msg);
270            }
271            _ => {}
272        }
273    }
274
275    fn handle_quote_data(&self, _message: &[Value]) {}
276    fn handle_series_data(&self, _event: TradingViewDataEvent, _messages: &[Value]) {}
277
278    fn notify_error(&self, error: Error, _message: &[Value]) {
279        warn!(?error, "study handler error");
280        let mut state = self.state.lock().unwrap();
281        if state.record_error() {
282            state.fail(format!("too many errors: {error:?}"));
283        }
284    }
285}
286
287// =============================================================================
288// StudyDataHandlerFactory
289// =============================================================================
290
291/// Factory for creating [`StudyDataHandler`] instances that share a common [`StudyState`].
292pub(crate) struct StudyDataHandlerFactory {
293    state: Arc<Mutex<StudyState>>,
294    study_id: String,
295}
296
297impl StudyDataHandlerFactory {
298    /// Create a new factory wrapping the given shared state and study ID.
299    pub fn new(state: Arc<Mutex<StudyState>>, study_id: impl Into<String>) -> Self {
300        Self {
301            state,
302            study_id: study_id.into(),
303        }
304    }
305}
306
307impl HandlerFactory for StudyDataHandlerFactory {
308    type Handler = StudyDataHandler;
309
310    fn create(&self, command_tx: CommandTx) -> Self::Handler {
311        StudyDataHandler {
312            state: Arc::clone(&self.state),
313            study_id: self.study_id.clone(),
314            cmd_tx: command_tx,
315        }
316    }
317}
318
319// =============================================================================
320// Unit Tests
321// =============================================================================
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use serde_json::json;
327
328    fn setup_handler(study_id: &str) -> (StudyDataHandler, Arc<Mutex<StudyState>>) {
329        let state = Arc::new(Mutex::new(StudyState::new()));
330        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
331        let handler = StudyDataHandler {
332            state: Arc::clone(&state),
333            study_id: study_id.to_string(),
334            cmd_tx,
335        };
336        (handler, state)
337    }
338
339    #[test]
340    fn test_matching_study_id_routing() {
341        let (handler, state) = setup_handler("st_target");
342
343        // Payload with target study and points
344        let payload = json!([
345            "cs_test",
346            {
347                "st_target": {
348                    "st": [
349                        {"i": -10, "v": [1600000000.0, 100.5, 200.5]},
350                        {"i": -9, "v": [1600086400.0, 105.0, 210.0]}
351                    ]
352                }
353            }
354        ]);
355
356        handler.handle_events(
357            TradingViewDataEvent::OnChartData,
358            payload.as_array().unwrap(),
359        );
360
361        let guard = state.lock().unwrap();
362        assert_eq!(guard.data.len(), 2);
363        assert_eq!(guard.data[0].index, -10);
364        assert_eq!(guard.data[0].value, vec![1600000000.0, 100.5, 200.5]);
365        assert_eq!(guard.data[1].index, -9);
366        assert_eq!(guard.data[1].value, vec![1600086400.0, 105.0, 210.0]);
367        assert_eq!(guard.total_points, 2);
368    }
369
370    #[test]
371    fn test_ignoring_unrelated_studies() {
372        let (handler, state) = setup_handler("st_target");
373
374        // Payload with only unrelated study and base series
375        let payload = json!([
376            "cs_test",
377            {
378                "st_unrelated": {
379                    "st": [
380                        {"i": 0, "v": [1600000000.0, 999.0]}
381                    ]
382                },
383                "sds_1": {
384                    "s": [
385                        {"i": 0, "v": [1600000000.0, 10.0, 20.0, 5.0, 15.0, 1000.0]}
386                    ]
387                }
388            }
389        ]);
390
391        handler.handle_events(
392            TradingViewDataEvent::OnChartDataUpdate,
393            payload.as_array().unwrap(),
394        );
395
396        let guard = state.lock().unwrap();
397        assert!(guard.data.is_empty());
398        assert_eq!(guard.total_points, 0);
399    }
400
401    #[test]
402    fn test_mixed_payload_extracts_only_target() {
403        let (handler, state) = setup_handler("st_target");
404
405        let payload = json!([
406            "cs_test",
407            {
408                "st_unrelated": {
409                    "st": [{"i": 1, "v": [100.0, 999.0]}]
410                },
411                "st_target": {
412                    "st": [{"i": 2, "v": [200.0, 888.0]}]
413                }
414            }
415        ]);
416
417        handler.handle_events(
418            TradingViewDataEvent::OnChartData,
419            payload.as_array().unwrap(),
420        );
421
422        let guard = state.lock().unwrap();
423        assert_eq!(guard.data.len(), 1);
424        assert_eq!(guard.data[0].index, 2);
425        assert_eq!(guard.data[0].value, vec![200.0, 888.0]);
426    }
427
428    #[test]
429    fn test_completion_filtering() {
430        let (handler, state) = setup_handler("st_target");
431
432        // Unrelated completion
433        let unrelated = json!(["cs_test", "st_other", "s1_st1"]);
434        handler.handle_events(
435            TradingViewDataEvent::OnStudyCompleted,
436            unrelated.as_array().unwrap(),
437        );
438        assert!(!state.lock().unwrap().completed);
439
440        // Matching completion
441        let matching = json!(["cs_test", "st_target", "s1_st1"]);
442        handler.handle_events(
443            TradingViewDataEvent::OnStudyCompleted,
444            matching.as_array().unwrap(),
445        );
446        assert!(state.lock().unwrap().completed);
447    }
448
449    #[test]
450    fn test_study_error_filtering() {
451        let (handler, state) = setup_handler("st_target");
452
453        // Unrelated study error should be ignored
454        let unrelated_err = json!(["cs_test", "st_other", "study initialization failed"]);
455        handler.handle_events(
456            TradingViewDataEvent::OnError(TradingViewError::StudyError),
457            unrelated_err.as_array().unwrap(),
458        );
459        assert!(!state.lock().unwrap().errored);
460
461        // Matching study error should fail state
462        let matching_err = json!(["cs_test", "st_target", "invalid indicator parameters"]);
463        handler.handle_events(
464            TradingViewDataEvent::OnError(TradingViewError::StudyError),
465            matching_err.as_array().unwrap(),
466        );
467        let guard = state.lock().unwrap();
468        assert!(guard.errored);
469        assert!(
470            guard
471                .error_message
472                .as_ref()
473                .unwrap()
474                .contains("invalid indicator parameters")
475        );
476    }
477
478    #[test]
479    fn test_protocol_error_triggers_failure() {
480        let (handler, state) = setup_handler("st_target");
481
482        let protocol_err = json!(["cs_test", "critical error occurred"]);
483        handler.handle_events(
484            TradingViewDataEvent::OnError(TradingViewError::CriticalError),
485            protocol_err.as_array().unwrap(),
486        );
487        let guard = state.lock().unwrap();
488        assert!(guard.errored);
489        assert!(guard.error_message.is_some());
490    }
491
492    #[test]
493    fn test_sort_and_dedup_points_replaces_with_latest_point() {
494        let mut state = StudyState::new();
495
496        // Points arriving in chronological sequence:
497        // 1. Point at ts=1600020000, idx=10 with initial value 10.0
498        // 2. Point at ts=1600010000, idx=5 with value 100.0
499        // 3. Update to ts=1600020000, idx=10 with NEW value -42.5 (should overwrite 10.0)
500        // 4. Point at ts=1600000000, idx=1 with value 0.0
501        let p1 = DataPoint {
502            index: 10,
503            value: vec![1600020000.0, 10.0],
504        };
505        let p2 = DataPoint {
506            index: 5,
507            value: vec![1600010000.0, 100.0],
508        };
509        let p1_update = DataPoint {
510            index: 10,
511            value: vec![1600020000.0, -42.5, 1e100],
512        };
513        let p0 = DataPoint {
514            index: 1,
515            value: vec![1600000000.0, 0.0],
516        };
517
518        state.record_points(vec![p1, p2, p1_update, p0], 4);
519        let finalized = state.finalize();
520
521        // Must have 3 points ordered ascending by timestamp
522        assert_eq!(finalized.len(), 3);
523        assert_eq!(finalized[0].value[0] as i64, 1600000000);
524        assert_eq!(finalized[1].value[0] as i64, 1600010000);
525        assert_eq!(finalized[2].value[0] as i64, 1600020000);
526        // Latest point for index 10 must replace earlier value:
527        assert_eq!(finalized[2].value[1], -42.5);
528        assert_eq!(finalized[2].value[2], 1e100);
529    }
530
531    #[test]
532    fn test_captured_time_scale_update_fixture() {
533        let fixture_str = include_str!("../../tests/data/time_scale_update.json");
534        let messages: Vec<Value> = serde_json::from_str(fixture_str).unwrap();
535
536        // Track "st3" which appears in the fixture
537        let (handler, state) = setup_handler("st3");
538
539        for msg in &messages {
540            let m = msg["m"].as_str().unwrap();
541            let p = msg["p"].as_array().unwrap();
542            let event = TradingViewDataEvent::from(m.to_string());
543            handler.handle_events(event, p);
544        }
545
546        let mut guard = state.lock().unwrap();
547        assert!(guard.completed, "st3 should have received study_completed");
548        assert!(!guard.errored);
549        assert!(!guard.data.is_empty(), "st3 should have captured points");
550
551        let points = guard.finalize();
552        // Points must be sorted ascending by timestamp
553        for window in points.windows(2) {
554            let ts_a = window[0].value[0] as i64;
555            let ts_b = window[1].value[0] as i64;
556            assert!(
557                ts_a <= ts_b,
558                "points must be ordered ascending: {ts_a} > {ts_b}"
559            );
560        }
561    }
562
563    #[test]
564    fn test_captured_fixture_ignores_unrelated() {
565        let fixture_str = include_str!("../../tests/data/time_scale_update.json");
566        let messages: Vec<Value> = serde_json::from_str(fixture_str).unwrap();
567
568        // Track a study ID not present in the fixture
569        let (handler, state) = setup_handler("st_nonexistent");
570
571        for msg in &messages {
572            let m = msg["m"].as_str().unwrap();
573            let p = msg["p"].as_array().unwrap();
574            let event = TradingViewDataEvent::from(m.to_string());
575            handler.handle_events(event, p);
576        }
577
578        let guard = state.lock().unwrap();
579        assert!(!guard.completed);
580        assert!(guard.data.is_empty());
581    }
582}