use std::sync::Arc;
use anyhow::Result;
use super::extended::Coverage;
use super::{DataSource, alpaca, finnhub, tiingo, yahoo};
use crate::config::KeyField;
pub struct SourceInfo {
pub id: &'static str,
pub aliases: &'static [&'static str],
pub hint: &'static str,
pub about: &'static str,
pub signup: &'static str,
pub key_fields: &'static [KeyField],
pub make: fn(keys: &[String]) -> Result<Arc<dyn DataSource>>,
pub reqs_per_symbol: u32,
pub rate_limit_per_min: Option<u32>,
pub coverage: fn() -> Coverage,
}
pub static SOURCES: &[SourceInfo] = &[
SourceInfo {
id: "yahoo",
aliases: &["yf", "yfinance"],
hint: "no key needed, ~15 min delayed",
about: "Keyless and about 15 minutes behind, pre and after hours included. Yahoo blocks an IP that asks too often for tens of minutes, so keep the interval long.",
signup: "",
key_fields: &[],
make: |_| Ok(Arc::new(yahoo::Yahoo::new()?)),
reqs_per_symbol: 1,
rate_limit_per_min: None,
coverage: || Coverage::Market,
},
SourceInfo {
id: "finnhub",
aliases: &["fh"],
hint: "real-time quotes, needs a key (free at finnhub.io)",
about: "Real-time quotes, 60 requests a minute on the free plan. No candle history and no pre or after hours of its own: the chart fills in as the session runs.",
signup: "Get one free at finnhub.io.",
key_fields: &[KeyField {
config_name: "finnhub",
env_var: "FINNHUB_API_KEY",
label: "Finnhub key",
}],
make: |keys| Ok(Arc::new(finnhub::Finnhub::new(keys[0].clone())?)),
reqs_per_symbol: 1,
rate_limit_per_min: Some(60),
coverage: || Coverage::Nothing,
},
SourceInfo {
id: "alpaca",
aliases: &["alpc"],
hint: "realtime IEX quotes and real candle history, free keys at alpaca.markets",
about: "Real-time IEX prices and full candle history, 200 requests a minute. IEX is one exchange with a few percent of the volume; the whole market comes 15 minutes late.",
signup: "Get them free at alpaca.markets, paper keys work.",
key_fields: &[
KeyField {
config_name: "alpaca_key_id",
env_var: "APCA_API_KEY_ID",
label: "Alpaca key ID",
},
KeyField {
config_name: "alpaca_secret",
env_var: "APCA_API_SECRET_KEY",
label: "Alpaca secret",
},
],
make: |keys| {
Ok(Arc::new(alpaca::Alpaca::new(
keys[0].clone(),
keys[1].clone(),
)?))
},
reqs_per_symbol: 2,
rate_limit_per_min: Some(200),
coverage: || {
if alpaca::feed() == "iex" {
Coverage::Venue
} else {
Coverage::Market
}
},
},
SourceInfo {
id: "tiingo",
aliases: &["tngo"],
hint: "real-time IEX prices, keys at tiingo.com",
about: "Real-time IEX prices and bars, consolidated daily history, one request a poll for the whole watchlist. IEX barely trades before the open or after the close.",
signup: "Get one at tiingo.com; the free plan allows 50 requests an hour.",
key_fields: &[KeyField {
config_name: "tiingo",
env_var: "TIINGO_API_KEY",
label: "Tiingo key",
}],
make: |keys| Ok(Arc::new(tiingo::Tiingo::new(keys[0].clone())?)),
reqs_per_symbol: 1,
rate_limit_per_min: None,
coverage: || Coverage::Venue,
},
];
pub fn rate_warning(
info: &SourceInfo,
symbols: usize,
every_secs: u64,
borrows_sip: bool,
) -> Option<String> {
let limit = info.rate_limit_per_min?;
let every_secs = every_secs.max(1);
let extra = if info.id == "alpaca" && borrows_sip {
symbols as u64 * 2
} else {
0
};
let per_min = symbols as u64 * info.reqs_per_symbol as u64 * 60 / every_secs + extra;
if per_min <= limit as u64 {
return None;
}
let available = (limit as u64).saturating_sub(extra);
if available == 0 {
return Some(format!(
"{symbols} symbols exceed the {} request/min allowance on {} with extended data: trim the watchlist",
limit, info.id
));
}
let fits = (symbols as u64 * info.reqs_per_symbol as u64 * 60).div_ceil(available);
Some(format!(
"{} symbols every {}s is up to {} requests a minute on {}, over the {} its plan allows: poll every {}s or trim the watchlist",
symbols, every_secs, per_min, info.id, limit, fits
))
}
pub fn find(name: &str) -> Option<&'static SourceInfo> {
let name = name.to_lowercase();
SOURCES
.iter()
.find(|s| s.id == name || s.aliases.contains(&name.as_str()))
}
pub fn ids() -> Vec<&'static str> {
SOURCES.iter().map(|s| s.id).collect()
}
pub fn cli_source_help() -> String {
let names = SOURCES
.iter()
.map(|s| {
if s.key_fields.is_empty() {
format!("{} (keyless)", s.id)
} else {
s.id.to_string()
}
})
.collect::<Vec<_>>()
.join(", ");
format!("Price source: {names}; keyed sources need an API key")
}
pub fn missing_keys_msg(info: &SourceInfo) -> String {
let envs: Vec<&str> = info.key_fields.iter().map(|f| f.env_var).collect();
if envs.len() == 1 {
format!(
"{} needs an API key: set it in the settings screen (s) or export {}=<key>",
info.id, envs[0]
)
} else {
format!(
"{} needs API keys: set them in the settings screen (s) or export {}",
info.id,
envs.join(" / ")
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ALPHAI_KEY_FIELD;
#[test]
fn registry_is_coherent() {
assert!(
SOURCES[0].key_fields.is_empty(),
"entry 0 must be the keyless default"
);
let mut names: Vec<&str> = Vec::new();
let mut config_names: Vec<&str> = Vec::new();
for s in SOURCES {
assert_eq!(s.id, s.id.to_lowercase(), "{}: id must be lowercase", s.id);
assert!(!s.hint.is_empty(), "{}: empty hint", s.id);
assert!(!s.about.is_empty(), "{}: empty about", s.id);
assert_eq!(
s.signup.is_empty(),
s.key_fields.is_empty(),
"{}: a keyed source says where its keys come from, a keyless one does not",
s.id
);
names.push(s.id);
names.extend(s.aliases);
for f in s.key_fields {
assert_ne!(
f.config_name, ALPHAI_KEY_FIELD.config_name,
"{}: 'alphai' is reserved for the news key",
s.id
);
assert!(
!f.env_var.is_empty() && !f.label.is_empty(),
"{}: bare key field",
s.id
);
config_names.push(f.config_name);
}
}
for (list, what) in [
(&mut names, "source id/alias"),
(&mut config_names, "key config_name"),
] {
let before = list.len();
list.sort_unstable();
list.dedup();
assert_eq!(list.len(), before, "duplicate {what} in the registry");
}
}
#[test]
fn every_source_builds_and_reports_its_id() {
for s in SOURCES {
let dummy = vec!["dummy".to_string(); s.key_fields.len()];
let built = (s.make)(&dummy).unwrap_or_else(|e| panic!("{}: make failed: {e:#}", s.id));
assert_eq!(
built.name(),
s.id,
"{}: DataSource::name() != registry id",
s.id
);
}
}
#[test]
fn missing_keys_msg_names_every_env_var() {
for s in SOURCES.iter().filter(|s| !s.key_fields.is_empty()) {
let msg = missing_keys_msg(s);
assert!(msg.contains(s.id), "{msg}");
assert!(msg.contains("settings screen (s)"), "{msg}");
for f in s.key_fields {
assert!(
msg.contains(f.env_var),
"{}: error lacks {}: {msg}",
s.id,
f.env_var
);
}
}
}
#[test]
fn rate_warning_fires_only_over_the_ceiling() {
let alpaca = find("alpaca").unwrap();
let msg = rate_warning(alpaca, 5, 2, true).expect("300 req/min is over 200");
assert!(msg.contains("310"), "{msg}");
assert!(msg.contains("alpaca"), "{msg}");
assert!(msg.contains("poll every 4s"), "{msg}");
assert!(rate_warning(alpaca, 5, 4, true).is_none());
assert!(rate_warning(alpaca, 5, 15, true).is_none());
let own = rate_warning(alpaca, 5, 2, false).expect("300 req/min is over 200");
assert!(own.contains("300 requests"), "{own}");
assert!(rate_warning(find("finnhub").unwrap(), 5, 2, false).is_some());
assert!(rate_warning(find("finnhub").unwrap(), 2, 2, false).is_none());
assert!(rate_warning(find("yahoo").unwrap(), 100, 2, false).is_none());
}
#[test]
fn find_matches_ids_and_aliases_case_insensitively() {
assert_eq!(find("YAHOO").unwrap().id, "yahoo");
assert_eq!(find("yf").unwrap().id, "yahoo");
assert_eq!(find("Alpc").unwrap().id, "alpaca");
assert!(find("nope").is_none());
}
}