File size: 9,671 Bytes
35676b4 e6496c0 35676b4 e6496c0 35676b4 559c2ff 35676b4 e6496c0 35676b4 e6496c0 35676b4 e6496c0 d1e793b e6496c0 35676b4 097a290 e6496c0 097a290 35676b4 e6496c0 35676b4 7880373 35676b4 559c2ff d1e793b 559c2ff 35676b4 7880373 d1e793b 35676b4 e6496c0 559c2ff e6496c0 7880373 e6496c0 35676b4 e6496c0 35676b4 e6496c0 35676b4 e6496c0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | import sys
from datetime import datetime, timezone
from dotenv import load_dotenv
load_dotenv()
from ingestion.edgar import fetch_all_edgar_data, _extract_guidance
from ingestion.transcript import fetch_transcript
from ingestion.embedder import clear_ticker_data, embed_and_store_filing, embed_and_store_transcript
from ingestion.guidance_parser import parse_guidance
from ingestion.yf_fallback import fill_missing_metrics
from storage.metrics_db import init_db, upsert_metrics, prune_old_metrics, get_all_metrics
from storage.sections_db import init_sections_db, upsert_section, get_section
N_ANNUAL = 3
N_QUARTERLY = 12
def _period_to_av_quarter(period: str) -> str:
"""Convert EDGAR period string to Alpha Vantage quarter format.
EDGAR: "Q42024", "FY2024", "Q12025"
AV: "2024Q4", "2024Q4", "2025Q1"
"""
period = period.strip()
if not period or len(period) < 6:
return ""
if period.startswith("FY"):
fy = period[2:]
if len(fy) == 4 and fy.isdigit():
return f"{fy}Q4"
return ""
if period[0] == "Q" and period[1] in "1234":
fp = period[:2]
fy = period[2:]
if len(fy) == 4 and fy.isdigit():
return f"{fy}{fp}"
return ""
return ""
def ingest(ticker: str, full: bool = False) -> None:
print(f"[ingest] Starting {'FULL' if full else 'delta'} ingestion for {ticker.upper()}")
init_db()
init_sections_db()
print(f"[ingest] Fetching EDGAR data (last {N_ANNUAL} annual + {N_QUARTERLY} quarterly)...")
filings = fetch_all_edgar_data(ticker, n_annual=N_ANNUAL, n_quarterly=N_QUARTERLY)
if not filings:
print(f"[ingest] ERROR: CIK not found for '{ticker}'. Check the ticker is a valid S&P 500 symbol.")
sys.exit(1)
print(f"[ingest] Found {len(filings)} filing(s) for {filings[0].company_name}")
if full:
print("[ingest] --full: clearing existing Chroma data for this ticker...")
clear_ticker_data(ticker.upper())
# Periods already fully resolved (metrics stored + transcript resolved).
# sections.db persists across runs and is the source of truth for transcript resolution:
# None β never attempted (must fetch from AV)
# "" β attempted, none available (skip AV, re-embed not needed)
# text β transcript present (reuse, no AV call)
existing_metric_periods: set[str] = {
m["period"] for m in get_all_metrics(ticker.upper())
}
processed = skipped = 0
for edgar in filings:
label = f"{edgar.form_type} {edgar.period} ({edgar.filing_date})"
# --- Delta check ---
if not full:
transcript_resolved = get_section(edgar.ticker, edgar.period, "transcript")
profile_sections_resolved = (
edgar.form_type != "10-K"
or (
get_section(edgar.ticker, edgar.period, "business") is not None
and get_section(edgar.ticker, edgar.period, "segments_geography") is not None
)
)
if (
edgar.period in existing_metric_periods
and transcript_resolved is not None
and profile_sections_resolved
):
print(f"[ingest] SKIP {label} (already ingested)")
skipped += 1
continue
processed += 1
edgar = fill_missing_metrics(edgar)
print(f"[ingest] Processing {label}...")
guidance = _extract_guidance(edgar.mda_text)
# Fetch transcript for both 10-Q and 10-K (annual Q4 call).
# In delta mode: reuse the cached transcript from sections.db when available,
# so no AV API call is made for already-resolved periods.
transcript = ""
if edgar.form_type in ("10-Q", "10-K"):
quarter = _period_to_av_quarter(edgar.period)
if not quarter:
print(f"[ingest] WARNING: Cannot derive quarter from period '{edgar.period}'. Skipping transcript.")
else:
stored = None if full else get_section(edgar.ticker, edgar.period, "transcript")
if stored is not None:
# Reuse cached value (may be "" if previously unavailable) β 0 AV calls.
transcript = stored
if transcript:
print(f"[ingest] Transcript reused from cache ({len(transcript):,} chars)")
else:
# First time or forced full β fetch from Alpha Vantage.
transcript = fetch_transcript(edgar.ticker, quarter) or ""
if not transcript:
print(f"[ingest] WARNING: No transcript found for {quarter}.")
guidance_struct = parse_guidance(edgar.mda_text, transcript or None, edgar.period)
if guidance_struct.get("guidance_period") or guidance_struct.get("guidance_revenue_low") is not None:
print(f"[ingest] Parsed guidance for {guidance_struct.get('guidance_period')}: "
f"rev=[{guidance_struct.get('guidance_revenue_low')},{guidance_struct.get('guidance_revenue_high')}] "
f"eps=[{guidance_struct.get('guidance_eps_low')},{guidance_struct.get('guidance_eps_high')}]")
upsert_metrics({
"ticker": edgar.ticker,
"period": edgar.period,
"company_name": edgar.company_name,
"filing_date": edgar.filing_date,
"form_type": edgar.form_type,
"revenue": edgar.revenue,
"revenue_yoy_pct": edgar.revenue_yoy_pct,
"eps": edgar.eps,
"gross_margin": edgar.gross_margin,
"operating_margin": edgar.operating_margin,
"free_cash_flow": edgar.free_cash_flow,
"guidance_text": guidance,
"guidance_disclosed": 1 if guidance else 0,
"ingested_at": datetime.now(timezone.utc).isoformat(),
"shares_diluted": edgar.shares_diluted,
"effective_tax_rate": edgar.effective_tax_rate,
"interest_expense": edgar.interest_expense,
"total_debt": edgar.total_debt,
"dividends_paid": edgar.dividends_paid,
"buybacks": edgar.buybacks,
"capex": edgar.capex,
"stockholders_equity": edgar.stockholders_equity,
"period_basis": edgar.period_basis,
"report_date": edgar.report_date,
"accession": edgar.accession,
"source_url": edgar.source_url,
"metric_contexts": edgar.metric_contexts,
"quality_warnings": edgar.quality_warnings,
"data_quality_status": edgar.data_quality_status,
**guidance_struct,
})
# Persist raw section text for cross-period text diffing (analysis/textdiff.py)
if edgar.mda_text:
upsert_section(edgar.ticker, edgar.period, edgar.form_type, "mda", edgar.mda_text)
if edgar.risk_factors_text:
upsert_section(edgar.ticker, edgar.period, edgar.form_type, "risk_factors", edgar.risk_factors_text)
if edgar.form_type == "10-K":
# Store empty results too: this records that the profile sections
# were attempted and prevents endless delta backfills.
upsert_section(edgar.ticker, edgar.period, edgar.form_type, "business", edgar.business_text)
upsert_section(
edgar.ticker,
edgar.period,
edgar.form_type,
"segments_geography",
edgar.segments_geography_text,
)
embed_and_store_filing(
ticker=edgar.ticker,
company_name=edgar.company_name,
mda_text=edgar.mda_text,
risk_text=edgar.risk_factors_text,
filing_date=edgar.filing_date,
period=edgar.period,
form_type=edgar.form_type,
accession=edgar.accession,
source_url=edgar.source_url,
business_text=edgar.business_text,
segments_geography_text=edgar.segments_geography_text,
)
# Always mark transcript resolution β even empty β so future delta runs skip the AV call.
# embed_and_store_transcript is only called when text is non-empty.
if edgar.form_type in ("10-Q", "10-K"):
upsert_section(edgar.ticker, edgar.period, edgar.form_type, "transcript", transcript)
if transcript:
embed_and_store_transcript(
ticker=edgar.ticker,
company_name=edgar.company_name,
transcript_text=transcript,
transcript_date=edgar.filing_date,
period=edgar.period,
source_url="",
provider="alphavantage",
)
print(f"[ingest] Transcript stored ({len(transcript):,} chars)")
# Prune SQLite to configured limits
prune_old_metrics(ticker.upper(), "10-K", N_ANNUAL)
prune_old_metrics(ticker.upper(), "10-Q", N_QUARTERLY)
summary = f"{processed} processed, {skipped} skipped (already up-to-date)"
print(f"[ingest] Done. {filings[0].company_name} ({ticker.upper()}) β {summary}.")
if __name__ == "__main__":
args = sys.argv[1:]
full_flag = "--full" in args
tickers = [a for a in args if not a.startswith("--")]
if len(tickers) != 1:
print("Usage: python ingest.py <TICKER> [--full]")
print(" --full Wipe and re-ingest all periods (fetches all AV transcripts again)")
sys.exit(1)
ingest(tickers[0], full=full_flag)
|