use std::collections::BTreeMap;
use std::path::Path;
use pomelo_data::csv_io::{write_series, OhlcvRow};
use pomelo_data::{LocalSource, ObjectSink, ObjectSource, PRICES_DIR};
use super::config::{SyncConfig, SyncSummary, WriteMode};
use super::fundamentals::sync_fundamentals;
use super::http::Fetcher;
use super::industry::{encode_industry, fetch_profile, load_existing_industry, Profile};
use super::price::{parse_price_rows, price_url, read_existing_prices};
use super::snapshot::{compute_symbol, SnapshotAccum};
use super::HttpClient;
use super::INDUSTRY_KEY;
pub fn sync<H: HttpClient>(
http: &H,
api_key: &str,
symbols: &[String],
out: &Path,
cfg: &SyncConfig,
) -> Result<SyncSummary, String> {
sync_into(http, api_key, symbols, &LocalSource::new(out), cfg)
}
pub fn sync_into<H: HttpClient, S: ObjectSink + ObjectSource>(
http: &H,
api_key: &str,
symbols: &[String],
store: &S,
cfg: &SyncConfig,
) -> Result<SyncSummary, String> {
if api_key.trim().is_empty() {
return Err("empty API key".to_string());
}
if symbols.is_empty() {
return Err("no symbols requested".to_string());
}
if cfg.from > cfg.to {
return Err(format!("from ({}) is after to ({})", cfg.from, cfg.to));
}
let fetcher = Fetcher::new(http, cfg);
let mut summary = SyncSummary::default();
let mut industry: BTreeMap<String, (String, Option<f64>)> = if cfg.include_industry {
load_existing_industry(store)
} else {
BTreeMap::new()
};
let mut snapshots = cfg.include_snapshot_factors.then(SnapshotAccum::new);
let need_profile = cfg.skip_non_stocks
|| cfg.min_market_cap > 0.0
|| cfg.include_industry
|| cfg.include_snapshot_factors;
for sym in symbols {
let price_key = format!("{PRICES_DIR}/{sym}.csv.gz");
if cfg.mode == WriteMode::Resume && store.get(&price_key).ok().flatten().is_some() {
eprintln!("{sym}: already present, skipping (resume)");
summary.symbols_skipped += 1;
continue;
}
let mut profile: Option<Profile> = None;
if need_profile {
match fetch_profile(&fetcher, sym, api_key) {
Ok(Some(p)) => {
if cfg.skip_non_stocks && (p.is_etf || p.is_fund) {
let kind = if p.is_etf { "ETF" } else { "fund" };
eprintln!("{sym}: {kind}, skipping (pass --include-etf to keep)");
summary.symbols_filtered += 1;
continue;
}
if cfg.min_market_cap > 0.0 {
match p.market_cap {
Some(mc) if mc < cfg.min_market_cap => {
eprintln!(
"{sym}: market cap {mc:.0} < {:.0}, skipping",
cfg.min_market_cap
);
summary.symbols_filtered += 1;
continue;
}
None => eprintln!("{sym}: market cap unknown, keeping (cannot screen)"),
_ => {}
}
}
profile = Some(p);
}
Ok(None) if cfg.skip_non_stocks || cfg.min_market_cap > 0.0 => {
eprintln!("{sym}: no profile data, cannot screen (keeping)");
}
Ok(None) => {}
Err(e) => eprintln!("{sym}: profile unavailable, cannot screen (keeping): {e}"),
}
}
eprintln!("{sym}: fetching prices…");
let fetched = match fetcher
.get_rows(&price_url(sym, cfg, api_key))
.map(|rows| parse_price_rows(&rows, cfg))
{
Ok(r) => r,
Err(e) => {
eprintln!("{sym}: price fetch failed: {e}");
summary.failures.push((sym.clone(), e));
continue;
}
};
if fetched.is_empty() {
let msg = "no price rows in range".to_string();
eprintln!("{sym}: {msg}");
summary.failures.push((sym.clone(), msg));
continue;
}
let rows: Vec<OhlcvRow> = if cfg.mode == WriteMode::Append {
let mut by_day = read_existing_prices(store, sym);
for r in fetched {
by_day.insert(r.day, r);
}
by_day.into_values().collect()
} else {
fetched
};
match write_series(&rows).map_err(|e| e.to_string()) {
Ok(bytes) => {
if let Err(e) = store.put(&price_key, &bytes) {
let e = e.to_string();
eprintln!("{sym}: write failed: {e}");
summary.failures.push((sym.clone(), e));
continue;
}
}
Err(e) => {
eprintln!("{sym}: encode failed: {e}");
summary.failures.push((sym.clone(), e));
continue;
}
}
summary.symbols_written += 1;
summary.price_rows += rows.len();
eprintln!("{sym}: wrote {} price rows", rows.len());
let price_days: Vec<i32> = rows.iter().map(|r| r.day).collect();
if cfg.include_fundamentals {
match sync_fundamentals(&fetcher, store, sym, api_key, &price_days) {
Ok(true) => summary.fundamentals_written += 1,
Ok(false) => {}
Err(e) => {
eprintln!("{sym}: fundamentals skipped: {e}");
summary.failures.push((format!("{sym} (fundamentals)"), e));
}
}
}
if let Some(acc) = snapshots.as_mut() {
let last_close = rows.last().map(|r| r.adj_close).unwrap_or(f64::NAN);
let snap = compute_symbol(&fetcher, sym, api_key, &price_days, last_close);
let industry = profile.as_ref().and_then(|p| p.industry.clone());
acc.push(sym.clone(), snap, industry, &price_days);
}
if cfg.include_industry {
match profile
.as_ref()
.and_then(|p| p.sector.as_ref().map(|s| (s.clone(), p.market_cap)))
{
Some((sector, mcap)) => {
industry.insert(sym.clone(), (sector, mcap));
}
None => eprintln!("{sym}: no sector in profile"),
}
}
}
if let Some(acc) = snapshots {
summary.snapshot_factor_panels = acc.write_panels(store).map_err(|e| e.to_string())?;
}
if cfg.include_industry && !industry.is_empty() {
let bytes = encode_industry(&industry).map_err(|e| e.to_string())?;
store.put(INDUSTRY_KEY, &bytes).map_err(|e| e.to_string())?;
summary.industry_written = true;
eprintln!("wrote {} industry rows to {INDUSTRY_KEY}", industry.len());
}
Ok(summary)
}