Skip to main content

digdigdig3/l1/paid/tiingo/
connector.rs

1//! # Tiingo Connector
2//!
3//! Main connector implementation with trait implementations.
4//!
5//! ## Trait Implementation Status
6//! - `ExchangeIdentity`: Yes (basic identification)
7//! - `MarketData`: Yes (full implementation for stocks/crypto/forex)
8//! - `Trading`: No (returns UnsupportedOperation - data provider only)
9//! - `Account`: No (returns UnsupportedOperation - data provider only)
10//! - `Positions`: No (returns UnsupportedOperation - data provider only)
11
12use std::collections::HashMap;
13use std::sync::{Arc, Mutex};
14use std::time::Duration;
15
16use async_trait::async_trait;
17use serde_json::Value;
18
19use crate::core::{
20    HttpClient, Credentials,
21    ExchangeId, ExchangeType, AccountType, Symbol,
22    ExchangeError, ExchangeResult,
23    Price, Kline, Ticker, OrderBook,
24    Order, Balance, AccountInfo, Position, FundingRate,
25    OrderRequest, CancelRequest,
26    BalanceQuery, PositionQuery, PositionModification,
27    OrderHistoryFilter, PlaceOrderResponse, FeeInfo,
28};
29use crate::core::traits::{
30    ExchangeIdentity, MarketData, Trading, Account, Positions,
31};
32use crate::core::utils::WeightRateLimiter;
33use crate::core::types::{SymbolInfo, SymbolInput};
34
35use super::endpoints::{
36    TiingoUrls, TiingoEndpoint,
37    format_crypto_symbol, format_forex_symbol,
38    map_interval,
39};
40use super::auth::TiingoAuth;
41use super::parser::TiingoParser;
42
43// ═══════════════════════════════════════════════════════════════════════════════
44// CONNECTOR
45// ═══════════════════════════════════════════════════════════════════════════════
46
47/// Tiingo connector for multi-asset data
48pub struct TiingoConnector {
49    /// HTTP client
50    http: HttpClient,
51    /// Authentication
52    auth: TiingoAuth,
53    /// URLs
54    urls: TiingoUrls,
55    /// Rate limiter (5 req/min for free tier, higher for paid)
56    rate_limiter: Arc<Mutex<WeightRateLimiter>>,
57}
58
59impl TiingoConnector {
60    /// Create new connector
61    ///
62    /// # Arguments
63    /// * `credentials` - API credentials (requires api_key as API token)
64    pub async fn new(credentials: Credentials) -> ExchangeResult<Self> {
65        let auth = TiingoAuth::new(&credentials)?;
66        let urls = TiingoUrls::MAINNET;
67        let http = HttpClient::new(30_000)?; // 30 sec timeout
68
69        // Initialize rate limiter: 5 req/min for free tier
70        // Note: Paid tiers have higher limits (up to 1200/min)
71        let rate_limiter = Arc::new(Mutex::new(
72            WeightRateLimiter::new(5, Duration::from_secs(60))
73        ));
74
75        Ok(Self {
76            http,
77            auth,
78            urls,
79            rate_limiter,
80        })
81    }
82
83    // ═══════════════════════════════════════════════════════════════════════════
84    // HTTP HELPERS
85    // ═══════════════════════════════════════════════════════════════════════════
86
87    /// Wait for rate limit if needed
88    async fn rate_limit_wait(&self, weight: u32) {
89        loop {
90            let wait_time = {
91                let mut limiter = self.rate_limiter.lock().expect("Mutex poisoned");
92                if limiter.try_acquire(weight) {
93                    return;
94                }
95                limiter.time_until_ready(weight)
96            };
97
98            if wait_time > Duration::ZERO {
99                tokio::time::sleep(wait_time).await;
100            }
101        }
102    }
103
104    /// GET request with authentication
105    async fn get(
106        &self,
107        endpoint: TiingoEndpoint,
108        ticker: Option<&str>,
109        params: HashMap<String, String>,
110    ) -> ExchangeResult<Value> {
111        // Wait for rate limit
112        self.rate_limit_wait(1).await;
113
114        let base_url = self.urls.rest_url();
115        let url = endpoint.build_url(base_url, ticker);
116
117        // Get auth headers
118        let headers = self.auth.get_auth_header();
119
120        // Build query string
121        let query = if params.is_empty() {
122            String::new()
123        } else {
124            let qs: Vec<String> = params.iter()
125                .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
126                .collect();
127            format!("?{}", qs.join("&"))
128        };
129
130        let full_url = format!("{}{}", url, query);
131
132        // Make request
133        let response = self.http.get(&full_url, &headers).await?;
134
135        Ok(response)
136    }
137}
138
139// ═══════════════════════════════════════════════════════════════════════════════
140// TRAIT: ExchangeIdentity
141// ═══════════════════════════════════════════════════════════════════════════════
142
143impl ExchangeIdentity for TiingoConnector {
144    fn exchange_id(&self) -> ExchangeId {
145        ExchangeId::Tiingo
146    }
147
148    fn is_testnet(&self) -> bool {
149        false // Tiingo doesn't have testnet
150    }
151
152    fn supported_account_types(&self) -> Vec<AccountType> {
153        // Data provider only, but we use Spot as default for compatibility
154        vec![AccountType::Spot]
155    }
156
157    fn exchange_type(&self) -> ExchangeType {
158        ExchangeType::DataProvider
159    }
160}
161
162// ═══════════════════════════════════════════════════════════════════════════════
163// TRAIT: MarketData
164// ═══════════════════════════════════════════════════════════════════════════════
165
166#[async_trait]
167impl MarketData for TiingoConnector {
168    /// Get current price (uses IEX endpoint for stocks)
169    async fn get_price(
170        &self,
171        symbol: SymbolInput<'_>,
172        _account_type: AccountType,
173    ) -> ExchangeResult<Price> {
174        let sym_str: String = match symbol { SymbolInput::Raw(s) => s.to_string(), SymbolInput::Canonical(c) => c.to_concat() };
175        let mut params = HashMap::new();
176        params.insert("columns".to_string(), "close".to_string());
177
178        let response = self.get(
179            TiingoEndpoint::IexPrices,
180            Some(&sym_str),
181            params,
182        ).await?;
183
184        // Parse IEX prices and get latest
185        let klines = TiingoParser::parse_iex_prices(&response)?;
186        let latest = klines.last()
187            .ok_or_else(|| ExchangeError::Parse("No price data available".to_string()))?;
188
189        Ok(latest.close)
190    }
191
192    /// Get orderbook (NOT SUPPORTED - data provider doesn't offer orderbook)
193    async fn get_orderbook(
194        &self,
195        _symbol: SymbolInput<'_>,
196        _limit: Option<u16>,
197        _account_type: AccountType,
198    ) -> ExchangeResult<OrderBook> {
199        Err(ExchangeError::UnsupportedOperation(
200            "Tiingo does not provide orderbook data - market data provider only".to_string()
201        ))
202    }
203
204    /// Get klines/candles
205    async fn get_klines(
206        &self,
207        symbol: SymbolInput<'_>,
208        interval: &str,
209        limit: Option<u16>,
210        _account_type: AccountType,
211        _end_time: Option<i64>,
212    ) -> ExchangeResult<Vec<Kline>> {
213        let sym_str: String = match symbol { SymbolInput::Raw(s) => s.to_string(), SymbolInput::Canonical(c) => c.to_concat() };
214        let resample_freq = map_interval(interval);
215
216        let mut params = HashMap::new();
217        params.insert("resampleFreq".to_string(), resample_freq.to_string());
218
219        let response = self.get(
220            TiingoEndpoint::IexPrices,
221            Some(&sym_str),
222            params,
223        ).await?;
224
225        let mut klines = TiingoParser::parse_iex_prices(&response)?;
226
227        // Apply limit client-side if specified
228        if let Some(lim) = limit {
229            let start = klines.len().saturating_sub(lim as usize);
230            klines = klines[start..].to_vec();
231        }
232
233        Ok(klines)
234    }
235
236    /// Get 24h ticker
237    async fn get_ticker(
238        &self,
239        symbol: SymbolInput<'_>,
240        _account_type: AccountType,
241    ) -> ExchangeResult<Ticker> {
242        let sym_str: String = match symbol { SymbolInput::Raw(s) => s.to_string(), SymbolInput::Canonical(c) => c.to_concat() };
243        let response = self.get(
244            TiingoEndpoint::IexPrices,
245            Some(&sym_str),
246            HashMap::new(),
247        ).await?;
248
249        let klines = TiingoParser::parse_iex_prices(&response)?;
250
251        if klines.is_empty() {
252            return Err(ExchangeError::Parse("No ticker data available".to_string()));
253        }
254
255        // Construct ticker from recent klines
256        let latest = klines.last().expect("Klines should not be empty");
257        let high = klines.iter().map(|k| k.high).fold(f64::NEG_INFINITY, f64::max);
258        let low = klines.iter().map(|k| k.low).fold(f64::INFINITY, f64::min);
259        let volume: f64 = klines.iter().map(|k| k.volume).sum();
260
261        Ok(Ticker {
262            symbol: sym_str,
263            last_price: latest.close,
264            bid_price: None,
265            ask_price: None,
266            high_24h: Some(high),
267            low_24h: Some(low),
268            volume_24h: Some(volume),
269            quote_volume_24h: None,
270            price_change_24h: None,
271            price_change_percent_24h: None,
272            timestamp: latest.open_time,
273        })
274    }
275
276    /// Ping (check connection)
277    async fn ping(&self) -> ExchangeResult<()> {
278        // Use fundamentals definitions endpoint as ping (lightweight)
279        let response = self.get(
280            TiingoEndpoint::FundamentalsDefinitions,
281            None,
282            HashMap::new(),
283        ).await?;
284
285        if response.is_array() || response.is_object() {
286            Ok(())
287        } else {
288            Err(ExchangeError::Network("Ping failed".to_string()))
289        }
290    }
291
292    /// Get exchange info — returns supported crypto tickers from Tiingo
293    async fn get_exchange_info(&self, account_type: AccountType) -> ExchangeResult<Vec<SymbolInfo>> {
294        // Tiingo doesn't have a bulk stock listing endpoint (requires ticker per request).
295        // CryptoMeta returns all supported crypto tickers without pagination.
296        let response = self.get(TiingoEndpoint::CryptoMeta, None, HashMap::new()).await?;
297
298        let arr = response.as_array()
299            .ok_or_else(|| ExchangeError::Parse("Expected array of crypto tickers".to_string()))?;
300
301        let infos = arr.iter().filter_map(|item| {
302            let ticker = item.get("ticker")?.as_str()?.to_string();
303            let base = item.get("baseCurrency")
304                .and_then(|v| v.as_str())
305                .unwrap_or("")
306                .to_uppercase();
307            let quote = item.get("quoteCurrency")
308                .and_then(|v| v.as_str())
309                .unwrap_or("USD")
310                .to_uppercase();
311
312            Some(SymbolInfo {
313                symbol: ticker,
314                base_asset: base,
315                quote_asset: quote,
316                status: "TRADING".to_string(),
317                price_precision: 8,
318                quantity_precision: 8,
319                min_quantity: None,
320                max_quantity: None,
321                tick_size: None,
322                step_size: None,
323                min_notional: None,
324                account_type,
325            })
326        }).collect();
327
328        Ok(infos)
329    }
330}
331
332// ═══════════════════════════════════════════════════════════════════════════════
333// TRAIT: Trading (UNSUPPORTED - Data Provider Only)
334// ═══════════════════════════════════════════════════════════════════════════════
335
336#[async_trait]
337impl Trading for TiingoConnector {
338    async fn place_order(&self, _req: OrderRequest) -> ExchangeResult<PlaceOrderResponse> {
339        Err(ExchangeError::UnsupportedOperation(
340            "Tiingo is a data provider, not an exchange. Trading is not supported.".to_string()
341        ))
342    }
343
344    async fn cancel_order(&self, _req: CancelRequest) -> ExchangeResult<Order> {
345        Err(ExchangeError::UnsupportedOperation(
346            "Tiingo is a data provider, not an exchange. Trading is not supported.".to_string()
347        ))
348    }
349
350    async fn get_order(
351        &self,
352        _symbol: &str,
353        _order_id: &str,
354        _account_type: AccountType,
355    ) -> ExchangeResult<Order> {
356        Err(ExchangeError::UnsupportedOperation(
357            "Tiingo is a data provider, not an exchange. Trading is not supported.".to_string()
358        ))
359    }
360
361    async fn get_open_orders(
362        &self,
363        _symbol: Option<&str>,
364        _account_type: AccountType,
365    ) -> ExchangeResult<Vec<Order>> {
366        Err(ExchangeError::UnsupportedOperation(
367            "Tiingo is a data provider, not an exchange. Trading is not supported.".to_string()
368        ))
369    }
370
371    async fn get_order_history(
372        &self,
373        _filter: OrderHistoryFilter,
374        _account_type: AccountType,
375    ) -> ExchangeResult<Vec<Order>> {
376        Err(ExchangeError::UnsupportedOperation(
377            "Tiingo is a data provider, not an exchange. Trading is not supported.".to_string()
378        ))
379    }
380}
381
382// ═══════════════════════════════════════════════════════════════════════════════
383// TRAIT: Account (UNSUPPORTED - Data Provider Only)
384// ═══════════════════════════════════════════════════════════════════════════════
385
386#[async_trait]
387impl Account for TiingoConnector {
388    async fn get_balance(&self, _query: BalanceQuery) -> ExchangeResult<Vec<Balance>> {
389        Err(ExchangeError::UnsupportedOperation(
390            "Tiingo is a data provider, not an exchange. Account operations are not supported.".to_string()
391        ))
392    
393    }
394
395    async fn get_account_info(
396        &self,
397        _account_type: AccountType,
398    ) -> ExchangeResult<AccountInfo> {
399        Err(ExchangeError::UnsupportedOperation(
400            "Tiingo is a data provider, not an exchange. Account operations are not supported.".to_string()
401        ))
402    }
403
404    async fn get_fees(&self, _symbol: Option<&str>) -> ExchangeResult<FeeInfo> {
405        Err(ExchangeError::UnsupportedOperation(
406            "Tiingo is a data provider, not an exchange. Account operations are not supported.".to_string()
407        ))
408    }
409}
410
411// ═══════════════════════════════════════════════════════════════════════════════
412// TRAIT: Positions (UNSUPPORTED - Data Provider Only)
413// ═══════════════════════════════════════════════════════════════════════════════
414
415#[async_trait]
416impl Positions for TiingoConnector {
417    async fn get_positions(&self, _query: PositionQuery) -> ExchangeResult<Vec<Position>> {
418        Err(ExchangeError::UnsupportedOperation(
419            "Tiingo is a data provider, not an exchange. Position tracking is not supported.".to_string()
420        ))
421    }
422
423    async fn get_funding_rate(
424        &self,
425        _symbol: &str,
426        _account_type: AccountType,
427    ) -> ExchangeResult<FundingRate> {
428        Err(ExchangeError::UnsupportedOperation(
429            "Tiingo is a data provider, not an exchange. Position tracking is not supported.".to_string()
430        ))
431    }
432
433    async fn modify_position(&self, _req: PositionModification) -> ExchangeResult<()> {
434        Err(ExchangeError::UnsupportedOperation(
435            "Tiingo is a data provider, not an exchange. Position tracking is not supported.".to_string()
436        ))
437    }
438}
439
440// ═══════════════════════════════════════════════════════════════════════════════
441// EXTENDED METHODS (Provider-Specific)
442// ═══════════════════════════════════════════════════════════════════════════════
443
444impl TiingoConnector {
445    /// Get daily EOD prices for stocks
446    pub async fn get_daily_prices(
447        &self,
448        ticker: &str,
449        start_date: Option<&str>,
450        end_date: Option<&str>,
451    ) -> ExchangeResult<Vec<Kline>> {
452        let mut params = HashMap::new();
453
454        if let Some(start) = start_date {
455            params.insert("startDate".to_string(), start.to_string());
456        }
457        if let Some(end) = end_date {
458            params.insert("endDate".to_string(), end.to_string());
459        }
460
461        let response = self.get(
462            TiingoEndpoint::DailyPrices,
463            Some(ticker),
464            params,
465        ).await?;
466
467        TiingoParser::parse_daily_prices(&response)
468    }
469
470    /// Get crypto top-of-book quote
471    pub async fn get_crypto_top(
472        &self,
473        symbol: &Symbol,
474    ) -> ExchangeResult<Ticker> {
475        let ticker_symbol = format_crypto_symbol(symbol);
476
477        let mut params = HashMap::new();
478        params.insert("tickers".to_string(), ticker_symbol);
479
480        let response = self.get(
481            TiingoEndpoint::CryptoTop,
482            None,
483            params,
484        ).await?;
485
486        TiingoParser::parse_crypto_top(&response)
487    }
488
489    /// Get crypto historical prices
490    pub async fn get_crypto_prices(
491        &self,
492        symbol: &Symbol,
493        start_date: Option<&str>,
494        interval: &str,
495    ) -> ExchangeResult<Vec<Kline>> {
496        let ticker_symbol = format_crypto_symbol(symbol);
497        let resample_freq = map_interval(interval);
498
499        let mut params = HashMap::new();
500        params.insert("tickers".to_string(), ticker_symbol);
501        params.insert("resampleFreq".to_string(), resample_freq.to_string());
502
503        if let Some(start) = start_date {
504            params.insert("startDate".to_string(), start.to_string());
505        }
506
507        let response = self.get(
508            TiingoEndpoint::CryptoPrices,
509            None,
510            params,
511        ).await?;
512
513        TiingoParser::parse_crypto_prices(&response)
514    }
515
516    /// Get forex top-of-book quote
517    pub async fn get_forex_top(
518        &self,
519        symbol: &Symbol,
520    ) -> ExchangeResult<Ticker> {
521        let ticker_symbol = format_forex_symbol(symbol);
522
523        let response = self.get(
524            TiingoEndpoint::ForexTop,
525            Some(&ticker_symbol),
526            HashMap::new(),
527        ).await?;
528
529        TiingoParser::parse_forex_top(&response)
530    }
531
532    /// Get forex historical prices
533    pub async fn get_forex_prices(
534        &self,
535        symbol: &Symbol,
536        start_date: Option<&str>,
537        interval: &str,
538    ) -> ExchangeResult<Vec<Kline>> {
539        let ticker_symbol = format_forex_symbol(symbol);
540        let resample_freq = map_interval(interval);
541
542        let mut params = HashMap::new();
543        params.insert("resampleFreq".to_string(), resample_freq.to_string());
544
545        if let Some(start) = start_date {
546            params.insert("startDate".to_string(), start.to_string());
547        }
548
549        let response = self.get(
550            TiingoEndpoint::ForexPrices,
551            Some(&ticker_symbol),
552            params,
553        ).await?;
554
555        TiingoParser::parse_forex_prices(&response)
556    }
557}