working api calls
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
use crate::corporate::openfigi::OpenFigiClient;
|
||||
|
||||
// src/corporate/scraper.rs
|
||||
use super::{types::*, helpers::*};
|
||||
use csv::ReaderBuilder;
|
||||
@@ -6,7 +8,7 @@ use scraper::{Html, Selector};
|
||||
use chrono::{DateTime, Duration, NaiveDate, Timelike, Utc};
|
||||
use tokio::{time::{Duration as TokioDuration, sleep}};
|
||||
use reqwest::Client as HttpClient;
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
use zip::ZipArchive;
|
||||
use std::fs::File;
|
||||
use std::{collections::HashMap};
|
||||
@@ -14,15 +16,25 @@ use std::io::{Read, BufReader};
|
||||
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
|
||||
|
||||
/// Discover all exchanges where this ISIN trades by querying Yahoo Finance
|
||||
pub async fn discover_available_exchanges(isin: &str, known_ticker: &str) -> anyhow::Result<Vec<TickerInfo>> {
|
||||
/// Discover all exchanges where this ISIN trades by querying Yahoo Finance and enriching with OpenFIGI API calls.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `isin` - The ISIN to search for.
|
||||
/// * `known_ticker` - A known ticker symbol for fallback or initial check.
|
||||
///
|
||||
/// # Returns
|
||||
/// A vector of FigiInfo structs containing enriched data from API calls.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if HTTP requests fail, JSON parsing fails, or OpenFIGI API responds with an error.
|
||||
pub async fn discover_available_exchanges(isin: &str, known_ticker: &str) -> anyhow::Result<Vec<FigiInfo>> {
|
||||
println!(" Discovering exchanges for ISIN {}", isin);
|
||||
|
||||
let mut discovered_tickers = Vec::new();
|
||||
let mut potential: Vec<(String, PrimaryInfo)> = Vec::new();
|
||||
|
||||
// Try the primary ticker first
|
||||
if let Ok(info) = check_ticker_exists(known_ticker).await {
|
||||
discovered_tickers.push(info);
|
||||
potential.push((known_ticker.to_string(), info));
|
||||
}
|
||||
|
||||
// Search for ISIN directly on Yahoo to find other listings
|
||||
@@ -31,149 +43,267 @@ pub async fn discover_available_exchanges(isin: &str, known_ticker: &str) -> any
|
||||
isin
|
||||
);
|
||||
|
||||
match HttpClient::new()
|
||||
let resp = HttpClient::new()
|
||||
.get(&search_url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let json = resp.json::<Value>().await?;
|
||||
|
||||
if let Some(quotes) = json["quotes"].as_array() {
|
||||
for quote in quotes {
|
||||
// First: filter by quoteType directly from search results (faster rejection)
|
||||
let quote_type = quote["quoteType"].as_str().unwrap_or("");
|
||||
if quote_type.to_uppercase() != "EQUITY" {
|
||||
continue; // Skip bonds, ETFs, mutual funds, options, etc.
|
||||
}
|
||||
|
||||
if let Some(symbol) = quote["symbol"].as_str() {
|
||||
// Avoid duplicates
|
||||
if potential.iter().any(|(s, _)| s == symbol) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Double-check with full quote data (some search results are misleading)
|
||||
if let Ok(info) = check_ticker_exists(symbol).await {
|
||||
potential.push((symbol.to_string(), info));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if potential.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Enrich with OpenFIGI API
|
||||
let client = OpenFigiClient::new()?;
|
||||
|
||||
let mut discovered_figis = Vec::new();
|
||||
|
||||
if !client.has_key() {
|
||||
// Fallback without API key - create FigiInfo with default/empty fields
|
||||
for (symbol, info) in potential {
|
||||
println!(" Found equity listing: {} on {} ({}) - no FIGI (fallback mode)", symbol, info.exchange_mic, info.currency);
|
||||
let figi_info = FigiInfo {
|
||||
isin: info.isin,
|
||||
figi: String::new(),
|
||||
name: info.name,
|
||||
ticker: symbol,
|
||||
mic_code: info.exchange_mic,
|
||||
currency: info.currency,
|
||||
compositeFIGI: String::new(),
|
||||
securityType: String::new(),
|
||||
marketSector: String::new(),
|
||||
shareClassFIGI: String::new(),
|
||||
securityType2: String::new(),
|
||||
securityDescription: String::new(),
|
||||
};
|
||||
discovered_figis.push(figi_info);
|
||||
}
|
||||
return Ok(discovered_figis);
|
||||
}
|
||||
|
||||
// With API key, batch the mapping requests
|
||||
let chunk_size = 100;
|
||||
for chunk in potential.chunks(chunk_size) {
|
||||
let mut jobs = vec![];
|
||||
for (symbol, info) in chunk {
|
||||
jobs.push(json!({
|
||||
"idType": "TICKER",
|
||||
"idValue": symbol,
|
||||
"micCode": info.exchange_mic,
|
||||
"marketSecDes": "Equity",
|
||||
}));
|
||||
}
|
||||
|
||||
let resp = client.get_figi_client()
|
||||
.post("https://api.openfigi.com/v3/mapping")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&jobs)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow::anyhow!("OpenFIGI mapping failed with status: {}", resp.status()));
|
||||
}
|
||||
|
||||
let parsed: Vec<Value> = resp.json().await?;
|
||||
|
||||
for (i, item) in parsed.iter().enumerate() {
|
||||
let (symbol, info) = &chunk[i];
|
||||
if let Some(data) = item["data"].as_array() {
|
||||
if let Some(entry) = data.first() {
|
||||
let market_sec = entry["marketSector"].as_str().unwrap_or("");
|
||||
if market_sec != "Equity" {
|
||||
continue;
|
||||
}
|
||||
println!(" Found equity listing: {} on {} ({}) - FIGI: {}", symbol, info.exchange_mic, info.currency, entry["figi"]);
|
||||
let figi_info = FigiInfo {
|
||||
isin: info.isin.clone(),
|
||||
figi: entry["figi"].as_str().unwrap_or("").to_string(),
|
||||
name: entry["name"].as_str().unwrap_or(&info.name).to_string(),
|
||||
ticker: symbol.clone(),
|
||||
mic_code: info.exchange_mic.clone(),
|
||||
currency: info.currency.clone(),
|
||||
compositeFIGI: entry["compositeFIGI"].as_str().unwrap_or("").to_string(),
|
||||
securityType: entry["securityType"].as_str().unwrap_or("").to_string(),
|
||||
marketSector: market_sec.to_string(),
|
||||
shareClassFIGI: entry["shareClassFIGI"].as_str().unwrap_or("").to_string(),
|
||||
securityType2: entry["securityType2"].as_str().unwrap_or("").to_string(),
|
||||
securityDescription: entry["securityDescription"].as_str().unwrap_or("").to_string(),
|
||||
};
|
||||
discovered_figis.push(figi_info);
|
||||
} else {
|
||||
println!(" No data returned for ticker {} on MIC {}", symbol, info.exchange_mic);
|
||||
}
|
||||
} else if let Some(error) = item["error"].as_str() {
|
||||
println!(" OpenFIGI error for ticker {}: {}", symbol, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Respect rate limit (6 seconds between requests with key)
|
||||
sleep(TokioDuration::from_secs(6)).await;
|
||||
}
|
||||
|
||||
Ok(discovered_figis)
|
||||
}
|
||||
|
||||
/// Check if a ticker exists on Yahoo Finance and return core metadata.
|
||||
///
|
||||
/// This function calls the public Yahoo Finance quoteSummary endpoint and extracts:
|
||||
/// - ISIN (when available)
|
||||
/// - Company name
|
||||
/// - Exchange MIC code
|
||||
/// - Trading currency
|
||||
///
|
||||
/// It strictly filters to only accept **equity** securities.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ticker` - The ticker symbol to validate (e.g., "AAPL", "7203.T", "BMW.DE")
|
||||
///
|
||||
/// # Returns
|
||||
/// `Ok(PrimaryInfo)` on success, `Err` if ticker doesn't exist, is not equity, or data is malformed.
|
||||
///
|
||||
/// # Errors
|
||||
/// - Ticker not found
|
||||
/// - Not an equity (ETF, bond, etc.)
|
||||
/// - Missing critical fields
|
||||
/// - Network or JSON parsing errors
|
||||
pub async fn check_ticker_exists(ticker: &str) -> anyhow::Result<PrimaryInfo> {
|
||||
let url = format!(
|
||||
"https://query1.finance.yahoo.com/v10/finance/quoteSummary/{}?modules=price%2CassetProfile",
|
||||
ticker
|
||||
);
|
||||
|
||||
let resp = match HttpClient::new()
|
||||
.get(&url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if let Ok(json) = resp.json::<Value>().await {
|
||||
if let Some(quotes) = json["quotes"].as_array() {
|
||||
for quote in quotes {
|
||||
// First: filter by quoteType directly from search results (faster rejection)
|
||||
let quote_type = quote["quoteType"].as_str().unwrap_or("");
|
||||
if quote_type.to_uppercase() != "EQUITY" {
|
||||
continue; // Skip bonds, ETFs, mutual funds, options, etc.
|
||||
}
|
||||
|
||||
if let Some(symbol) = quote["symbol"].as_str() {
|
||||
// Avoid duplicates
|
||||
if discovered_tickers.iter().any(|t: &TickerInfo| t.ticker == symbol) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Double-check with full quote data (some search results are misleading)
|
||||
match check_ticker_exists(symbol).await {
|
||||
Ok(info) => {
|
||||
println!(" Found equity listing: {} on {} ({})",
|
||||
symbol, info.exchange_mic, info.currency);
|
||||
discovered_tickers.push(info);
|
||||
}
|
||||
Err(e) => {
|
||||
// Most common: it's not actually equity or not tradable
|
||||
// println!(" Rejected {}: {}", symbol, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Be respectful to Yahoo
|
||||
sleep(TokioDuration::from_millis(120)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to reach Yahoo Finance for ticker {}: {}",
|
||||
ticker,
|
||||
err
|
||||
));
|
||||
}
|
||||
Err(e) => println!(" Search API error: {}", e),
|
||||
}
|
||||
|
||||
// Also try common exchange suffixes for the base ticker
|
||||
if let Some(base) = known_ticker.split('.').next() {
|
||||
let suffixes = vec![
|
||||
"", // US
|
||||
".L", // London
|
||||
".DE", // Frankfurt/XETRA
|
||||
".PA", // Paris
|
||||
".AS", // Amsterdam
|
||||
".MI", // Milan
|
||||
".SW", // Switzerland
|
||||
".T", // Tokyo
|
||||
".HK", // Hong Kong
|
||||
".SS", // Shanghai
|
||||
".SZ", // Shenzhen
|
||||
".TO", // Toronto
|
||||
".AX", // Australia
|
||||
".SA", // Brazil
|
||||
".MC", // Madrid
|
||||
".BO", // Bombay
|
||||
".NS", // National Stock Exchange India
|
||||
];
|
||||
|
||||
for suffix in suffixes {
|
||||
let test_ticker = format!("{}{}", base, suffix);
|
||||
|
||||
// Skip if already found
|
||||
if discovered_tickers.iter().any(|t| t.ticker == test_ticker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(info) = check_ticker_exists(&test_ticker).await {
|
||||
discovered_tickers.push(info);
|
||||
sleep(TokioDuration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(" Found {} tradable exchanges", discovered_tickers.len());
|
||||
Ok(discovered_tickers)
|
||||
}
|
||||
};
|
||||
|
||||
/// Check if a ticker exists and get its exchange/currency info
|
||||
async fn check_ticker_exists(ticker: &str) -> anyhow::Result<TickerInfo> {
|
||||
let url = format!(
|
||||
"https://query1.finance.yahoo.com/v10/finance/quoteSummary/{}?modules=price",
|
||||
ticker
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Yahoo returned HTTP {} for ticker {}", resp.status(), ticker));
|
||||
}
|
||||
|
||||
let json: Value = match resp
|
||||
.json()
|
||||
.await {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to parse JSON response from Yahoo Finance {}: {}",
|
||||
ticker,
|
||||
err
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let result_array = json["quoteSummary"]["result"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'quoteSummary.result' in response"))?;
|
||||
|
||||
if result_array.is_empty() || result_array[0].is_null() {
|
||||
return Err(anyhow::anyhow!("No quote data returned for ticker {}", ticker));
|
||||
}
|
||||
|
||||
let quote = &result_array[0]["price"];
|
||||
let profile = &result_array[0]["assetProfile"];
|
||||
|
||||
// === 1. Must be EQUITY ===
|
||||
let quote_type = quote["quoteType"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_ascii_uppercase();
|
||||
|
||||
if quote_type != "EQUITY" {
|
||||
println!(" → Skipping {} (quoteType: {})", ticker, quote_type);
|
||||
return Err(anyhow::anyhow!("Not an equity security: {}", quote_type));
|
||||
}
|
||||
|
||||
// === 2. Extract basic info ===
|
||||
let long_name = quote["longName"]
|
||||
.as_str()
|
||||
.or_else(|| quote["shortName"].as_str())
|
||||
.unwrap_or(ticker)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let currency = quote["currency"]
|
||||
.as_str()
|
||||
.unwrap_or("USD")
|
||||
.to_string();
|
||||
|
||||
let exchange_mic = quote["exchange"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if exchange_mic.is_empty() {
|
||||
return Err(anyhow::anyhow!("Missing exchange MIC for ticker {}", ticker));
|
||||
}
|
||||
|
||||
// === 3. Extract ISIN (from assetProfile if available) ===
|
||||
let isin = profile["isin"]
|
||||
.as_str()
|
||||
.and_then(|s| if s.len() == 12 && s.chars().all(|c| c.is_ascii_alphanumeric()) { Some(s) } else { None })
|
||||
.unwrap_or("")
|
||||
.to_ascii_uppercase();
|
||||
|
||||
// === 4. Final sanity check: reject obvious debt securities ===
|
||||
let name_upper = long_name.to_ascii_uppercase();
|
||||
if name_upper.contains(" BOND") ||
|
||||
name_upper.contains(" NOTE") ||
|
||||
name_upper.contains(" DEBENTURE") ||
|
||||
name_upper.contains(" PREFERRED") && !name_upper.contains(" STOCK") {
|
||||
return Err(anyhow::anyhow!("Security name suggests debt instrument: {}", long_name));
|
||||
}
|
||||
|
||||
println!(
|
||||
" → Valid equity: {} | {} | {} | ISIN: {}",
|
||||
ticker,
|
||||
long_name,
|
||||
exchange_mic,
|
||||
if isin.is_empty() { "N/A" } else { &isin }
|
||||
);
|
||||
|
||||
let resp = HttpClient::new()
|
||||
.get(&url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let json: Value = resp.json().await?;
|
||||
|
||||
if let Some(result) = json["quoteSummary"]["result"].as_array() {
|
||||
if result.is_empty() {
|
||||
return Err(anyhow::anyhow!("No quote data for {}", ticker));
|
||||
}
|
||||
|
||||
let quote = &result[0]["price"];
|
||||
|
||||
// CRITICAL: Only accept EQUITY securities
|
||||
let quote_type = quote["quoteType"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_uppercase();
|
||||
|
||||
if quote_type != "EQUITY" {
|
||||
// Optional: debug what was filtered
|
||||
println!(" → Skipping {} (quoteType: {})", ticker, quote_type);
|
||||
return Err(anyhow::anyhow!("Not an equity: {}", quote_type));
|
||||
}
|
||||
|
||||
let exchange = quote["exchange"].as_str().unwrap_or("");
|
||||
let currency = quote["currency"].as_str().unwrap_or("USD");
|
||||
let short_name = quote["shortName"].as_str().unwrap_or("");
|
||||
|
||||
// Optional: extra sanity — make sure it's not a bond masquerading as equity
|
||||
if short_name.to_uppercase().contains("BOND") ||
|
||||
short_name.to_uppercase().contains("NOTE") ||
|
||||
short_name.to_uppercase().contains("DEBENTURE") {
|
||||
return Err(anyhow::anyhow!("Name suggests debt security"));
|
||||
}
|
||||
|
||||
if !exchange.is_empty() {
|
||||
return Ok(TickerInfo {
|
||||
ticker: ticker.to_string(),
|
||||
exchange_mic: exchange.to_string(),
|
||||
currency: currency.to_string(),
|
||||
primary: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("Invalid or missing data for {}", ticker))
|
||||
Ok(PrimaryInfo {
|
||||
isin,
|
||||
name: long_name,
|
||||
exchange_mic,
|
||||
currency,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert Yahoo's exchange name to MIC code (best effort)
|
||||
@@ -225,6 +355,31 @@ pub async fn dismiss_yahoo_consent(client: &Client) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetches earnings events for a ticker using a dedicated ScrapeTask.
|
||||
///
|
||||
/// This function creates and executes a ScrapeTask to navigate to the Yahoo Finance earnings calendar,
|
||||
/// reject cookies, and extract the events.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ticker` - The stock ticker symbol.
|
||||
///
|
||||
/// # Returns
|
||||
/// A vector of CompanyEvent structs on success.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the task execution fails, e.g., chromedriver spawn or navigation issues.
|
||||
pub async fn get_earnings_events_task(ticker: &str) -> anyhow::Result<Vec<CompanyEvent>> {
|
||||
let url = format!("https://finance.yahoo.com/calendar/earnings?symbol={}", ticker);
|
||||
let task: ScrapeTask<Vec<CompanyEvent>> = ScrapeTask::new(
|
||||
url,
|
||||
|client| Box::pin(async move {
|
||||
reject_yahoo_cookies(client).await?;
|
||||
extract_earnings(client).await // Assuming extract_earnings is an async fn that uses client
|
||||
}),
|
||||
);
|
||||
task.execute().await
|
||||
}
|
||||
|
||||
pub async fn fetch_earnings_history(client: &Client, ticker: &str) -> anyhow::Result<Vec<CompanyEvent>> {
|
||||
let url = format!("https://finance.yahoo.com/calendar/earnings?symbol={}&offset=0&size=100", ticker);
|
||||
client.goto(&url).await?;
|
||||
|
||||
Reference in New Issue
Block a user