Skip to main content

pomelo_fmp/
universe.rs

1//! Universe discovery and symbol-list filters.
2
3use super::config::SyncConfig;
4use super::http::Fetcher;
5use super::industry::flag;
6use super::util::num;
7use super::HttpClient;
8use super::FMP_BASE;
9
10/// The default exchange filter — the three US major exchanges. (AMEX is now
11/// NYSE American, but FMP still labels it `AMEX`.)
12pub const US_EXCHANGES: &str = "NASDAQ,NYSE,AMEX";
13
14/// Filters for [`build_symbol_list`] — a screened market universe.
15#[derive(Default)]
16pub struct SymbolFilter {
17    /// Only symbols at/above this company market cap (`0.0` = no floor).
18    pub min_market_cap: f64,
19    /// Restrict to one or more exchanges (comma-separated FMP codes, e.g.
20    /// [`US_EXCHANGES`]). `None`, an empty string, or `"all"` = every exchange.
21    pub exchange: Option<String>,
22    /// Keep ETFs / funds (default: stocks only).
23    pub include_etf: bool,
24    /// Cap the number of returned symbols (`None` = the API default).
25    pub limit: Option<usize>,
26}
27
28/// Build a screened symbol universe from FMP's screener
29/// (`/stable/company-screener`) — the "establish the sync list first" step so a
30/// whole-market backtest has a persisted, reviewable symbol list to sync. The
31/// filters are pushed to the API *and* re-applied client-side as a safety net.
32/// Returns tickers, sorted and de-duplicated.
33pub fn build_symbol_list<H: HttpClient>(
34    http: &H,
35    api_key: &str,
36    cfg: &SyncConfig,
37    filter: &SymbolFilter,
38) -> Result<Vec<String>, String> {
39    let fetcher = Fetcher::new(http, cfg);
40    let mut url = format!("{FMP_BASE}/stable/company-screener?apikey={api_key}");
41    if filter.min_market_cap > 0.0 {
42        url.push_str(&format!(
43            "&marketCapMoreThan={}",
44            filter.min_market_cap as u64
45        ));
46    }
47    if !filter.include_etf {
48        url.push_str("&isEtf=false&isFund=false");
49    }
50    if let Some(ex) = &filter.exchange {
51        let ex = ex.trim();
52        // Empty / "all" is the escape hatch for every exchange (no filter).
53        if !ex.is_empty() && !ex.eq_ignore_ascii_case("all") {
54            url.push_str(&format!("&exchange={ex}"));
55        }
56    }
57    if let Some(n) = filter.limit {
58        url.push_str(&format!("&limit={n}"));
59    }
60    let rows = fetcher.get_rows(&url)?;
61    let mut syms: Vec<String> = rows
62        .iter()
63        .filter_map(|r| {
64            let obj = r.as_object()?;
65            // Re-apply the screen client-side in case the API ignores a param.
66            if !filter.include_etf && (flag(obj, "isEtf") || flag(obj, "isFund")) {
67                return None;
68            }
69            if filter.min_market_cap > 0.0 {
70                if let Some(mc) = num(obj, &["marketCap", "marketCapitalization"]) {
71                    if mc < filter.min_market_cap {
72                        return None;
73                    }
74                }
75            }
76            obj.get("symbol")?
77                .as_str()
78                .map(str::trim)
79                .filter(|s| !s.is_empty())
80                .map(str::to_string)
81        })
82        .collect();
83    syms.sort();
84    syms.dedup();
85    Ok(syms)
86}
87
88/// Parse a market-cap threshold with an optional magnitude suffix — `k`, `m`,
89/// `b`, `t` (thousand / million / billion / trillion), case-insensitive. Plain
90/// numbers and scientific notation pass through. Examples: `1b` → 1e9,
91/// `500m` → 5e8, `2.5t` → 2.5e12, `1e9` → 1e9, `0` → 0.
92pub fn parse_market_cap(s: &str) -> Result<f64, String> {
93    let s = s.trim();
94    if s.is_empty() {
95        return Err("empty market-cap value".to_string());
96    }
97    let mult = match s.chars().last().unwrap().to_ascii_lowercase() {
98        'k' => 1e3,
99        'm' => 1e6,
100        'b' => 1e9,
101        't' => 1e12,
102        _ => 1.0,
103    };
104    // Strip the suffix only when one matched (ASCII, so 1-byte).
105    let digits = if mult == 1.0 { s } else { &s[..s.len() - 1] };
106    let val: f64 = digits
107        .trim()
108        .parse()
109        .map_err(|_| format!("invalid market cap '{s}' (try 1b, 500m, or a plain number)"))?;
110    if val < 0.0 || !val.is_finite() {
111        return Err(format!("market cap must be a non-negative number: '{s}'"));
112    }
113    Ok(val * mult)
114}
115
116/// Parse a symbols-list file into tickers. One ticker per line; the first
117/// comma-separated field is taken (so a `symbol,...` CSV works), and blank
118/// lines, `#` comments, and a literal `symbol` header are skipped.
119pub fn parse_symbols_list(text: &str) -> Vec<String> {
120    text.lines()
121        .filter_map(|line| {
122            let line = line.trim();
123            if line.is_empty() || line.starts_with('#') {
124                return None;
125            }
126            let first = line.split(',').next()?.trim();
127            if first.is_empty() || first.eq_ignore_ascii_case("symbol") {
128                return None;
129            }
130            Some(first.to_string())
131        })
132        .collect()
133}