alphai-tui 0.30.0

Terminal stock dashboard: live quotes and charts plus AI-scored financial news and SEC Form 4 insider activity from AlphAI
//! The single registration point for price sources.
//!
//! To add a backend: implement `DataSource` in a new module under
//! `src/source/`, then append one `SourceInfo` entry to `SOURCES` below.
//! Everything else derives from this table: `--source` parsing and help,
//! the "available:" error list, config `[keys]` resolution, the settings
//! screen picker cycle and its key rows, and the missing-key message. The
//! tests at the bottom keep an entry honest.

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 {
    /// Canonical id: the config value, settings cycle, header display and
    /// CLI value. Must equal what `DataSource::name()` returns.
    pub id: &'static str,
    /// Extra accepted `--source` spellings (matched lowercased).
    pub aliases: &'static [&'static str],
    /// One-line description for the settings row.
    pub hint: &'static str,
    /// What the settings screen says under its rows while the source is
    /// picked: what it covers, how fresh, what it costs.
    pub about: &'static str,
    /// Where its keys come from, as a sentence; empty when keyless.
    pub signup: &'static str,
    /// Credentials in the order `make` receives them; empty = keyless.
    pub key_fields: &'static [KeyField],
    /// Build the source. `keys` holds resolved values in `key_fields`
    /// order; `make_source` checks presence before calling.
    pub make: fn(keys: &[String]) -> Result<Arc<dyn DataSource>>,
    /// HTTP requests one poll cycle spends per symbol.
    pub reqs_per_symbol: u32,
    /// Requests a minute the free plan allows, when the provider documents
    /// one. `rate_warning` compares the watchlist against it; None means
    /// there is no published ceiling to check.
    pub rate_limit_per_min: Option<u32>,
    /// How much of the pre and post market the source sees by itself,
    /// which decides what `extended_source = "auto"` borrows. A function
    /// because Alpaca's answer depends on `ALPACA_FEED`.
    pub coverage: fn() -> Coverage,
}

/// Registry of price sources. Entry 0 is the keyless default: the fallback
/// when neither the CLI nor the config names a source, and the reset target
/// for unknown names in the settings cycle. Order defines the settings
/// cycle and the key rows.
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(),
            )?))
        },
        // Snapshot plus bars, against the Basic plan's ceiling.
        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())?)),
        // One quote request per poll covers the whole watchlist; bars are
        // refreshed at most once a minute per ticker, daily history every
        // fifteen minutes.
        reqs_per_symbol: 1,
        // The free plan allows 50 requests an hour and Power 10,000, and
        // nothing in a key or a response says which one it is on. A
        // ceiling picked for either would be wrong for the other, so none
        // is checked; the refusal itself says what ran out.
        rate_limit_per_min: None,
        coverage: || Coverage::Venue,
    },
];

/// Whether the watchlist and the poll interval together outrun the
/// source's per-minute ceiling, as a message naming the interval that
/// would fit. A poll cycle spends `reqs_per_symbol` per ticker, so a long
/// watchlist on a short interval quietly turns every ticker into an error
/// row; the check runs at startup and in the settings screen, where the
/// interval is edited. `borrows_sip` says the consolidated tape is under
/// the source for pre and post market (see `extended::Provider::Sip`).
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);
    // The tape costs a snapshot and bars per symbol once a minute, on the
    // same key when the price source is Alpaca. Reserve that allowance so
    // the suggested interval also fits the extended session.
    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;
    }
    // The shortest interval that fits, rounded up.
    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
    ))
}

/// Look up a source by id or alias, case-insensitively.
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()))
}

/// Canonical ids, for error messages and docs.
pub fn ids() -> Vec<&'static str> {
    SOURCES.iter().map(|s| s.id).collect()
}

/// `--source` help line, derived so it can never trail the registry.
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")
}

/// How to fix a missing credential, generated from the key fields so a new
/// source cannot forget to write it.
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");
        }
    }

    /// Every entry constructs (with dummy keys) and reports the id it was
    /// registered under; a mismatch would desync the header and the cycle.
    #[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
                );
            }
        }
    }

    /// The guard only speaks up when the watchlist and interval actually
    /// overrun the plan, and it names an interval that does not.
    #[test]
    fn rate_warning_fires_only_over_the_ceiling() {
        let alpaca = find("alpaca").unwrap();
        // Five symbols at two requests each, every 2s: 300 a minute.
        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}");
        // The interval it suggests has to clear the limit.
        assert!(rate_warning(alpaca, 5, 4, true).is_none());
        assert!(rate_warning(alpaca, 5, 15, true).is_none());
        // Without the tape the ten requests it costs are not reserved.
        let own = rate_warning(alpaca, 5, 2, false).expect("300 req/min is over 200");
        assert!(own.contains("300 requests"), "{own}");
        // Finnhub spends one request per symbol against a lower ceiling.
        assert!(rate_warning(find("finnhub").unwrap(), 5, 2, false).is_some());
        assert!(rate_warning(find("finnhub").unwrap(), 2, 2, false).is_none());
        // A source with no published ceiling has nothing to warn about.
        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());
    }
}