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)