borsa_alphavantage/
lib.rs

1//! Alpha Vantage connector for borsa.
2//!
3//! Provides quotes, history, search, and earnings via the `BorsaConnector` interface.
4//!
5//! Lightweight adapter helpers used in tests are behind the optional
6//! `test-adapters` feature.
7#![warn(missing_docs)]
8use std::sync::Arc;
9
10use async_trait::async_trait;
11
12use borsa_core::{
13    AssetKind, BorsaError, HistoryRequest, HistoryResponse, Instrument, Quote, SearchRequest,
14    SearchResponse,
15    connector::{
16        BorsaConnector, ConnectorKey, /*EarningsProvider,*/ HistoryProvider, QuoteProvider,
17        SearchProvider,
18    },
19};
20
21/// Adapter layer that wraps the `alpha_vantage` client and exposes small async traits.
22pub mod adapter;
23mod convert;
24
25#[cfg(feature = "test-adapters")]
26use adapter::CloneArcAdapters;
27use adapter::{/*AvEarnings,*/ AvHistory, AvQuotes, AvSearch, RealAdapter};
28
29#[cfg(not(feature = "test-adapters"))]
30type AdapterArc = Arc<RealAdapter>;
31
32#[cfg(feature = "test-adapters")]
33type QuotesAdapter = Arc<dyn AvQuotes>;
34#[cfg(not(feature = "test-adapters"))]
35type QuotesAdapter = AdapterArc;
36
37#[cfg(feature = "test-adapters")]
38type HistoryAdapter = Arc<dyn AvHistory>;
39#[cfg(not(feature = "test-adapters"))]
40type HistoryAdapter = AdapterArc;
41
42#[cfg(feature = "test-adapters")]
43type SearchAdapter = Arc<dyn AvSearch>;
44#[cfg(not(feature = "test-adapters"))]
45type SearchAdapter = AdapterArc;
46
47/*
48#[cfg(feature = "test-adapters")]
49type EarningsAdapter = Arc<dyn AvEarnings>;
50#[cfg(not(feature = "test-adapters"))]
51type EarningsAdapter = AdapterArc;
52*/
53
54/// Public connector implementation backed by Alpha Vantage APIs.
55pub struct AvConnector {
56    quotes: QuotesAdapter,
57    history: HistoryAdapter,
58    search: SearchAdapter,
59    /* earnings: EarningsAdapter, */
60}
61
62impl AvConnector {
63    /// Use the native Alpha Vantage API key.
64    pub fn new_with_key(key: impl Into<String>) -> Self {
65        let a = RealAdapter::new_with_key(key);
66        Self::from_adapter(&a)
67    }
68
69    /// Use a `RapidAPI` key for Alpha Vantage.
70    pub fn new_with_rapidapi(key: impl Into<String>) -> Self {
71        let a = RealAdapter::new_with_rapidapi(key);
72        Self::from_adapter(&a)
73    }
74
75    /// Use the native Alpha Vantage API key with an external `reqwest::Client`.
76    pub fn new_with_key_and_client(key: impl Into<String>, http: reqwest::Client) -> Self {
77        let a = RealAdapter::new_with_key_and_client(key, http);
78        Self::from_adapter(&a)
79    }
80
81    /// Use a `RapidAPI` key with an external `reqwest::Client`.
82    pub fn new_with_rapidapi_and_client(key: impl Into<String>, http: reqwest::Client) -> Self {
83        let a = RealAdapter::new_with_rapidapi_and_client(key, http);
84        Self::from_adapter(&a)
85    }
86
87    /// For tests/injection.
88    #[cfg(feature = "test-adapters")]
89    #[must_use]
90    pub fn from_adapter<A: CloneArcAdapters + 'static>(adapter: &A) -> Self {
91        Self {
92            quotes: adapter.clone_arc_quotes(),
93            history: adapter.clone_arc_history(),
94            search: adapter.clone_arc_search(),
95            /* earnings: adapter.clone_arc_earnings(), */
96        }
97    }
98
99    /// Build from a concrete `RealAdapter` by cloning it into shared handles.
100    #[cfg(not(feature = "test-adapters"))]
101    #[must_use]
102    pub fn from_adapter(adapter: &RealAdapter) -> Self {
103        let shared = Arc::new(adapter.clone());
104        Self {
105            quotes: Arc::clone(&shared),
106            history: Arc::clone(&shared),
107            search: Arc::clone(&shared),
108            /* earnings: shared, */
109        }
110    }
111
112    /// Static connector key used in orchestrator priority configuration.
113    pub const KEY: ConnectorKey = ConnectorKey::new("borsa-alphavantage");
114
115    fn looks_like_not_found(msg: &str) -> bool {
116        let m = msg.to_ascii_lowercase();
117        m.contains("invalid api call")
118            || m.contains("no data")
119            || m.contains("not found")
120            || m.contains("unknown symbol")
121            || m.contains("no matches")
122    }
123
124    fn normalize_error(e: BorsaError, what: &str) -> BorsaError {
125        match e {
126            BorsaError::Connector { connector, msg } => {
127                if Self::looks_like_not_found(&msg) {
128                    BorsaError::not_found(what.to_string())
129                } else {
130                    BorsaError::Connector { connector, msg }
131                }
132            }
133            BorsaError::Other(msg) => {
134                if Self::looks_like_not_found(&msg) {
135                    BorsaError::not_found(what.to_string())
136                } else {
137                    BorsaError::connector("borsa-alphavantage", msg)
138                }
139            }
140            other => other,
141        }
142    }
143
144    /// Parse a forex symbol into base and quote currencies.
145    /// Requires explicit delimiters: EUR/USD, BTC/USDT, etc.
146    fn parse_forex_pair(symbol: &str) -> Result<(&str, &str), BorsaError> {
147        // Try to split on common delimiters
148        if let Some(pos) = symbol.find('/') {
149            let base = &symbol[..pos];
150            let quote = &symbol[pos + 1..];
151            if base.is_empty() || quote.is_empty() {
152                return Err(BorsaError::InvalidArg(format!(
153                    "Invalid forex pair format: '{symbol}' - empty base or quote currency"
154                )));
155            }
156            return Ok((base, quote));
157        }
158
159        if let Some(pos) = symbol.find('-') {
160            let base = &symbol[..pos];
161            let quote = &symbol[pos + 1..];
162            if base.is_empty() || quote.is_empty() {
163                return Err(BorsaError::InvalidArg(format!(
164                    "Invalid forex pair format: '{symbol}' - empty base or quote currency"
165                )));
166            }
167            return Ok((base, quote));
168        }
169
170        // No delimiter found - require explicit format
171        Err(BorsaError::InvalidArg(format!(
172            "Forex pair for AlphaVantage must be in 'BASE/QUOTE' format, got: '{symbol}'"
173        )))
174    }
175}
176
177#[async_trait]
178impl QuoteProvider for AvConnector {
179    async fn quote(&self, instrument: &Instrument) -> Result<Quote, BorsaError> {
180        self.quotes
181            .quote_equity(instrument.symbol_str())
182            .await
183            .map_err(|e| Self::normalize_error(e, &format!("quote for {}", instrument.symbol())))
184    }
185}
186
187#[async_trait]
188impl HistoryProvider for AvConnector {
189    async fn history(
190        &self,
191        instrument: &Instrument,
192        req: HistoryRequest,
193    ) -> Result<HistoryResponse, BorsaError> {
194        match instrument.kind() {
195            AssetKind::Forex => {
196                let (base, quote) = Self::parse_forex_pair(instrument.symbol_str())?;
197                self.history.forex(base, quote, &req).await.map_err(|e| {
198                    Self::normalize_error(e, &format!("history for {}", instrument.symbol()))
199                })
200            }
201            AssetKind::Crypto => self
202                .history
203                .crypto(instrument.symbol_str(), &req)
204                .await
205                .map_err(|e| {
206                    Self::normalize_error(e, &format!("history for {}", instrument.symbol()))
207                }),
208            _ => self
209                .history
210                .equity(instrument.symbol_str(), &req)
211                .await
212                .map_err(|e| {
213                    Self::normalize_error(e, &format!("history for {}", instrument.symbol()))
214                }),
215        }
216    }
217
218    fn supported_history_intervals(
219        &self,
220        _kind: AssetKind,
221    ) -> &'static [borsa_core::types::Interval] {
222        use borsa_core::types::Interval as I;
223        const AV_INTERVALS: &[I] = &[
224            I::I1m,
225            I::I5m,
226            I::I15m,
227            I::I30m,
228            I::I1h,
229            I::D1,
230            I::W1,
231            I::M1,
232        ];
233        AV_INTERVALS
234    }
235}
236
237#[async_trait]
238impl SearchProvider for AvConnector {
239    async fn search(&self, req: SearchRequest) -> Result<SearchResponse, BorsaError> {
240        let mut results = self
241            .search
242            .search(&req)
243            .await
244            .map_err(|e| Self::normalize_error(e, "search"))?;
245        if let Some(limit) = req.limit() {
246            results.truncate(limit);
247        }
248        Ok(SearchResponse { results })
249    }
250}
251
252/*
253#[async_trait]
254impl EarningsProvider for AvConnector {
255    async fn earnings(&self, instrument: &Instrument) -> Result<borsa_core::Earnings, BorsaError> {
256        self.earnings
257            .earnings(instrument.symbol_str())
258            .await
259            .map_err(|e| Self::normalize_error(e, &format!("earnings for {}", instrument.symbol())))
260    }
261}
262*/
263
264#[async_trait]
265impl BorsaConnector for AvConnector {
266    fn name(&self) -> &'static str {
267        "borsa-alphavantage"
268    }
269
270    fn vendor(&self) -> &'static str {
271        "Alpha Vantage"
272    }
273
274    // capabilities removed; capability directory via as_*_provider
275
276    fn as_history_provider(&self) -> Option<&dyn borsa_core::connector::HistoryProvider> {
277        Some(self as &dyn HistoryProvider)
278    }
279    fn as_quote_provider(&self) -> Option<&dyn borsa_core::connector::QuoteProvider> {
280        Some(self as &dyn QuoteProvider)
281    }
282    fn as_search_provider(&self) -> Option<&dyn borsa_core::connector::SearchProvider> {
283        Some(self as &dyn SearchProvider)
284    }
285    // Earnings provider unfortunately returns an error from the underlying crate, doesnt seem to be a bug in the connector implementation.
286    /*
287    fn as_earnings_provider(&self) -> Option<&dyn borsa_core::connector::EarningsProvider> {
288        Some(self as &dyn EarningsProvider)
289    }
290    */
291
292    fn supports_kind(&self, kind: AssetKind) -> bool {
293        matches!(
294            kind,
295            AssetKind::Equity | AssetKind::Forex | AssetKind::Crypto
296        )
297    }
298}