use std::collections::HashMap;
use serde_json::Value;
use pomelo_data::{assemble, write_combined_panel, ObjectSink, PANELS_DIR};
use super::factors::consensus_to_rating;
use super::fundamentals::{annual_url, FILING_DATE_KEYS};
use super::http::Fetcher;
use super::util::{iso_to_i32, num};
use super::HttpClient;
use super::FMP_BASE;
use pomelo_data::factors::{analyst_upside_pct, pe_industry_pctile};
pub(crate) const DIRECT_SERIES: &[&str] = &[
"piotroski_score",
"altman_z",
"fcf_yield",
"analyst_upside_pct",
"consensus_rating",
];
const PE_INDUSTRY_PCTILE: &str = "pe_industry_pctile";
fn snapshot_url(endpoint: &str, sym: &str, key: &str) -> String {
format!("{FMP_BASE}/stable/{endpoint}?symbol={sym}&apikey={key}")
}
fn first_row<H: HttpClient>(
fetcher: &Fetcher<H>,
url: &str,
) -> Option<serde_json::Map<String, Value>> {
fetcher
.get_rows(url)
.ok()?
.into_iter()
.next()
.and_then(|v| v.as_object().cloned())
}
fn tail(price_days: &[i32], as_of: i32, value: Option<f64>) -> Vec<(i32, f64)> {
match value {
Some(v) => price_days
.iter()
.copied()
.filter(|&d| d >= as_of)
.map(|d| (d, v))
.collect(),
None => Vec::new(),
}
}
pub(crate) struct SymbolSnapshot {
pub(crate) columns: [Vec<(i32, f64)>; DIRECT_SERIES.len()],
pub(crate) pe: Option<f64>,
pub(crate) report_asof: i32,
}
pub(crate) fn compute_symbol<H: HttpClient>(
fetcher: &Fetcher<H>,
sym: &str,
api_key: &str,
price_days: &[i32],
last_close: f64,
) -> SymbolSnapshot {
let Some(&last_day) = price_days.last() else {
return SymbolSnapshot {
columns: Default::default(),
pe: None,
report_asof: 0,
};
};
let scores = first_row(fetcher, &snapshot_url("financial-scores", sym, api_key));
let piotroski = scores.as_ref().and_then(|o| num(o, &["piotroskiScore"]));
let altman = scores.as_ref().and_then(|o| num(o, &["altmanZScore"]));
let report_asof = first_row(fetcher, &annual_url("income-statement", sym, api_key))
.and_then(|o| {
FILING_DATE_KEYS
.iter()
.find_map(|k| o.get(*k)?.as_str().and_then(iso_to_i32))
})
.unwrap_or(last_day);
let key_metrics = first_row(fetcher, &snapshot_url("key-metrics-ttm", sym, api_key));
let fcf = key_metrics
.as_ref()
.and_then(|o| num(o, &["freeCashFlowYieldTTM"]));
let pe = first_row(fetcher, &snapshot_url("ratios-ttm", sym, api_key))
.as_ref()
.and_then(|o| num(o, PE_KEYS))
.or_else(|| key_metrics.as_ref().and_then(|o| num(o, PE_KEYS)));
let upside = first_row(
fetcher,
&snapshot_url("price-target-consensus", sym, api_key),
)
.and_then(|o| num(&o, &["targetConsensus"]))
.and_then(|target| analyst_upside_pct(target, last_close));
let rating = first_row(fetcher, &snapshot_url("grades-summary", sym, api_key)).and_then(|o| {
o.get("consensus")
.and_then(Value::as_str)
.and_then(consensus_to_rating)
});
SymbolSnapshot {
columns: [
tail(price_days, report_asof, piotroski),
tail(price_days, report_asof, altman),
tail(price_days, report_asof, fcf),
tail(price_days, last_day, upside),
tail(price_days, last_day, rating),
],
pe,
report_asof,
}
}
const PE_KEYS: &[&str] = &["priceToEarningsRatioTTM", "peRatioTTM"];
struct PeInput {
industry: Option<String>,
pe: Option<f64>,
as_of: i32,
price_days: Vec<i32>,
}
pub(crate) struct SnapshotAccum {
symbols: Vec<String>,
columns: Vec<Vec<Vec<(i32, f64)>>>,
pe_inputs: Vec<PeInput>,
}
impl SnapshotAccum {
pub(crate) fn new() -> Self {
SnapshotAccum {
symbols: Vec::new(),
columns: vec![Vec::new(); DIRECT_SERIES.len()],
pe_inputs: Vec::new(),
}
}
pub(crate) fn push(
&mut self,
sym: String,
snap: SymbolSnapshot,
industry: Option<String>,
price_days: &[i32],
) {
self.symbols.push(sym);
for (factor, col) in snap.columns.into_iter().enumerate() {
self.columns[factor].push(col);
}
self.pe_inputs.push(PeInput {
industry,
pe: snap.pe,
as_of: snap.report_asof,
price_days: price_days.to_vec(),
});
}
pub(crate) fn write_panels(&self, store: &impl ObjectSink) -> Result<usize, String> {
let mut written = 0;
for (factor, name) in DIRECT_SERIES.iter().enumerate() {
written += self.write_one(store, name, &self.columns[factor])?;
}
let pe_cols = pe_industry_pctile_columns(&self.pe_inputs);
written += self.write_one(store, PE_INDUSTRY_PCTILE, &pe_cols)?;
Ok(written)
}
fn write_one(
&self,
store: &impl ObjectSink,
name: &str,
per_symbol: &[Vec<(i32, f64)>],
) -> Result<usize, String> {
if per_symbol.iter().all(|c| c.is_empty()) {
eprintln!("{name}: no data across the universe, skipping panel");
return Ok(0);
}
let panel = assemble(&self.symbols, per_symbol).map_err(|e| e.to_string())?;
let bytes = write_combined_panel(&panel).map_err(|e| e.to_string())?;
store
.put(&format!("{PANELS_DIR}/{name}.csv.gz"), &bytes)
.map_err(|e| e.to_string())?;
eprintln!(
"wrote {PANELS_DIR}/{name}.csv.gz ({} symbols)",
self.symbols.len()
);
Ok(1)
}
}
fn pe_industry_pctile_columns(inputs: &[PeInput]) -> Vec<Vec<(i32, f64)>> {
let mut cohorts: HashMap<&str, Vec<f64>> = HashMap::new();
for pin in inputs {
if let (Some(ind), Some(pe)) = (pin.industry.as_deref(), pin.pe) {
if pe.is_finite() && pe > 0.0 {
cohorts.entry(ind).or_default().push(pe);
}
}
}
inputs
.iter()
.map(|pin| match (pin.industry.as_deref(), pin.pe) {
(Some(ind), Some(pe)) => {
let cohort = cohorts.get(ind).map(Vec::as_slice).unwrap_or(&[]);
match pe_industry_pctile(pe, cohort) {
Some(v) => tail(&pin.price_days, pin.as_of, Some(v)),
None => Vec::new(),
}
}
_ => Vec::new(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn pin(industry: Option<&str>, pe: Option<f64>, as_of: i32) -> PeInput {
PeInput {
industry: industry.map(str::to_string),
pe,
as_of,
price_days: vec![20240101, 20240102],
}
}
#[test]
fn pe_industry_pctile_ranks_within_cohort_and_suppresses() {
let inputs = vec![
pin(Some("Software"), Some(10.0), 20240102),
pin(Some("Software"), Some(20.0), 20240102),
pin(Some("Software"), Some(30.0), 20240102),
pin(Some("Software"), Some(40.0), 20240102),
pin(Some("Software"), Some(50.0), 20240102),
pin(Some("Thin"), Some(15.0), 20240102),
pin(None, Some(25.0), 20240102),
pin(Some("Software"), Some(-5.0), 20240102),
];
let cols = pe_industry_pctile_columns(&inputs);
assert_eq!(cols[2], vec![(20240102, 50.0)]);
assert_eq!(cols[0], vec![(20240102, 10.0)]);
assert_eq!(cols[4], vec![(20240102, 90.0)]);
assert!(cols[5].is_empty());
assert!(cols[6].is_empty());
assert!(cols[7].is_empty());
}
#[test]
fn pe_industry_pctile_negative_peers_do_not_join_the_cohort() {
let inputs = vec![
pin(Some("Auto"), Some(10.0), 20240102),
pin(Some("Auto"), Some(20.0), 20240102),
pin(Some("Auto"), Some(30.0), 20240102),
pin(Some("Auto"), Some(40.0), 20240102),
pin(Some("Auto"), Some(-8.0), 20240102),
];
let cols = pe_industry_pctile_columns(&inputs);
assert!(cols.iter().all(|c| c.is_empty()));
}
}