SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%
5.3 KB · 115 lines python
Raw Blame History
1#!/usr/bin/env python32"""Fundamental screener + enrichment with point-in-time notes.34  screen.py --filters "pe<15,fcf_yield>0.06" [--sort fcf_yield:desc] [--as-of 2020-03-20] [--limit 25]5            [--quarters 3] [--no-enrich] [--out screen.csv]6  screen.py --tickers AAPL MSFT --quarters 4          # enrich only7"""8from __future__ import annotations910import argparse11import sys12from datetime import date13from pathlib import Path1415import pandas as pd1617sys.path.insert(0, str(Path(__file__).parent))18import hfmd  # noqa: E4021920KEY_RATIOS = ["pe", "pb", "ev_ebitda", "fcf_yield", "dividend_yield", "roe", "roa", "gross_margin", "operating_margin", "net_margin", "debt_to_equity", "current_ratio", "revenue_growth", "eps_growth", "market_cap"]21INCOME_COLS = ["period_end", "fiscal_period", "revenue", "operating_income", "net_income", "eps_diluted", "filed_at", "form"]222324def pit_note(q: pd.DataFrame) -> str:25    if q.empty or "filed_at" not in q:26        return "no quarterly statements available"27    q = q.copy()28    q["filed_at"] = pd.to_datetime(q["filed_at"], errors="coerce")29    q["period_end"] = pd.to_datetime(q.get("period_end"), errors="coerce")30    latest = q.sort_values("filed_at").iloc[-1]31    lag = (latest["filed_at"] - latest["period_end"]).days if pd.notna(latest["filed_at"]) and pd.notna(latest["period_end"]) else None32    age = (pd.Timestamp(date.today()) - latest["filed_at"]).days if pd.notna(latest["filed_at"]) else None33    return (f"latest period {latest['period_end'].date() if pd.notna(latest['period_end']) else '?'} became public on "34            f"{latest['filed_at'].date() if pd.notna(latest['filed_at']) else '?'} ({lag} days after period end; {age} days ago)"35            + (" — a newer quarter has ended but is not filed yet" if age is not None and age > 100 else ""))363738def main() -> int:39    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)40    ap.add_argument("--filters", help='e.g. "pe<15,roe>0.15,fcf_yield>0.06"')41    ap.add_argument("--sort", help="metric:asc|desc")42    ap.add_argument("--as-of", help="point-in-time date YYYY-MM-DD")43    ap.add_argument("--limit", type=int, default=25)44    ap.add_argument("--tickers", nargs="*", help="skip the screener, enrich these tickers")45    ap.add_argument("--quarters", type=int, default=3)46    ap.add_argument("--no-enrich", action="store_true")47    ap.add_argument("--out")48    a = ap.parse_args()49    if not a.filters and not a.tickers:50        ap.error("--filters or --tickers required")5152    hits = pd.DataFrame()53    if a.filters:54        try:55            hits, meta = hfmd.screener(a.filters, a.sort, a.as_of, a.limit)56        except hfmd.HfmdError as e:57            print(e, file=sys.stderr)58            if e.status == 404:59                print("→ the fundamentals module is not deployed on this server yet (v2 rollout). Nothing to screen.", file=sys.stderr)60                return 261            return 162        print(f"screen `{a.filters}`{' as of ' + a.as_of if a.as_of else ''}: {meta.get('count', len(hits))} match(es)"63              + (f" (universe {meta['universe']})" if 'universe' in meta else ""))64        if hits.empty:65            return 066        with pd.option_context("display.width", 200, "display.max_columns", 30, "display.float_format", "{:.3f}".format):67            print(hits.head(a.limit).to_string(index=False))68        tickers = list(hits["ticker"].head(a.limit)) if "ticker" in hits else []69    else:70        tickers = [t.upper() for t in a.tickers]7172    if a.no_enrich or not tickers:73        if a.out and not hits.empty:74            hits.to_csv(a.out, index=False)75            print(f"wrote {a.out}")76        return 07778    if not hfmd.API_KEY and len(tickers) > 8:79        hfmd.log(f"keyless mode: enriching {len(tickers)} tickers needs {2 * len(tickers)} requests (30/h quota) — set HFMD_API_KEY or use --limit 8")8081    enriched = []82    for t in tickers:83        print(f"\n=== {t} ===")84        try:85            r = hfmd.ratios(t)86            line = "  ".join(f"{k}={r[k]:.3g}" for k in KEY_RATIOS if isinstance(r.get(k), (int, float)))87            print("ratios:", line or r)88            if r.get("as_of") or r.get("period_end"):89                print(f"  based on period {r.get('period_end')} · filed {r.get('filed_at')} · price as of {r.get('as_of')}")90        except hfmd.HfmdError as e:91            r = {}92            print(f"ratios: {e}")93        try:94            q = hfmd.statements(t, "income", "quarterly", a.quarters)95            cols = [c for c in INCOME_COLS if c in q.columns] or list(q.columns)[:8]96            with pd.option_context("display.width", 200, "display.float_format", "{:,.0f}".format):97                print(q[cols].to_string(index=False) if not q.empty else "  no statements")98            print("  point-in-time:", pit_note(q))99        except hfmd.HfmdError as e:100            q = pd.DataFrame()101            print(f"statements: {e}")102        enriched.append({"ticker": t, **{k: r.get(k) for k in KEY_RATIOS}, "latest_filed_at": (q["filed_at"].max() if "filed_at" in q else None)})103104    if a.out:105        out = pd.DataFrame(enriched)106        if not hits.empty and "ticker" in hits:107            out = hits.merge(out, on="ticker", how="left", suffixes=("", "_latest"))108        out.to_csv(a.out, index=False)109        print(f"\nwrote {a.out}")110    return 0111112113if __name__ == "__main__":114    sys.exit(main())115