541 lines
32 KiB
Markdown
541 lines
32 KiB
Markdown
# Dolt bulk-data integration — implementation plan
|
||
|
||
Status: approved 2026-07-21, revised through four review rounds; direction: KISS
|
||
backend, UI value first. Hand-off document for the implementing agent;
|
||
self-contained.
|
||
|
||
## Objective
|
||
|
||
Replace the free-tier fundamentals APIs (FMP, Finnhub, Alpha Vantage) with bulk
|
||
data: SEC Company Facts for fundamentals, the DoltHub earnings repo for the
|
||
earnings calendar/history, and — later, independently — the DoltHub stocks repo for
|
||
historical OHLCV. PostgreSQL stays the production system of record.
|
||
|
||
**Delivery order: two independent workstreams.**
|
||
|
||
- **Workstream A (build first):** SEC fundamentals + Dolt earnings + API v1 +
|
||
FundamentalsPanel + decommission FMP/Finnhub/Alpha Vantage. Valuation uses the
|
||
existing Alpaca closes already in `ohlcv_records`. This alone achieves the goal
|
||
(killing the quota-limited APIs) and delivers all the UI value.
|
||
- **Workstream B (later, optional until needed):** replace historical OHLCV with
|
||
the Dolt stocks repo. The most complex machinery (4.7 GB clone, split
|
||
adjustment, source-bar table, reconciliation) lives here and blocks nothing in A.
|
||
|
||
**Guiding principle: KISS.** Plain daily importers with staging and atomic
|
||
promotion — no forensic replay, no permanent archive store, no conflict tables, no
|
||
aggregate tables. Engineering budget goes into the UI (quarter trends, peer
|
||
comparison). Deferred until a concrete need: exact source replay, point-in-time
|
||
backtest enforcement, fundamental metrics in scoring.
|
||
|
||
**Non-negotiables**
|
||
|
||
- The application never queries Dolt/DoltHub or SEC at request time. All access is
|
||
batch import → PostgreSQL. If a sync fails or the source is unchanged, production
|
||
continues on the last successfully imported data.
|
||
- Do not replace PostgreSQL with Dolt/Doltgres. Never commit to the upstream clones.
|
||
- No owned SEC Dolt repo: SEC JSON is normalized straight into PostgreSQL.
|
||
- Scoring **code** is unchanged, but swapping the data source changes production
|
||
behavior: `app/services/scoring_service.py` (~line 450) scores pe_ratio /
|
||
revenue_growth / earnings_surprise from `fundamental_data`, so new definitions
|
||
change rankings even with identical code. Cutover of `fundamental_data`
|
||
population requires the **score-parity gate** (phase A5) — never silently. All
|
||
*new* metrics are display-only.
|
||
- Intraday (10:00–15:00), near-close (15:30) and after-close (16:45) pipelines stay
|
||
on Alpaca unchanged.
|
||
|
||
## Data sources
|
||
|
||
1. **SEC Company Facts + submissions bulk files** (free, no key; costs bandwidth,
|
||
CPU and disk — optimize accordingly) — XBRL facts per **issuer (CIK), not per
|
||
ticker**. Tickers resolve to a CIK via SEC `company_tickers.json` (multi-class
|
||
issuers like GOOGL/GOOG share one CIK and one set of fundamentals). Submissions
|
||
also supply the SIC code (peer grouping) and `acceptanceDateTime`. Handle unit
|
||
variants and fiscal-period alignment (derive Q4 = FY − Q1..Q3 where needed).
|
||
**Amendments:** retain every accession immutably; readers select the newest
|
||
valid `accepted_at` snapshot per reporting period at read time. No flags, no
|
||
mutation.
|
||
2. **`post-no-preference/earnings`** (DoltHub) — announcement date, BMO/AMC session
|
||
(partial), period end, EPS estimate/actual, surprise history. Small clone.
|
||
`scripts/import_dolthub_earnings.py` is a research/SQLite importer — reuse its
|
||
normalization/alignment logic (calendar↔EPS-history monotonic alignment, SUE
|
||
scaling) but write a production PostgreSQL importer; do not extend the script.
|
||
3. **`post-no-preference/stocks`** (DoltHub, **workstream B**) — daily raw OHLCV
|
||
(unadjusted), symbol metadata, splits, dividends. Publishes ~01:30 ET the
|
||
following calendar day. Clone is ~4.7 GB.
|
||
|
||
**Licensing (phase A0) — DECIDED 2026-07-22:** `post-no-preference/earnings` is
|
||
**approved for private/internal ingestion under CC BY-SA 4.0**. Conditions the A2
|
||
importer must honor: preserve the upstream license, attribution, and transformation
|
||
notes (retain a CC BY-SA 4.0 reference + attribution to `post-no-preference/earnings`
|
||
and a note of the transformations applied — e.g. in a repo `NOTICE`/attribution file
|
||
and the importer module); **no public API, bulk export, or redistribution** of the
|
||
data; re-review licensing before any public or commercial access. The
|
||
`post-no-preference/stocks` repo (workstream B) is **not** covered here and will be
|
||
reviewed separately if B begins.
|
||
|
||
## Schema
|
||
|
||
**Migration 026 (workstream A)** — current head: `025_trade_setup_scan_run_id`:
|
||
|
||
- `data_import_runs` — lean: id, source (`sec_facts` | `dolt_earnings` |
|
||
`dolt_stocks`), revision (Dolt commit hash, or SEC archive SHA-256), status
|
||
(`running`/`validated`/`promoted`/`no_op`/`failed`), source_max_date, row_counts
|
||
JSON, validation JSON (includes reconciliation/discrepancy summaries — no
|
||
separate conflicts table; details go to structured logs), started_at,
|
||
completed_at, error_details. One run per source at a time (Postgres advisory
|
||
lock keyed by source).
|
||
- `earnings_events` — ticker_id, announce_date, session (`bmo`/`amc`/`unknown`),
|
||
period_end, eps_estimate, eps_actual, source, import_run_id. Unique
|
||
(ticker_id, announce_date). **Rescheduling:** within each promotion transaction,
|
||
delete this source's future-dated rows (announce_date > today) and re-insert
|
||
from the new snapshot, so moved or cancelled dates never linger. Past rows
|
||
(results) are never deleted.
|
||
- `tickers` — add nullable `cik`, `sic`, `sic_description` (from SEC submissions /
|
||
`company_tickers.json`; refreshed by the SEC import; multi-class tickers share
|
||
values). The only ticker↔issuer join point.
|
||
- `fundamental_snapshots` — **CIK-keyed, one immutable row per accession**: cik,
|
||
accession (unique), form, filed_date, **accepted_at** (kept although PIT
|
||
enforcement is deferred — one timestamp now vs painful retrofit later),
|
||
**period_start, period_end, fiscal_year, fiscal_period** (the filing's own
|
||
`dei`/`us-gaap` period identity — required to align non-calendar fiscal years and
|
||
to derive discrete quarters from cumulative facts), and the **price-independent
|
||
raw facts** so metrics are recomputable. **Store facts as the filing reports
|
||
them, not as derived quarters:** duration facts (revenue, net income, diluted EPS,
|
||
CFO, capex, EBITDA inputs) retain the filing's normalized **cumulative YTD/FY**
|
||
value for the (period_start → period_end) span; balance-sheet facts (cash+ST
|
||
investments, total debt, shares outstanding) are **period-end** values.
|
||
``shares_outstanding`` is a point-in-time count
|
||
(``dei:EntityCommonStockSharesOutstanding``), not the weighted-average diluted
|
||
share count — both consumers (est. market cap, YoY dilution) want a
|
||
point-in-time value. **Nothing
|
||
derived is frozen into a row:** discrete quarters (10-Q YTD deltas, Q4 = FY −
|
||
Q1..Q3), TTM, YoY and the four-period metric histories are all computed **at read time** by
|
||
picking the newest valid accepted_at snapshot for *each* required period — so
|
||
non-calendar fiscal years resolve correctly and a later amendment to a prior
|
||
quarter is reflected automatically without ever storing a stale derived quarter.
|
||
Readers pick the newest valid accepted_at per period; history powers the UI
|
||
reference comparisons and deterministic reads.
|
||
- Keep `fundamental_data` (`app/models/fundamental.py`) as the latest-value compat
|
||
cache, repopulated by the daily SEC job — but only after the phase-A5 parity
|
||
gate.
|
||
|
||
**Migration 027 (workstream B, written when B starts):**
|
||
|
||
- `ohlcv_source_bars` — source-truth bar table, required because `ohlcv_records`
|
||
allows one row per (ticker_id, date) (`app/models/ohlcv.py:12`) and Alpaca
|
||
ingestion upserts it in place (`app/services/price_service.py:82`) — Dolt and
|
||
Alpaca bars cannot coexist there. Holds **Dolt raw (unadjusted) bars only** —
|
||
Alpaca bars are already split-adjusted at the provider (`app/providers/alpaca.py:77`
|
||
requests `Adjustment.SPLIT`) and live exclusively in `ohlcv_records`. Columns:
|
||
source (`dolt`), adjustment (`raw` — explicit), ticker_id, date, OHLCV,
|
||
import_run_id; unique (source, ticker_id, date). Changed bars are counted in the
|
||
run's validation JSON and logged before overwrite.
|
||
- `corporate_actions` — ticker_id, type (`split`/`dividend`), ex_date,
|
||
ratio/amount, source, import_run_id. Unique (ticker_id, type, ex_date).
|
||
- `ohlcv_records` — add nullable `import_run_id` FK and `source` text (default
|
||
`'alpaca'`).
|
||
|
||
## Import framework
|
||
|
||
Every importer: idempotent per revision (same Dolt commit / archive checksum →
|
||
`no_op`, zero row changes); stage into a representation outside the live tables
|
||
first (in-memory for the small workstream-A sources; a file/table handle is fine
|
||
if workstream B ever needs it); promotion in one transaction;
|
||
safe to retry; a failed or unchanged run leaves the current dataset untouched.
|
||
Record every attempt in `data_import_runs`.
|
||
|
||
**SEC access requirements (operational safeguards, per SEC fair-access policy):**
|
||
send an identifying `User-Agent` with a contact email on every request; stay far
|
||
below the 10 req/s limit (the bulk endpoints need only a handful of requests per
|
||
run); exponential backoff on 429; a 403 means the User-Agent or request pattern is
|
||
wrong — alert and stop, never retry-loop. See SEC developer resources
|
||
(https://www.sec.gov/about/developer-resources).
|
||
|
||
**Reproducibility scope (deliberately limited):** the normalized snapshots in
|
||
PostgreSQL *are* the durable record. Keep only the last ~2 SEC archives on disk for
|
||
debugging. Byte-level replay of old runs is out of scope until a concrete need.
|
||
|
||
Dolt access: `dolt pull` on the persistent clone, record the resulting commit hash,
|
||
read via `dolt sql -r csv` (no long-running sql-server). **The scheduler shares one
|
||
event loop with the API** (`app/scheduler.py:73`) — run dolt/unzip/download
|
||
subprocesses via `asyncio.create_subprocess_exec` (or an executor), never blocking
|
||
calls. Check free disk space before pulling; alert and skip if below threshold.
|
||
|
||
**Deployment constraints:** deploy is `rsync --delete` of the repo tree
|
||
(`.gitea/workflows/deploy.yml:127`), so clones and archives must live **outside the
|
||
deployment path** — an env-configured persistent directory (e.g.
|
||
`DOLT_DATA_DIR=/var/lib/signal-platform/dolt`). The dolt binary is a new prod
|
||
runtime dependency: install it once with the version-pinned provisioner in
|
||
`deploy/provision_fundamentals.sh`; operational steps are in
|
||
`docs/fundamentals-deployment.md`. The clone is reproducible from DoltHub; the
|
||
normalized PostgreSQL rows remain part of the normal database backup.
|
||
|
||
**Validation gates (block promotion, raise an alert via the existing system-events
|
||
path):** source freshness as expected; tracked-universe coverage; no duplicate
|
||
business keys; fundamental units/periods consistent; row-count deltas within
|
||
reason; upstream schema change stops promotion. Workstream B adds: OHLC sanity
|
||
(high ≥ open/close/low, low ≤ open/close/high, volume ≥ 0); no unexplained split
|
||
discontinuities.
|
||
|
||
**Split adjustment (workstream B):** `ohlcv_source_bars` + `corporate_actions` are
|
||
the source of truth; canonical `ohlcv_records` is *generated* from them to match
|
||
Alpaca `Adjustment.SPLIT`, selecting only adjustment = `raw` rows as input so
|
||
adjustment is applied exactly once. A newly published split rewrites the symbol's
|
||
entire adjusted history — treat whole-symbol rewrites as a normal import event
|
||
(exempt that symbol from the row-count-delta gate for that run) and stamp rows with
|
||
the import_run_id (a backtest↔prod parity guard exists; changed history changes
|
||
backtests).
|
||
|
||
## Scheduling (`app/scheduler.py` — `SCHEDULE_DEFAULTS` / `_CRON_JOBS`, ~line 1451)
|
||
|
||
Follow the existing pattern: cron strings in SystemSettings via
|
||
`app/services/settings_store.py`, day-of-week as names never numbers, logging via
|
||
`_log_event`.
|
||
|
||
Workstream A:
|
||
|
||
- Dolt earnings import: daily ~02:30 ET (with the future-row replacement above).
|
||
- **SEC fundamentals job: daily ~04:00 ET.** One job, three steps:
|
||
(a) detect a composite revision from the latest EDGAR daily-index date, the
|
||
exact tracked index rows, and the tracked-universe fingerprint — an unchanged
|
||
revision is a `no_op` before Company Facts are fetched;
|
||
(b) when changed, fetch and parse Company Facts only for tracked-universe CIKs
|
||
that filed, plus full available history for the first run or a newly added
|
||
issuer, through validation→atomic promotion. Universe resolution and the exact
|
||
index inputs are cached during revision detection and reused during staging;
|
||
CIKs are resolved from `company_tickers.json` without writes until promotion;
|
||
(c) **always, locally, and only after production activation** (the phase-A5
|
||
parity approval): refresh the legacy `fundamental_data` fields and mark affected
|
||
cached fundamental scores stale. **Sources differ per field** — do not assume all
|
||
five come from SEC: `pe_ratio` and `market_cap` from the newest valid snapshots ×
|
||
latest PostgreSQL close, each with its own formula — `pe_ratio` = latest close /
|
||
TTM diluted EPS; `market_cap` = issuer-wide shares outstanding × latest close;
|
||
`revenue_growth` from the snapshots alone; `earnings_surprise` and
|
||
`next_earnings_date` from `earnings_events` (the Dolt earnings feed — these two do
|
||
not exist in SEC facts). Before activation the job imports snapshots only
|
||
(shadow). Step (c) must run identically when SEC is unreachable — prices move
|
||
daily even when filings don't, and the earnings-derived fields already live in
|
||
PostgreSQL.
|
||
**The new API valuation object is not stored anywhere** — it is computed at
|
||
request time (below). No valuation cache or table exists.
|
||
|
||
Workstream B:
|
||
|
||
- Dolt OHLCV+splits pull/import: `0 2 * * tue-sat` ET. If source_max_date is not
|
||
fresh, retry hourly until ~06:00, then give up quietly. After a successful
|
||
import, reconcile the previous session's Dolt-derived bars against the Alpaca
|
||
bars; summary into the run's validation JSON, details to logs.
|
||
- Move `schedule_daily_pipeline_cron` (morning refresh) from `0 2 * * *` to
|
||
`0 3 * * *` (only needed once the 02:00 slot is taken by the OHLCV pull).
|
||
**Late Dolt publication is a non-event:** the canonical scan runs at 15:30 on
|
||
Alpaca, so the morning pipeline runs normally even when the import hasn't
|
||
landed — no gating, no defensive coupling.
|
||
|
||
## Metrics catalog (curated — TTM basis)
|
||
|
||
Snapshots store **price-independent per-period facts** (the "snapshot" column below
|
||
means *derived from stored snapshots, assembled across periods at read time* — see
|
||
Schema — not frozen at import); price-dependent ratios are never frozen into
|
||
snapshots and have **no storage location at all**: the API computes
|
||
them at request time from the stored snapshots + the latest `ohlcv_records` close
|
||
(both already in PostgreSQL, so this works identically when SEC is unreachable).
|
||
The only stored price-dependent values are the legacy `fundamental_data` fields
|
||
that scoring already reads, refreshed daily by step (c) after activation.
|
||
|
||
| Metric | Definition | Where computed |
|
||
|---|---|---|
|
||
| Revenue growth YoY | TTM revenue vs prior TTM | snapshot |
|
||
| EPS growth YoY | TTM diluted EPS vs prior TTM | snapshot |
|
||
| Operating margin + 4q trend | TTM operating income / revenue | snapshot |
|
||
| FCF margin | (TTM CFO − capex) / revenue | snapshot |
|
||
| Net debt | total debt − (cash + ST investments); positive = net debt | snapshot |
|
||
| Net debt / EBITDA | net debt / TTM EBITDA | snapshot |
|
||
| Share count Δ YoY | shares outstanding vs year ago | snapshot |
|
||
| Trailing P/E | price / TTM diluted EPS | request time |
|
||
| FCF yield | TTM FCF / est. market cap | request time |
|
||
| Est. market cap | issuer-wide shares outstanding × ticker price | request time |
|
||
| Earnings surprise history | last 4+ from `earnings_events` | query |
|
||
|
||
**Market cap is an estimate** (issuer-wide shares outstanding × one ticker's price —
|
||
approximate for multi-class issuers). Share count comes from a single consolidated
|
||
value, not a class sum: prefer the one `dei:EntityCommonStockSharesOutstanding`
|
||
cover-page fact; if absent (e.g. Alphabet) fall back to
|
||
`us-gaap:CommonStockSharesOutstanding` at period end. companyfacts is
|
||
non-dimensional, so class-specific facts can't be summed reliably — never do that,
|
||
and never substitute weighted-average/diluted shares; if conflicting values remain,
|
||
store null. Label it "est." in the UI and round aggressively rather than withholding
|
||
it; false precision is the failure mode, not the approximation.
|
||
|
||
**Units follow existing app conventions:** percentages are percentage points
|
||
(21.0 = 21%), P/E and net-debt/EBITDA are multiples, market cap and net debt are
|
||
dollars.
|
||
|
||
Deliberately **excluded**: ROIC (invested-capital/NOPAT normalization too noisy),
|
||
gross margin (COGS tagging too inconsistent), any new composite score.
|
||
|
||
## Peer comparison (read-time only)
|
||
|
||
- Peer group = tracked-universe issuers sharing the **first two SIC digits**,
|
||
**deduplicated by CIK** — GOOG and GOOGL are one issuer, one observation, in
|
||
medians, percentiles and peer_count.
|
||
- Computed at read time from current snapshots — no aggregate tables until
|
||
performance demonstrates a need.
|
||
- Medians exclude null/invalid values. **Fewer than 5 valid peer issuers → omit
|
||
the peer result entirely** rather than showing a misleading universe comparison.
|
||
- Percentile direction respects metric polarity (higher-is-better for FCF yield,
|
||
lower-is-better for P/E and leverage).
|
||
|
||
## API contract (additive v1)
|
||
|
||
Every existing top-level field is preserved unchanged (name, type, position) —
|
||
backend and frontend ship independently, no breaking interval. New objects, exact
|
||
names and types:
|
||
|
||
```jsonc
|
||
{
|
||
// ...all existing legacy fields, unchanged...
|
||
"earnings": {
|
||
"next": {"date": "YYYY-MM-DD", "session": "bmo|amc|unknown", "days_until": 12} | null,
|
||
"recent": [ // newest first, max 4, may be empty
|
||
{"announce_date": "YYYY-MM-DD", "period_end": "YYYY-MM-DD|null",
|
||
"eps_estimate": 1.02|null, "eps_actual": 1.10|null, "surprise_pct": 7.8|null}
|
||
]
|
||
},
|
||
"metrics": [ // fixed row set — every key always present, value null when unavailable
|
||
{
|
||
"key": "revenue_growth_yoy", // revenue_growth_yoy | eps_growth_yoy | operating_margin |
|
||
// fcf_margin | net_debt | net_debt_to_ebitda | share_count_change_yoy
|
||
"value": 18.0, // number | null — pp / multiples / dollars per units above
|
||
"history": [ // oldest→newest, max 4 points, [] when unavailable
|
||
{"period_end": "YYYY-MM-DD", "value": 8.0}
|
||
],
|
||
"industry": { // object | null — null when < 5 valid peer issuers (CIK-deduped)
|
||
"label": "SIC 73 peers", // truthful 2-digit group label — grouping IS 2-digit,
|
||
// so no 4-digit description like "Prepackaged Software"
|
||
"median": 11.0,
|
||
"favorable_percentile": 82, // 0-100, polarity-aware (higher = more favorable)
|
||
"peer_count": 12 // issuers, not tickers
|
||
},
|
||
"period_end": "YYYY-MM-DD|null",
|
||
"filed_date": "YYYY-MM-DD|null",
|
||
"source": "sec|dolt|legacy_api"
|
||
}
|
||
],
|
||
"valuation": { // object | null (null until SEC snapshots exist, phase A3); same industry sub-object rules
|
||
// computed at REQUEST TIME from stored snapshots + latest PostgreSQL close —
|
||
// no valuation cache or table; unaffected by SEC availability
|
||
"pe": 29.2|null, "fcf_yield": 3.8|null,
|
||
"market_cap_est": 1.2e9|null, // estimated — UI labels "est."
|
||
"pe_industry": {...}|null, "fcf_yield_industry": {...}|null,
|
||
"price_date": "YYYY-MM-DD" // close used for the ratios
|
||
}
|
||
}
|
||
```
|
||
|
||
Null/freshness semantics: absent data is `null` with the row still present (the UI
|
||
shows "n/a", never hides rows); every metric carries its own source, period and
|
||
filing date — no panel-wide source label. The objects may serve partial data during
|
||
rollout (e.g. `earnings` live, `metrics` still `legacy_api`); the shape never
|
||
changes.
|
||
|
||
## UI — `frontend/src/components/ticker/FundamentalsPanel.tsx`
|
||
|
||
One distinctive visual device — the **Reference Rails** — in an otherwise restrained
|
||
panel. Preserve the app's dark glass styling and numeric typography.
|
||
|
||
```
|
||
Fundamentals
|
||
Growth accelerating · margins improving · valuation priced above peers
|
||
Next earnings Aug 3 · AMC EPS surprises ▂ ▅ ▃ ▆
|
||
|
||
Operating trend less favorable ← ref → more favorable
|
||
Revenue growth 18%
|
||
───────────────│━━━━● +3pp vs prior · accelerating
|
||
Share count YoY −1.7%
|
||
───────────────│━━━━● buying back
|
||
|
||
Valuation & balance less favorable ← median → more favorable
|
||
P/E 29.2×
|
||
────●━━━━━━━━━━│──── priced above peers · median 23.5× · 12 peers
|
||
```
|
||
|
||
- Growth and margins: horizontal rails compare the latest value with the prior
|
||
quarter or prior-period average; share-count YoY compares with zero. The rail
|
||
is normalized so right is always more favorable, including buybacks.
|
||
- P/E, FCF yield and leverage: horizontal favorable-percentile rails with a
|
||
peer-median marker. No decorative rail when `industry` is null (< 5 peers).
|
||
- Every row keeps the exact value and one deterministic comparison caption;
|
||
missing values render `n/a`, and insufficient peers render `peers n/a`.
|
||
- Earnings: four bars around a shared zero baseline — cyan beats, coral misses, gray
|
||
unavailable — plus next date and BMO/AMC session countdown.
|
||
- Accessibility: color is always paired with text; neutral/ambiguous stays gray;
|
||
rails and earnings bars expose complete ARIA descriptions.
|
||
- Remove the hard-coded "FMP" source label; surface filing and price-date
|
||
provenance in the footer.
|
||
|
||
**Deterministic reads — one shared rule set.** Implement as a single function with
|
||
named constants; the metric reads and the header sentence use identical outputs. No
|
||
LLM, no new composite score. Defaults (tunable constants, not scattered literals):
|
||
|
||
- A series read requires ≥ 3 periods; otherwise show "—" and no read.
|
||
- Growth metrics (pp): latest − prior ≥ +2.0 → "accelerating";
|
||
≤ −2.0 → "decelerating"; else "steady".
|
||
- Margins (latest vs mean of prior periods, pp): ≥ +1.0 → "improving";
|
||
≤ −1.0 → "deteriorating"; else "stable" (phrased "above/below own average"
|
||
where the layout calls for it).
|
||
- Share count YoY: > +1.0% → "N% dilution"; < −1.0% → "buying back"; else "flat".
|
||
- Peer-relative: favorable_percentile ≥ 60 → favorable ("above peers");
|
||
≤ 40 → adverse ("priced above peers" for P/E, "elevated leverage" for
|
||
net-debt/EBITDA); else "in line".
|
||
- Header sentence: join the growth read, margin read and peer-relative valuation
|
||
read with " · ", omitting segments that have no read (e.g. "Growth accelerating
|
||
· margins stable · valuation above industry median"). **Segment sources are
|
||
fixed:** growth = revenue growth read; margins = operating margin read;
|
||
valuation = P/E peer-relative read, falling back to FCF yield when P/E is null.
|
||
This keeps the header unambiguous when sibling metrics (EPS vs revenue growth,
|
||
P/E vs FCF yield) point in different directions.
|
||
|
||
## Decommissioning (end of workstream A)
|
||
|
||
Remove completely: FMP (`app/providers/fmp.py`), Finnhub + Alpha Vantage
|
||
(`app/providers/fundamentals_chain.py`), their config keys (`app/config.py`), and
|
||
their wiring in `app/scheduler.py`, `app/routers/ingestion.py`,
|
||
`app/services/ticker_universe_service.py`. Retain: Alpaca (prices), FRED,
|
||
sentiment provider, Telegram. Note: decommissioning does **not** depend on
|
||
workstream B — Alpaca remains the price source throughout.
|
||
|
||
## Rollout
|
||
|
||
**Workstream A:**
|
||
|
||
- A0. License review **DONE** (earnings approved for private/internal use under
|
||
CC BY-SA 4.0, no redistribution — see Licensing above). The Dolt version,
|
||
persistent `DOLT_DATA_DIR`, clone, and production checks are captured in
|
||
`deploy/provision_fundamentals.sh` and `docs/fundamentals-deployment.md`.
|
||
- A1. Migration 026, import-run framework.
|
||
- A2. Earnings ingestion in shadow (writes `earnings_events`, prod untouched);
|
||
verify forward-calendar coverage and rescheduling behavior.
|
||
- A3. SEC daily job in shadow (writes `fundamental_snapshots`). **Primary technical
|
||
risk here: Q4 derivation and fiscal-period alignment** — non-calendar fiscal years,
|
||
restatements/amendments, and XBRL unit/dimension variants; budget accordingly.
|
||
- A4. API v1 + FundamentalsPanel + peer comparison — served from snapshots and
|
||
earnings_events, independent of the scoring cutover (the additive API supports
|
||
partial data). UI value ships before anything touches scoring inputs.
|
||
- A5. **Score-parity gate** → `fundamental_data` cutover: compute candidate
|
||
pe_ratio/revenue_growth/earnings_surprise from SEC/Dolt side by side with the
|
||
API values across the tracked universe, report per-field deltas and resulting
|
||
fundamental-score/ranking changes, require explicit approval. Definition
|
||
changes (e.g. TTM vs provider convention) called out, not averaged away.
|
||
**Status 2026-07-24: the gate has been exercised and the evidence supports
|
||
approval** — see the handoff section below. Step (c) is implemented behind the
|
||
default-off `fundamental_data_sec_dolt_cutover_enabled` SystemSetting; the
|
||
remaining production action is flipping that switch on and observing it.
|
||
- A6. Remove FMP/Finnhub/Alpha Vantage; keep monitoring + manual fallback.
|
||
|
||
**Workstream B (independent, start when wanted):**
|
||
|
||
- B0. Stocks clone (~4.7 GB) provisioned; migration 027.
|
||
- B1. OHLCV + split adjustment in shadow (writes `ohlcv_source_bars` only; Alpaca
|
||
keeps owning `ohlcv_records`); historical backfill.
|
||
- B2. Reconciliation window (≥ 2 weeks) vs Alpaca; review validation summaries.
|
||
- B3. Promote Dolt as historical OHLCV source (canonical rebuilt from raw source
|
||
bars + splits); morning pipeline → 03:00.
|
||
|
||
## Test plan
|
||
|
||
- Daily SEC job: changed revision imports; unchanged conditional-HTTP check is a
|
||
`no_op` with zero downloads and zero row changes; validation failure leaves
|
||
production untouched; source unavailable still runs the local
|
||
`fundamental_data` refresh (step c, post-activation); before activation the job
|
||
never writes `fundamental_data`.
|
||
- Valuation endpoint returns identical values with SEC reachable and unreachable
|
||
(pure PostgreSQL computation); no valuation rows exist in any table.
|
||
- Multi-class tickers resolve to the same CIK snapshots; peer medians and
|
||
peer_count are CIK-deduplicated (GOOG+GOOGL = one observation).
|
||
- Earnings rescheduling: a moved future date replaces the old row atomically; a
|
||
cancelled date disappears; historical results are never touched.
|
||
- Amendment selection: for a period with multiple accessions, the newest valid
|
||
accepted_at wins at read time; older rows remain unchanged.
|
||
- History arrays are chronological, ≤ 4 points.
|
||
- Percentage-point units stay compatible with existing formatters and scoring
|
||
inputs.
|
||
- Deterministic reads: threshold boundary cases (exactly +2.0pp, exactly 60th
|
||
percentile) resolve per the stated rules; header uses identical outputs and
|
||
falls back from P/E to FCF yield for the valuation segment when P/E is null.
|
||
- Peer comparison disappears below 5 peer issuers; favorable-percentile direction
|
||
correct for both polarities.
|
||
- Workstream B: split-adjusted OHLCV matches Alpaca on representative normal /
|
||
split / reverse-split symbols.
|
||
- UI states: positive, adverse, neutral, insufficient history, insufficient
|
||
peers; mobile layout; non-color accessibility.
|
||
- Unit, integration, scheduler and frontend suites pass.
|
||
|
||
## Acceptance criteria
|
||
|
||
- App works normally with Dolt/DoltHub/SEC unreachable.
|
||
- Re-running the same revision: zero duplicate or changed rows.
|
||
- **Upcoming earnings dates present and timely for the tracked universe** — the
|
||
forward calendar is the hardest thing to replace and gates decommissioning.
|
||
- Coverage meets the tracked-universe target; scheduler runs cleanly with
|
||
FMP/Finnhub/AV keys removed from the environment.
|
||
- Score-parity diff reviewed and approved before `fundamental_data` cutover.
|
||
- Scheduled imports never block the API event loop.
|
||
|
||
## Handoff — remaining work after the A5 parity investigation (2026-07-24)
|
||
|
||
The 2026-07-23 parity report surfaced coverage gaps and wrong values; a nine-pass
|
||
investigation traced every one to parser/identity bugs (not source data), fixed them,
|
||
and reparsed production twice. Full evidence trail:
|
||
`reports/fundamentals-parity-20260723-findings.md` (root causes, decisions, validation)
|
||
plus the before/after reports (`fundamentals-parity-20260723T…` / `…20260724T….json`).
|
||
Post-fix: candidate scores 504 of 511 vs legacy's 507 (gap = PSKY/Q new registrants +
|
||
FITB, all explained); revenue-growth agreement 0.0038 median abs delta where both exist.
|
||
Dennis reviewed the evidence 2026-07-24 and directed proceeding to cutover.
|
||
|
||
**Task 1 — A5 activation (IMPLEMENTED 2026-07-24; production switch remains).** The
|
||
post-activation local refresh of `fundamental_data` derives `pe_ratio` and
|
||
`market_cap` from newest valid snapshots × latest PostgreSQL close, `revenue_growth`
|
||
from snapshots, `earnings_surprise`/`next_earnings_date` from `earnings_events`; mark
|
||
affected cached fundamental scores stale; must run identically when SEC is unreachable.
|
||
It consumes `fundamentals_derivation.derive()` outputs, NOT raw snapshot fields —
|
||
that path carries the split guard (`ttm_diluted_eps`
|
||
nulls when contaminated, with `ttm_diluted_eps_caveat`) and the multi-class share
|
||
fallback (`shares_outstanding` + `shares_outstanding_estimated`). Parity and activation
|
||
share the same candidate builder. Activation is the explicit
|
||
`fundamental_data_sec_dolt_cutover_enabled` SystemSetting and defaults off. It is
|
||
managed by the **Fundamentals data source** card in Admin → Settings; while active,
|
||
the weekly legacy collector skips itself so it cannot overwrite the SEC/Dolt cache.
|
||
See `docs/fundamentals-deployment.md` for the production flip and rollback procedure.
|
||
|
||
**Task 2 — A6 decommissioning.** After a short observation window: remove
|
||
FMP/Finnhub/Alpha Vantage providers, config and env keys; keep monitoring + manual
|
||
fallback. Gated by the acceptance criteria above — especially forward-calendar
|
||
timeliness from `dolt_earnings` (its `source_max_date` ran ~5 weeks ahead as of
|
||
2026-07-23, which passes).
|
||
|
||
**Known caveats to carry (documented in the findings report, not bugs to fix):**
|
||
- KLAC-class post-filing splits: P/E wrong until the next 10-Q; undetectable from
|
||
snapshots. Workstream B's `corporate_actions` table is the natural future fix.
|
||
- BRK-B: no share count exists anywhere in companyfacts → no market cap, correctly.
|
||
- FITB: unscored (split guard + no taggable revenue) — the one name that lost its
|
||
score relative to legacy; composite renormalises.
|
||
- Share-change guard at 25% nulls P/E for stock-funded M&A too (COF, WAT…);
|
||
revisit only if the ~3% universe hit-rate proves painful.
|
||
- `sec_cik_overrides` SystemSetting pins XOM → 34088 (applied in prod); the
|
||
`no_xbrl_filings` SystemEvent says when a new pin is needed.
|
||
- After any future parser change, stored rows need `scripts/reparse_fundamentals.py`
|
||
(dry-run default; `--apply` rewrites) — snapshots are otherwise immutable.
|
||
|
||
## Deferred (explicitly, until a concrete need appears)
|
||
|
||
- Workstream B itself is deferred relative to A and blocks nothing in A.
|
||
- Exact byte-level source replay of historical imports; permanent archive store.
|
||
- Point-in-time backtest enforcement (`accepted_at` is stored now; derivation and
|
||
backtest visibility rules are built only when fundamentals enter
|
||
scoring/backtesting).
|
||
- Fundamental metrics in the score; sector-relative scoring.
|
||
- Aggregate/rollup tables for peer statistics.
|
||
- Any valuation cache or table (request-time computation from snapshots + latest
|
||
close suffices).
|
||
- A dedicated conflicts table (validation JSON + logs suffice).
|