Spaces:
Sleeping
Sleeping
File size: 10,442 Bytes
5b40465 5a03a83 4ae956f 5a03a83 4ae956f 5b40465 4d76839 5b40465 4ae956f 5b40465 5a03a83 5b40465 4ae956f 5b40465 5a03a83 4ae956f 5a03a83 5b40465 4ae956f 5b40465 4ae956f 5b40465 4ae956f 5b40465 5a03a83 5b40465 5a03a83 5b40465 5a03a83 5b40465 4d76839 5b40465 4d76839 5b40465 | 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 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | #!/usr/bin/env python
"""One-off import of historical consensus EPS from Alpha Vantage.
These records are explicitly weaker evidence than the daily live snapshots:
Alpha Vantage supplies the estimate but no timestamp for it, so nothing here
can prove an estimate predates the release it is compared against. They are
marked capture_kind="backfill_at_report" and are refused by
analytics.consensus.aligned_consensus by design (it filters on
capture_kind == "live_snapshot" before any other test).
Labelling. Alpha Vantage's quarterly entries don't carry this repo's
"Q{n}{YYYY}" period label, and the label can't be derived from the calendar:
this repo's fiscal quarters don't line up with calendar quarters for every
issuer (AAPL and NVDA both run offset fiscal years), so calendar-quarter
arithmetic on fiscalDateEnding mislabels them. Instead each Alpha Vantage
entry's fiscalDateEnding is matched against metrics.report_date for the same
ticker.
NOTE on metrics.report_date: despite the name, this column does NOT hold an
announcement/filing date β it holds the fiscal PERIOD END date (it comes
from SEC's filings.recent.reportDate, see ingestion/edgar.py:68-80). That is
why the join below uses Alpha Vantage's fiscalDateEnding (also a period-end
date) rather than its reportedDate (an announcement date, which sits ~30
days later and essentially never lines up with metrics.report_date β an
earlier version of this script joined on reportedDate and matched 0 of 617
real records; measured drift after switching to fiscalDateEnding was 0-5
days across the six covered tickers).
The join is further restricted to metrics rows whose period looks like
"Q{1-4}{YYYY}" (a real 10-Q row). Alpha Vantage's quarterlyEarnings entries
are quarterly by definition, so an "FY{YYYY}" (10-K/annual) row is never a
valid partner even when its report_date happens to fall inside the match
window β a fiscal Q4 often shares (or nearly shares) its period end with the
fiscal year end, and without this restriction that produces a category
error: a quarterly estimate mislabelled with an annual period. A fiscal Q4
quarter has no 10-Q row in this repo's period scheme at all, so it correctly
finds no partner and keeps the date-string fallback.
- an exact date match, or the nearest one within 7 days (5 measured
maximum across tickers, plus margin for 52/53-week fiscal calendars),
against a QUARTERLY row only, borrows that row's period verbatim
(target_period_source = "matched_metrics_report_date" β kept as-is even
though the column is a period end, not a report date, to stay
consistent with records already written under that source string);
- otherwise the record falls back to Alpha Vantage's own fiscalDateEnding
string as the identifier (target_period_source =
"alphavantage_fiscal_date_ending"). metrics.db holds far fewer rows per
ticker than Alpha Vantage returns quarters, so this is the majority case.
A date string can never collide with a "Q{n}{YYYY}" label, so
aligned_consensus finds no reported row for it and refuses on
"no_reported_period" β but the (estimate, actual, report date) triple is
still preserved in the archive for the calibration backtest, which needs
it more than it needs the label to resolve.
Idempotent: snapshot_id is derived from the OBSERVED identity of the
estimate -- ticker, Alpha Vantage's fiscalDateEnding, metric, provider,
captured_at -- and never from target_period, which is derived and moves.
target_period starts as the fiscalDateEnding string and becomes a
"Q{n}{YYYY}" label as soon as ingest.py adds the matching quarterly row,
so keying identity on it would mint a new id for the same observation at
the next quarterly ingest and double-write it under two labels. Keyed on
the period end instead, a re-run produces identical ids, which the store
skips. Note the consequence: a re-run after an ingest recognises the
record as already present and writes nothing at all, so the archived
target_period stays whatever it was when first written. That is the
correct behaviour for an append-only log -- period_end_date is on every
backfill record, so a consumer that wants the current label joins on
that rather than trusting a label frozen at write time.
"""
from __future__ import annotations
import re
import sys
from datetime import date
from pathlib import Path
from typing import Optional
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dotenv import load_dotenv
load_dotenv()
from ingestion.alphavantage import fetch_earnings
from storage.forecast_store import append_snapshots, snapshot_id
from storage.metrics_db import get_all_metrics, list_tickers
# 7 days: 5 measured maximum drift between Alpha Vantage's fiscalDateEnding
# and metrics.report_date across the six covered tickers, plus margin for
# 52/53-week fiscal calendars.
_MATCH_WINDOW_DAYS = 7
# Only a real 10-Q row is a valid match partner for a quarterly Alpha
# Vantage estimate. "FY{YYYY}" (10-K/annual) rows are excluded even when
# their report_date falls inside the window β see the module docstring.
_QUARTERLY_PERIOD_RE = re.compile(r"^Q[1-4]\d{4}$")
def _parse_date(value: Optional[str]) -> Optional[date]:
if not value:
return None
try:
return date.fromisoformat(str(value)[:10])
except ValueError:
return None
def _match_period(metrics_rows: list[dict], fiscal_date_ending: Optional[str]) -> Optional[str]:
"""Return the quarterly metrics row's period whose report_date (actually
a fiscal period-end date β see the NOTE in this module's docstring)
exactly matches ``fiscal_date_ending``, or, failing that, the nearest
quarterly row within _MATCH_WINDOW_DAYS. Non-quarterly rows (annual
"FY{YYYY}" rows) are never considered. Returns None if no quarterly row
is close enough (or fiscal_date_ending is missing)."""
target = _parse_date(fiscal_date_ending)
if target is None:
return None
best_period: Optional[str] = None
best_diff: Optional[int] = None
for row in metrics_rows:
period = row.get("period")
if not period or not _QUARTERLY_PERIOD_RE.match(period):
continue
row_date = _parse_date(row.get("report_date"))
if row_date is None:
continue
diff = abs((row_date - target).days)
if diff == 0:
return period
if diff <= _MATCH_WINDOW_DAYS and (best_diff is None or diff < best_diff):
best_diff = diff
best_period = period
return best_period
def _target_period(
metrics_rows: list[dict], fiscal_date_ending: str
) -> tuple[str, str]:
"""Return (target_period, target_period_source)."""
matched = _match_period(metrics_rows, fiscal_date_ending)
if matched is not None:
return matched, "matched_metrics_report_date"
return fiscal_date_ending, "alphavantage_fiscal_date_ending"
def backfill(ticker: str) -> tuple[int, int, int]:
"""Import historical consensus EPS for one ticker.
Returns (written, matched_count, fallback_count)."""
# fetch_earnings returns (data, error), not a bare dict.
payload, error = fetch_earnings(ticker)
if not payload:
print(f" {ticker}: no earnings payload β {error}", file=sys.stderr)
return 0, 0, 0
metrics_rows = get_all_metrics(ticker)
records = []
matched_count = 0
fallback_count = 0
for quarter in payload.get("quarterlyEarnings", []):
estimated = quarter.get("estimatedEPS")
ending = quarter.get("fiscalDateEnding")
reported_date = quarter.get("reportedDate")
if estimated in (None, "None", "") or not ending:
continue
try:
value = float(estimated)
except (TypeError, ValueError):
continue
period, source = _target_period(metrics_rows, ending)
if source == "matched_metrics_report_date":
matched_count += 1
else:
fallback_count += 1
# captured_at is the report date: the latest moment this estimate could
# have been observed. It is deliberately NOT treated as pre-release.
captured_at = reported_date or ending
records.append({
# Identity comes from fiscalDateEnding (stored below as
# period_end_date), NOT from target_period. target_period is
# DERIVED: it is this same date string until a matching quarterly
# row lands in metrics.db, at which point it becomes
# "Q{n}{YYYY}". ingest.py adds such rows every quarter, so an id
# keyed on the label would change under a re-run and append the
# same observation a second time under the other name -- the
# duplication Ruling 13 had to clean by hand. fiscalDateEnding is
# an observed provider fact and never moves, which leaves
# target_period free to improve without forking the record.
"snapshot_id": snapshot_id(
ticker, ending, "eps", "alphavantage", captured_at
),
"ticker": ticker.upper(),
"target_period": period,
"target_period_source": source,
"period_end_date": ending,
"metric": "eps",
"value": value,
"basis": "adjusted",
"currency": "USD",
"period_basis": "quarter",
"provider": "alphavantage",
"provider_period_code": None,
"captured_at": captured_at,
"capture_kind": "backfill_at_report",
})
written = append_snapshots(records)
print(
f" {ticker}: {written} new of {len(records)} historical quarters "
f"({matched_count} matched, {fallback_count} fallback)"
)
return written, matched_count, fallback_count
def main() -> int:
tickers = list_tickers()
if not tickers:
print("no covered tickers found", file=sys.stderr)
return 1
total = 0
total_matched = 0
total_fallback = 0
for t in tickers:
written, matched_count, fallback_count = backfill(t)
total += written
total_matched += matched_count
total_fallback += fallback_count
print(
f"\nbackfilled {total} records "
f"({total_matched} matched, {total_fallback} fallback)"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
|