A filing gap pauses its issuer until the filing is ingested or a later one supersedes it, which assumes the gap is temporary. It is not always: SEC's per-company Company-Facts files can go stale indefinitely — 43 large caps whose Q2 10-Qs the frames API carries but whose companyfacts files never received (Abbott's newest fact was 2026-04-29 in late August) — and because the supersede rule needs a *successfully ingested* later filing, a stale file swallows the next quarter too. The pause was open-ended, not seasonal. So the pause hands off to the alert: once filing_gap_aged has escalated a gap, it stops gating if the issuer's newest stored 10-K/10-Q is under 180 days old. An issuer with nothing that recent has no usable fundamentals at all and stays paused, which is the case the gate was built for. Applied in the gate service only. active_gaps is deliberately untouched so _retry_backlog keeps retrying and a recovered filing still resolves normally, and the bound covers both gate paths — the queue and the validation_json summary that mirrors the same filings — since bounding one leaves production behaviour unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Lo99z3jWqu9X9ueBU3Z3D
227 lines
10 KiB
Markdown
227 lines
10 KiB
Markdown
# Fundamentals production deployment
|
||
|
||
This is the one-time production setup for the Dolt earnings and SEC fundamentals
|
||
imports. Since A6 (2026-08) these are the *only* fundamentals sources — the
|
||
FMP/Finnhub/Alpha Vantage providers, the weekly legacy collector and the A5 parity
|
||
report are gone, and the cache write path is unconditional. Do not add OS cron
|
||
entries: the application scheduler owns both jobs.
|
||
|
||
## What the deployment adds
|
||
|
||
- `Dolt Earnings Import` runs daily at 02:30 America/New_York.
|
||
- `SEC Fundamentals Import` runs daily at 04:00 America/New_York, then refreshes
|
||
`fundamental_data` — the compat cache scoring reads — from stored snapshots,
|
||
earnings events and closes.
|
||
- Both jobs are visible, toggleable, and manually triggerable in Admin → Jobs.
|
||
**Disabling the SEC job stops its SEC network fetch only**; the local cache
|
||
refresh still runs, because prices and earnings move daily even when no filing
|
||
does.
|
||
- Cron expressions are editable in Admin → Schedule.
|
||
- Every attempt is recorded in `data_import_runs`; failures also create a system
|
||
event. A failed validation does not promote partial data.
|
||
- An SEC filing still missing after the short publication-lag window enters
|
||
`sec_filing_gaps`. The daily importer retries it automatically; affected
|
||
tickers are excluded from actionable setups until a snapshot is recovered or
|
||
a later valid 10-K/10-Q supersedes the gap. Migration `028` materializes older
|
||
promoted gaps into this queue once, so setup reads never scan import history.
|
||
- A gap that survives 14 days raises `filing_gap_aged` and, from that point,
|
||
stops pausing setups **if** the issuer's own newest stored 10-K/10-Q is less
|
||
than `GAP_GATE_RECENT_FILING_DAYS` (180) old. This is the hand-off from pause
|
||
to alert, and it exists because the pause would otherwise be open-ended:
|
||
SEC's per-company Company-Facts files can go stale indefinitely (2026-08: 43
|
||
large caps whose Q2 10-Qs the `frames` API carried but whose
|
||
`companyfacts/CIK*.json` never received), and the supersede rule needs a
|
||
*successfully ingested* later filing, so a stale file swallows the next
|
||
quarter too. Retrying is unaffected — the gap stays queued and a recovered
|
||
filing still resolves it normally. An issuer with no filing that recent has no
|
||
usable fundamentals at all and stays paused.
|
||
|
||
The systemd service uses one application worker. The import framework also holds
|
||
a PostgreSQL advisory lock per source, so an overlapping manual/scheduled run is
|
||
skipped safely.
|
||
|
||
## Prerequisites
|
||
|
||
The production `.env` at `/opt/signalplatform/.env` must contain:
|
||
|
||
```dotenv
|
||
DOLT_BINARY=/usr/local/bin/dolt
|
||
DOLT_DATA_DIR=/var/lib/signal-platform/dolt
|
||
DOLT_EARNINGS_SUBDIR=earnings
|
||
DOLT_MIN_FREE_DISK_GB=5.0
|
||
SEC_USER_AGENT=signal-platform/1.0 (contact: real-address@example.com)
|
||
SEC_REQUEST_SPACING_SECONDS=0.2
|
||
```
|
||
|
||
Use a real monitored contact address. Keep at least 5 GB free at the Dolt data
|
||
path; 8–10 GB gives comfortable growth headroom. The data directory must stay
|
||
outside `/opt/signalplatform`, because deployments use `rsync --delete` there.
|
||
|
||
`FMP_API_KEY`, `FINNHUB_API_KEY` and `ALPHA_VANTAGE_API_KEY` must be **removed**
|
||
from this file. Nothing reads them any more, and leaving them installed is the
|
||
one thing that would let a rolled-back pre-A6 process resume the legacy
|
||
collector and overwrite the SEC/Dolt cache.
|
||
|
||
## One-time provisioning
|
||
|
||
First deploy the commit containing this bundle to production. Then SSH to the
|
||
server and run:
|
||
|
||
```bash
|
||
cd /opt/signalplatform
|
||
sudo bash ./deploy/provision_fundamentals.sh
|
||
sudo -u deploy bash ./deploy/provision_fundamentals.sh --check
|
||
sudo systemctl restart signalplatform.service
|
||
curl -fsS http://127.0.0.1:8998/api/v1/health
|
||
```
|
||
|
||
The provisioner is idempotent. It installs the pinned Dolt version, creates the
|
||
persistent directory as `deploy:deploy`, clones
|
||
`post-no-preference/earnings`, configures a repository-local author identity for
|
||
`dolt pull`, verifies free space and `.env`, and refuses an unexpected
|
||
Dolt version. It does not modify PostgreSQL or start an import. The public clone
|
||
does not require `dolt login`.
|
||
|
||
For a server provisioned before the author-identity check was added, repair the
|
||
existing clone once with:
|
||
|
||
```bash
|
||
sudo -u deploy -H /usr/local/bin/dolt config --global --add user.name "Signal Platform"
|
||
sudo -u deploy -H /usr/local/bin/dolt config --global --add user.email "signal-platform@localhost"
|
||
```
|
||
|
||
Do not replace the pinned version with `latest`. A future Dolt upgrade should be
|
||
a reviewed change to `DOLT_VERSION`, followed by the same provision/check flow.
|
||
|
||
## First-run verification
|
||
|
||
In Admin → Jobs, wait until no other job is running, then:
|
||
|
||
1. Trigger **Dolt Earnings Import**. Expect `completed` with import
|
||
status `promoted`; a repeat without an upstream change should report `no_op`.
|
||
2. Trigger **SEC Fundamentals Import**. The first run performs the
|
||
tracked-universe history backfill and can take materially longer than a daily
|
||
incremental run. Expect `completed` with import status `promoted`.
|
||
3. Check Admin → System Events. There should be no new import error.
|
||
4. Confirm the next-run times correspond to 02:30 and 04:00 New York time.
|
||
5. Open several ticker pages and confirm the fundamentals panel has populated
|
||
data and still handles partial/missing issuers cleanly. A ticker held by the
|
||
quality gate should show **New setups paused** with the specific SEC reason.
|
||
|
||
## Verification
|
||
|
||
Optional database verification:
|
||
|
||
```sql
|
||
SELECT source, status, revision, source_max_date, started_at, completed_at,
|
||
validation_json
|
||
FROM data_import_runs
|
||
WHERE source IN ('dolt_earnings', 'sec_facts')
|
||
ORDER BY id DESC
|
||
LIMIT 10;
|
||
|
||
SELECT count(*) FROM earnings_events WHERE source = 'dolt_earnings';
|
||
SELECT count(*), count(DISTINCT cik) FROM fundamental_snapshots;
|
||
```
|
||
|
||
During the longer first SEC run, execute the following in a second SSH session.
|
||
It opens an independent database connection and attempts the same source lock:
|
||
|
||
```bash
|
||
cd /opt/signalplatform
|
||
sudo -u deploy .venv/bin/python - <<'PY'
|
||
import asyncio
|
||
|
||
from sqlalchemy import text
|
||
|
||
from app.database import engine
|
||
from app.services.data_import import _advisory_key
|
||
|
||
|
||
async def main():
|
||
key = _advisory_key("sec_facts")
|
||
async with engine.connect() as connection:
|
||
acquired = await connection.scalar(
|
||
text("SELECT pg_try_advisory_lock(:key)"), {"key": key}
|
||
)
|
||
print("UNEXPECTED: lock acquired" if acquired else "OK: source lock is busy")
|
||
if acquired:
|
||
await connection.execute(
|
||
text("SELECT pg_advisory_unlock(:key)"), {"key": key}
|
||
)
|
||
|
||
|
||
asyncio.run(main())
|
||
PY
|
||
```
|
||
|
||
Expect `OK: source lock is busy`. This is the remaining live-PostgreSQL
|
||
mutual-exclusion check; SQLite unit tests cannot exercise PostgreSQL advisory
|
||
locks. A second Admin trigger should independently report the job as busy.
|
||
|
||
## The fundamentals cache
|
||
|
||
`fundamental_data` is the compat cache scoring reads. The SEC Fundamentals
|
||
Import rebuilds it every run from data already in PostgreSQL: newest valid
|
||
snapshots x latest close for `pe_ratio` and `market_cap`, snapshots alone for
|
||
`revenue_growth`, and `earnings_events` for `earnings_surprise` and
|
||
`next_earnings_date`. It therefore also runs after an SEC network/validation
|
||
failure, a `no_op`, a source-lock skip, or with the job disabled — no network
|
||
access is involved. The job message appends the cache row count and the changed
|
||
score-input count.
|
||
|
||
A refresh marks affected fundamental and composite score caches stale. The
|
||
normal 15:30 near-close scanner recomputes them before using the rankings; until
|
||
then, reads truthfully expose the stale state.
|
||
|
||
Verify the refreshed rows:
|
||
|
||
```sql
|
||
SELECT count(*) AS rows,
|
||
max(fetched_at) AS refreshed_at,
|
||
count(pe_ratio) AS pe_available,
|
||
count(revenue_growth) AS growth_available,
|
||
count(earnings_surprise) AS surprise_available,
|
||
count(next_earnings_date) AS next_date_available
|
||
FROM fundamental_data;
|
||
|
||
SELECT dimension, is_stale, count(*)
|
||
FROM dimension_scores
|
||
WHERE dimension = 'fundamental'
|
||
GROUP BY dimension, is_stale;
|
||
```
|
||
|
||
## Failure and rollback
|
||
|
||
- **There is no provider fallback any more, and no Admin switch that freezes the
|
||
cache.** Disabling **SEC Fundamentals Import** stops SEC network access only;
|
||
the 04:00 job still rebuilds `fundamental_data` from the stored snapshots,
|
||
earnings events and closes.
|
||
- Restoring `fundamental_data` from the PostgreSQL backup is therefore a
|
||
*temporary* fix on its own: if the bad values come from the snapshots or from
|
||
the derivation code, the next scheduled run reproduces them. Fix the cause —
|
||
restore or repair `fundamental_snapshots` / `earnings_events`, or revert the
|
||
parser change and re-run `scripts/reparse_fundamentals.py --apply`.
|
||
- To genuinely freeze the cache while you work, stop the service
|
||
(`sudo systemctl stop signalplatform.service`) — that stops the scheduler with
|
||
it. There is no finer-grained control, by design: a silently frozen scoring
|
||
input is worse than an obvious outage.
|
||
- Disable a failing source-import job in Admin → Jobs when SEC network access
|
||
itself must stop. Existing promoted snapshots and events remain available, and
|
||
the job's runtime message still reports the cache result.
|
||
- Inspect the job runtime, latest `data_import_runs.validation_json`, service
|
||
logs, and Admin → System Events before retrying.
|
||
- `unresolved_filing` is emitted once when a filing enters automatic retry. It
|
||
does not require a server command. If the gap is still current after 14 days,
|
||
`filing_gap_aged` is emitted once with the CIK, accession, and parser/mapping
|
||
reason. A later valid 10-K/10-Q retires the gap even when the original SEC
|
||
accession never becomes usable.
|
||
- Successful co-registrant recovery is logged without a warning. New registrants
|
||
with no XBRL history are also logged quietly, but their ticker page explains
|
||
that setups remain paused and that successor shells may need `sec_cik_overrides`.
|
||
- Re-run `sudo -u deploy bash ./deploy/provision_fundamentals.sh --check` for
|
||
binary, clone, permission, disk, or environment failures.
|
||
- The Dolt clone is a reproducible cache and does not need a bespoke backup.
|
||
PostgreSQL (including `earnings_events`, `fundamental_snapshots`, and import
|
||
audit rows) must remain covered by the normal production database backup.
|