Compare commits

Author SHA1 Message Date
dennisthiessen 6f1ee450f1 research: prepare effective risk floor ab 2026-08-05 22:49:50 +02:00
dennisthiessen aa6cd5cac4 docs: record portfolio capacity findings 2026-08-05 22:23:11 +02:00
Dennis Thiessen 24482c62fe results added 2026-08-05 21:39:29 +02:00
dennisthiessen 6fc82ae857 fix: isolate production universe in capacity research 2026-08-05 21:00:19 +02:00
Dennis Thiessen 23fe39fd78 results added 2026-08-05 20:33:16 +02:00
dennisthiessen 477aa4b2da fix: support legacy research snapshots on macOS 2026-08-05 10:40:45 +02:00
dennisthiessen e58d2bb2cf docs: clarify control parity accounting 2026-08-05 08:56:53 +02:00
dennisthiessen 1ace6688dd research: add focused portfolio capacity matrix 2026-08-05 08:28:30 +02:00
dennisthiessenandClaude Opus 5 07d864cf64 fix: draw the trade chart for positions older than the window
Deploy / lint (push) Successful in 10s
Deploy / test (push) Successful in 1m20s
Deploy / deploy (push) Successful in 39s
TradeChart shows a fixed 21-bar window. Once a trade is older than that,
its entry bar precedes the window and entryIdx goes negative, so the price
and trail paths index past the start of series/stopPath and emit NaN
coordinates -- the browser then drops both paths entirely, leaving only the
horizontal level lines. Clamp the index to the left edge and drop the entry
marker when the entry bar is outside the window; the full-width entry line
already carries it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:12:32 +02:00
dennisthiessen 2435abacaf refactor: simplify open position details
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m13s
Deploy / deploy (push) Successful in 38s
2026-08-04 12:23:57 +02:00
dennisthiessenandClaude Opus 5 d29d603158 test: drop redundant scanner primary-target suites
Deploy / lint (push) Successful in 29s
Deploy / test (push) Successful in 1m23s
Deploy / deploy (push) Successful in 42s
test_rr_scanner_bug_exploration.py and test_rr_scanner_fix_check.py both
assert one invariant: the headline target is the probability-based near
level, not the far max-R:R lottery. That is already covered directly by
test_recommendation_service.py's _select_primary_target tests, which also
reach cases these never did (empty list, probability floor, activation vs
scanner floor), and end to end by test_rr_scanner_integration.py's
full-flow test -- a strict superset of their deterministic cases: three
resistance and three support levels, both directions, plus persistence
and rr_ratio consistency.

The two files also duplicated each other, and their docstrings had gone
stale: test_deterministic_long_three_levels documented a hand-computed
_compute_quality_score winner even though the assertion is about the
probability primary that supersedes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:11:26 +02:00
dennisthiessenandClaude Opus 5 70157ccfc2 perf: reuse the test schema instead of rebuilding it per test
The autouse _setup_db fixture ran create_all + drop_all for every test in
the suite, including the many that never open a session. That cycle costs
~49ms against these 22 tables; truncating them instead costs ~6ms for the
same guarantee of an empty database per test.

Build the schema once, then delete every row before each subsequent test.
No model sets sqlite_autoincrement, so SQLite reuses rowids after a full
delete and generated ids still restart at 1.

Measured over 874 tests, deterministic order: 138.6s -> 74.5s (~46%).
Verified green under pytest-randomly's default random ordering as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:11:20 +02:00
dennisthiessen f49b422095 fix: close remaining ingestion review gaps 2026-08-04 08:55:30 +02:00
dennisthiessen 59ac108c90 perf: scope SEC ticker quality checks 2026-08-04 08:09:37 +02:00
dennisthiessen e0f3d43efb fix: tighten max-hold session countdown 2026-08-04 08:07:10 +02:00
dennisthiessen 4c0c0579f5 fix: make SEC quality gating terminal-safe 2026-08-04 07:58:18 +02:00
dennisthiessen d1caac86b5 fix: preserve OHLCV stale detection 2026-08-04 07:39:40 +02:00
dennisthiessen d431ee283d feat: show max-hold session countdown 2026-08-03 23:55:30 +02:00
dennisthiessen 3a6900d45a fix: gate setups on SEC filing completeness 2026-08-03 23:47:07 +02:00
dennisthiessen 7d703ea524 fix: refresh same-day OHLCV bars 2026-08-03 23:13:17 +02:00
dennisthiessen 7bcdf77ef9 fix(sec): name aged filings in deferred warnings
Deploy / lint (push) Successful in 10s
Deploy / test (push) Successful in 2m3s
Deploy / deploy (push) Successful in 42s
2026-07-31 14:58:22 +02:00
dennisthiessen c8c660e63d fix(sec): warn when deferred imports stay stale 2026-07-31 13:27:31 +02:00
dennisthiessen f58f8b0818 fix(sec): defer expected Company Facts lag without alerting 2026-07-31 12:40:29 +02:00
dennisthiessenandClaude Opus 5 862d1d536b Keep the missing-weekday note honest about market holidays
Deploy / lint (push) Successful in 10s
Deploy / test (push) Successful in 1m49s
Deploy / deploy (push) Successful in 42s
A weekday with no published index is usually just a market holiday
(~10/yr), not a fault. The WARNING still earns its place as the guard
against inferring missing from an error code, but the comment should
not claim more than it can.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:39:46 +02:00
dennisthiessenandClaude Opus 5 5b4fdab85c Stop reading an absent SEC daily index as a fair-access block
The fundamentals import has been dead since 2026-07-25, alerting
SecForbiddenError on form.20260725.idx with "set a real sec_user_agent
contact email". The User-Agent was never the problem.

www.sec.gov/Archives is served from an S3 bucket with no ListBucket
grant, so a MISSING key cannot answer 404 — it returns 403 with S3's
AccessDenied XML. SEC publishes a daily index for business days only, so
2026-07-25 (a Saturday) is simply absent. _get mapped every 403 to the
fatal SecForbiddenError, which made daily_index's `except
SecNotFoundError` unreachable for the exact case it was written for:
the first weekend an incremental walk crossed killed the run, and
last_processed never advanced past Friday.

Latent until activation, not a change at SEC: with no promoted run the
importer takes the backfill path and makes zero daily_index calls, so
the walk was first exercised by the first incremental run.

Verified live 2026-07-30: Sat/Sun 403 with AccessDenied XML while Fri
(51 rows) and Mon (26 rows) return 200 on the same UA; a genuine
rejection is instead the WAF's text/html "Undeclared Automated Tool"
page, served even for files that exist. So the downgrade to "missing" is
gated on all three: the /Archives/ prefix, an XML content type, and
S3's own error code. Every other 403 still alerts and stops.

Missing weekday indexes now log at WARNING — if a rejection page were
ever misread as absent, the importer must not advance past real filings
quietly.

No state to reset: _last_processed_index_date reads promoted runs only,
so the next run walks 2026-07-25..29, skipping the weekend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:37:26 +02:00
dennisthiessenandClaude Opus 5 a7aefa6fe7 Recover SEC facts misfiled under a co-registrant CIK
Deploy / lint (push) Successful in 10s
Deploy / test (push) Successful in 1m47s
Deploy / deploy (push) Successful in 39s
The fundamentals import had been failing for three days on two tracked
filings the index listed but Company Facts appeared not to have. They were
not lagging: SEC filed the XBRL of NEE's and DOW's 2026-07-24 combined
parent/subsidiary 10-Qs under the co-registrant's CIK (Florida Power &
Light, Dow Chemical), so the ticker-carrying filer's own facts file never
receives that accession. This does not self-correct - an NEE filing
misattributed the same way in 2014 is still misfiled.

Because source_max_date advances only on a promoted run, the failure was
self-perpetuating: every later run re-walked the same index day and re-hit
the same two filings.

- Recover from the co-registrant file. The daily index lists every
  co-registrant of an accession, which is the only pointer to where the
  facts actually landed. Rows are re-stamped to the real filer, since
  parse_snapshots stamps the CIK of the payload it read.
- Guard the recovery with a share-count continuity check against the
  issuer's own history, so a subsidiary's standalone facts can never be
  stored as the parent's. No history, no recovery.
- Bound the blocking: a filing still unresolvable after
  MISSING_XBRL_RETRY_DAYS promotes with a named unresolved_filing warning
  instead of wedging every later import.
- Name the offending filings in the alert and record them in
  validation_json, separating not_in_companyfacts from not_in_submissions.
  The gate previously reported a count and discarded the accessions.

Verified against live SEC data: both filings recover with the correct CIK
and the guard rejects a mismatched reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:52:59 +02:00
dennisthiessenandClaude Opus 5 d2a27d4a78 Fix F541 lint failure in the event study summary
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m47s
Deploy / deploy (push) Successful in 38s
A conditional clause in the summary sentence carried an f prefix with no
placeholders, failing `ruff check app/` and blocking the deploy. Literal only;
the rendered text is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:29:08 +02:00
dennisthiessenandClaude Opus 5 83c0555e52 Event study: report its own statistical limits
Deploy / lint (push) Failing after 8s
Deploy / test (push) Skipped
Deploy / deploy (push) Skipped
The v3 cutover run scored 2/4 corrections warned against v2's 3/4, which reads
like a regression and is not one. Only 4 of the 11 detected corrections fall in
the holdout, so recall is one event from a different headline -- and the event
that flips is decided by threshold placement, not by what the score saw. "v3
without the credit sensor" catches 2025-02-21 at a *higher* threshold (35.5)
than shipped v3 misses it at (32.3), because the alarm rule needs a rising edge
and a lower threshold can fire outside the horizon then never reset below.

Two caveats are now computed and surfaced rather than left for the reader to
infer:

- Holdout event count against MIN_EVENTS_FOR_CONFIDENCE. The summary sentence
  states how many of the detected corrections actually fall in the test period.
- Warning-sensor coverage across the split. The score renormalises over what is
  available, so a training window predating a sensor's history freezes the
  threshold on a different construct than the holdout is measured against. At
  the cutover that is 39% of training sessions with all three sensors versus
  100% of the test period, credit history beginning 2023-07-25.

Restricting the threshold to sensor-matched training sessions was tested and
rejected: those sessions are a calm recent stretch, so the threshold falls from
32.3 to 22.5 and false alarms rise from 3.3 to 8.6/yr. It swaps a coverage bias
for a regime-selection bias. The report states its limits instead.

_warning_series now returns per-session sensor counts alongside the scores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:12:53 +02:00
dennisthiessenandClaude Opus 5 019ca1342a Rewrite Regime Monitor as v3: fundamentals off the score, desaturate P3
Deploy / lint (push) Successful in 11s
Deploy / test (push) Successful in 1m43s
Deploy / deploy (push) Successful in 38s
The LLM-sourced capex/earnings observations carried 12+8 of 100 Warning points,
so both pegged at 100 produced a Warning of 20.0 -- below the event study's 25.3
alarm threshold and still inside the "stable" band. The reading was
arithmetically incapable of changing anything on screen, which is why refreshing
it appeared to do nothing. They are now a qualitative overlay reported beside
the scores rather than diluted into them.

Calibrated against the 408 v2 sessions to 2026-07-24, reproduced offline from
Alpaca + FRED; the harness matched the stored prod distribution exactly before
any parameter was changed.

State:
- P3 used dd_pct * 5, reaching 100 at a 20% drawdown -- the 90th percentile of
  the observed distribution -- so 39/408 sessions sat at exactly 100 with no
  resolution left during the part of a selloff that matters most. Replaced with
  anchored breakpoints keeping headroom past the observed 36% maximum, blended
  2:1 like P1/P2 instead of max(). P3's realized share of State falls from 65%
  to 40%, matching its nominal weight.
- Credit level is now anchors-only. ICE capped FRED's BAMLH0A0HYM2 at a rolling
  3-year window in April 2026, silently turning the 10-year percentile leg into
  a 3-year one that scored 20 points of stress at an OAS of 3.5 -- the level its
  own anchors call "mild". The anchors already encode the long-run distribution.

Warning:
- Added HY OAS 20-session widening (25%). The level is pinned at zero below the
  3.5 anchor; its rate of change is not.
- Divergence tapers to a 0.35 floor instead of a hard price_ret >= 0 gate, which
  zeroed the sensor through every decline: on 2026-07-24 the basket shed 10
  points of participation in 20 sessions and Warning printed exactly 0.
- The event study and the live monitor now share one sensor definition, so they
  cannot silently drift apart.

Bands are per axis (State 20/50/80, Warning 20/40/60) with quadrant dividers at
50/40; v2 Warning never exceeded 64.9 against a shared 60, leaving that half of
the quadrant unreachable. Realized shares: State 73/15/8/3%, Warning 69/20/8/3%.

Snapshots now record credit_history_days and vix_history_days -- the percentile
defect went unnoticed for months because nothing asserted the window the code
claimed.

Cutover: the first run rebuilds 400 sessions automatically; the Event Study job
must be re-run, as its cached report self-invalidates on the methodology check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:36:57 +02:00
dennisthiessen 49bf3b140e Add Admin control for fundamentals cutover
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m48s
Deploy / deploy (push) Successful in 40s
2026-07-24 16:15:51 +02:00
dennisthiessen b0537ebe9a Implement A5 fundamentals cutover activation
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m46s
Deploy / deploy (push) Successful in 36s
2026-07-24 14:19:22 +02:00
dennisthiessenandClaude Opus 4.8 3df36a9bfb docs(dolt-plan): record A5 gate outcome and hand off remaining work
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m39s
Deploy / deploy (push) Successful in 38s
The parity gate has been exercised: nine-pass investigation, fixes, two prod
reparses, and an after-report at 504/511 candidate coverage with agreement
unchanged. What remains of workstream A is the activation itself (step c, the
post-approval fundamental_data refresh — not yet implemented) and A6
decommissioning, both now specified in a handoff section with the caveats the
next implementer must carry (KLAC splits, BRK-B, FITB, the 25% guard, CIK
overrides, reparse-after-parser-changes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:32:53 +02:00
dennisthiessenandClaude Opus 4.8 98793925ef Merge fix/sec-fundamentals-parity-gaps: post-reparse verification docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:32:12 +02:00
dennisthiessenandClaude Opus 4.8 2e083d3bb9 docs(sec): post-reparse verification and cutover recommendation
Closes the parity investigation: prod reparse runs 6+7 reconciled against the
dry run, the collision check that caught the BEN regression (and its residue --
36 historical 53-week-drift rows, deliberately left), and the 2026-07-24 parity
report diffed against the 2026-07-23 baseline. Candidate coverage 482 -> 504 of
511 with the gap fully explained (PSKY/Q new registrants, FITB guard-tripped
with no revenue), agreement unchanged where both sides exist, and the remaining
deltas are documented definition differences. Includes the after-report and a
correction to the seventh pass (FITB loses its score, not just one input).
Recommends approving the A5 cutover with KLAC as the one carried caveat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:29:13 +02:00
dennisthiessen 4135946cb3 Merge pull request 'fix(sec): derive fiscal year end from the issuer's own 10-K' (#3) from fix/sec-fundamentals-parity-gaps into main
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m49s
Deploy / deploy (push) Successful in 1m0s
Reviewed-on: #3
2026-07-24 12:01:20 +02:00
dennisthiessenandClaude Opus 4.8 3d42ca7241 fix(sec): derive fiscal year end from the issuer's own 10-K
Regression found by the post-reparse collision check. _period_identity trusted
submissions.fiscalYearEnd, which is not reliable: Franklin Resources (BEN)
declares 1231 while every one of its 10-Ks ends 09-30.

The effect was data loss, not just a bad label. BEN's real fiscal Q1 (Dec 31)
sat 0 days from the claimed year end, matching no quarter band, so it fell back
to SEC's fy/fp; its fiscal Q2 (Mar 31) computed 275 days out and was labelled
Q1. Both landed on the same key, the collision discarded one, and BEN lost TTM
EPS and revenue growth entirely — values it had before this branch.

A 10-K's reportDate IS the fiscal year end by definition, so resolve_fiscal_
year_end() now prefers the issuer's most recent annual filing and treats the
declared value as a fallback for issuers with no 10-K in the set.

Scanned the full tracked universe: 2 of 506 issuers declare a year end more
than 21 days from their own 10-K — BEN (91d, broken) and DELL (29d, mislabelled
but functionally correct). Both now derive correctly and match the legacy
provider: BEN revenue growth 3.8243 vs 3.82, DELL 38.5735 vs 38.57. Controls
(AAPL, COST, PEP, DPZ, IRM, JPM, CRM, STX, AVY) byte-identical.

826 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 11:59:15 +02:00
dennisthiessen 77fa8b8c65 Merge pull request 'Fix/sec fundamentals parity gaps' (#2) from fix/sec-fundamentals-parity-gaps into main
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 2m31s
Deploy / deploy (push) Successful in 41s
Reviewed-on: #2
2026-07-24 10:58:16 +02:00
dennisthiessenandClaude Opus 4.8 0e556d8a43 fix(sec): keep the market-cap fallback through an amendment merge
Found in review. _merge_amendments rebuilds a period from _MERGED_FIELDS +
_CARRIED_FIELDS alone, so a column in neither list is absent from the merged
row, not just stale — and callers read it with getattr(..., None), which
silently yields None. weighted_avg_diluted_shares was never added when the
market-cap fallback landed (_SNAPSHOT_COLS in the importer was updated, its
counterpart in the derivation was not).

The failure needed both of this branch's fixes at once: a multi-class issuer
with a partial amendment on its latest period (META with a Part-III-only
10-K/A) would silently lose market cap and FCF yield again.

Adds the field, a regression test for that case, and a guard test asserting
the merge/carry lists cover every SnapshotRow field, so the next column added
fails loudly rather than losing data quietly. Confirmed the guard catches the
original bug.

Also from review:
- Expose pe_caveat in the valuation payload, so a P/E suppressed by split
  contamination says why instead of looking like missing data (the caveat was
  set but never read).
- no_xbrl_filings now names both causes; the old text advised pinning a CIK
  override, which is wrong for a genuine new registrant that simply has not
  filed yet and clears itself.
- Document that fiscalYearEnd is the issuer's current calendar, so a fiscal-
  year-end change degrades old periods (fallback/newest-wins), not current ones.
- Parser-level tests for _select_weighted_avg_shares (shortest-span-wins and
  concept priority), which only had derivation-level coverage.

823 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 10:50:17 +02:00
dennisthiessenandClaude Opus 4.8 fae621475b docs(sec): fundamentals parity root-cause findings
Nine-pass investigation of the 2026-07-23 A5 parity report: for each coverage
gap and wrong value, the root cause traced against live SEC company facts, the
fix, and its live-data validation. Also records the decisions taken (weighted-
average share fallback, keep ASC-606 revenue basis, basic-EPS fallback, keep the
25% split-guard threshold) and what remains genuinely unfixable from this data
(KLAC post-filing split, BRK-B dimensional share count). Includes the source
parity report the findings analyse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 10:24:18 +02:00
dennisthiessenandClaude Opus 4.8 e54f03cba6 feat(sec): reparse path, CIK overrides, resolution validation
Operational plumbing to land the parser fixes and to make silent resolution
failures visible.

- Reparse: SecFundamentalsImporter(reparse=True) restages every accession with
  the current parser and rewrites the ones that now reconstruct differently,
  writing the full column set so a row is never half old-parse. Snapshots stay
  immutable with respect to SEC; the stored row is our reconstruction, and after
  a parser fix keeping it is a stale cache, not history. run_import(force=True)
  bypasses the unchanged-revision no-op, since the staleness is on our side, not
  the source's. Exposed as scripts/reparse_fundamentals.py, dry-run by default.
- CIK overrides: sec_universe reads a {symbol: cik} pin from
  SystemSetting['sec_cik_overrides'], applied ahead of company_tickers.json, for
  when SEC maps a ticker to a successor shell with no filings (XOM -> a zero-
  filing "ExxonMobil Holdings Corp" while every 10-K/Q is under CIK 34088).
- Resolution validation: a tracked issuer resolving to a registrant with no XBRL
  filings now records no_xbrl_filings and raises a warning naming the CIKs and
  the override setting, instead of silently yielding nothing on every run.
- _diff_fields compares datetime instants, not representations: accepted_at
  round-trips naive from SQLite but tz-aware from Postgres, which otherwise made
  a reparse of identical data report every row as changed (and false-positived
  the pre-existing discrepancy warning).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 10:24:05 +02:00
dennisthiessenandClaude Opus 4.8 921f3d06fb fix(sec): correct fundamentals derivation from SEC company facts
The A5 parity report surfaced coverage gaps and wrong values that all traced
to the SEC facts parser and read-time derivation rather than to bad source
data. Fixes, each validated by replaying the production parser + derivation
against live company facts:

- Period identity is derived from period_end against the issuer's fiscal
  calendar, not SEC's fy/fp fields, which collide (two period ends on one key,
  one silently discarded) and invert (a period sorting before one that precedes
  it) often enough to break the quarter chain. Recovers BXP, CRM, CRWD, FRT,
  MTD, NTAP, PPL, STX, WDAY. Fixed labels are internal ordering keys only (not
  in any API schema), so a filer whose year ends in early January shifting by
  one is harmless.
- Revenue concept list gains RevenuesNetOfInterestExpense (banks) and the
  IncludingAssessedTax variant (REITs/consumer); EPS gains the continuing-ops
  variant (REG/FCX) and, last, basic EPS for a period tagging no diluted
  variant at all (PPL). All appended, so any issuer that already resolved keeps
  its concept.
- YTD span tolerance 20 -> 25 days, covering 4-4-5 retail calendars whose
  36-week YTD-Q3 (251-252d) previously missed by ~2 (COST, PEP, DPZ).
- Amendment resolution is per field: a partial 10-K/A (Part III only, no
  financial facts) no longer blanks the period (DVN).
- TTM diluted EPS is suppressed when a split contaminates the trailing window
  (BKNG's mixed-unit sum produced a P/E of 1.10 that clamped to a perfect
  fundamental sub-score). A post-filing split with no share-count evidence
  (KLAC) remains undetectable from this data.
- Multi-class share fallback: weighted_avg_diluted_shares is captured and used
  for market cap when the cover-page count is absent (dimensional, so missing
  from company facts for META/CMCSA/CHTR/FOXA/NWSA/LEN). Within ~0.6% of the
  true count on controls; flagged shares_estimated in the API. BRK-B has no
  weighted-average fact either and stays unavailable.

820 unit tests pass; new tests confirmed to fail against the pre-fix code.
Effect is inert until existing rows are reparsed (see reparse path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 10:23:51 +02:00
dennisthiessen 259001e419 Merge branch 'docs/dolt-plan-clarifications'
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m45s
Deploy / deploy (push) Successful in 39s
2026-07-23 21:18:45 +02:00
dennisthiessen ce8c60d957 feat: add fundamentals parity reporting 2026-07-23 21:17:58 +02:00
dennisthiessen 361cfd7883 docs: record fundamentals research decision and clean up 2026-07-23 17:50:33 +02:00
Dennis Thiessen dba7ea739b tests done 2026-07-23 17:37:43 +02:00
dennisthiessen 34d6dda1ab feat: add split-safe fundamentals research protocol 2026-07-23 17:27:28 +02:00
Dennis Thiessen 7f944d718f tests done 2026-07-23 16:38:13 +02:00
dennisthiessen eae4d34c06 feat: add fundamentals weighting backtest research 2026-07-23 15:49:15 +02:00
dennisthiessen ddc88b130b fix(deploy): configure Dolt author identity 2026-07-23 13:47:57 +02:00
dennisthiessen 00c28ccb76 fix(sec): remove unused sqlalchemy import
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m45s
Deploy / deploy (push) Successful in 40s
2026-07-23 13:32:39 +02:00
dennisthiessen 8be7635506 fix(sec): remove unused sqlalchemy import 2026-07-23 13:29:06 +02:00
dennisthiessen b68aff80a6 Merge pull request 'Docs/dolt plan clarifications' (#1) from docs/dolt-plan-clarifications into main
Deploy / lint (push) Failing after 10s
Deploy / test (push) Skipped
Deploy / deploy (push) Skipped
Reviewed-on: #1
2026-07-23 13:27:07 +02:00
dennisthiessen 88fc90cc6f feat(deploy): schedule fundamentals shadow imports 2026-07-23 12:41:27 +02:00
dennisthiessen 71d21a45b6 feat(frontend): finish fundamentals reference rails 2026-07-23 10:59:50 +02:00
dennisthiessenandClaude Opus 4.8 480baf762f chore(frontend): harness at desktop width (768px) for two-column preview
max-w-md (448px) under-sized the panel vs its real full-width tab placement and
never triggered the desktop two-column layout. Widen to max-w-3xl; note to
resize to ~390px for the mobile (single-column) check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 09:34:09 +02:00
dennisthiessenandClaude Opus 4.8 103b182598 feat(frontend): FundamentalsPanel — Reference Rails redesign
Replace the four-cell quarter tape (too many equally-weighted numbers) with one
comparison rail per metric, so the panel answers "improving? sound? fairly
valued?" instead of asking the reader to decode it.

- Operating trend (revenue/EPS growth, operating/FCF margin, share count): a rail
  centered on a truthful reference — prior quarter (growth), prior-period average
  (margins), or zero (share count) — with a dot at the current delta and a bar
  back to the reference, plus a shaded neutral band (backend's +-2pp / +-1pp /
  +-1% rules). Value, read, reference label, and signed delta stay visible;
  per-quarter history drops out of the default view.
- Valuation & balance (net debt/EBITDA, P/E, FCF yield): a 0-100 favorable-
  percentile rail with the peer median fixed at 50; right is always more
  favorable (percentile is polarity-aware). median + peer_count shown.
- Not a progress bar: reference line, not a 100% target.
- Horizon tokens: cyan #6EC9DB favorable / coral #EF9182 adverse / #5D6373 track,
  replacing emerald/rose. Two columns on desktop, single column (rows stack) on
  mobile. Null -> n/a with no rail; insufficient peers -> "peers n/a", no track.
- Kept: compact earnings line, provenance footer, local-date parsing, aria-labels
  on every rail. tsc -b passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 09:28:38 +02:00
dennisthiessenandClaude Opus 4.8 bd23f41a1d fix(frontend): FundamentalsPanel review — mobile, local dates, peer a11y
Addresses the static review + adds a dev-only visual harness:

1. Tape no longer overflows narrow mobile: each row stacks (label + read on one
   line, cells below) under sm, keeping the single-line grid on desktop.
2. Date-only strings (earnings, price_date) are parsed as LOCAL calendar dates,
   so a viewer west of UTC no longer sees the previous day.
3. Peer context is visible ("med X · Np") on every width and the percentile
   strip carries a full aria-label — no longer hover-only / desktop-only.
4. Per-metric provenance + freshness surfaced (SEC filings · latest quarter,
   filed date) replacing the removed panel-wide FMP label.
5. Same-day earnings render "today", not "in 0d".

Harness: frontend/harness.html + src/dev/harness.tsx (dev-only, served at
/harness.html by vite, not in the production build) render full /
partial-insufficient-peer / empty fixtures for desktop + ~390px review.

tsc -b passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 08:56:45 +02:00
dennisthiessenandClaude Opus 4.8 9172c1a699 feat(frontend): A4 — FundamentalsPanel v1 (quarter tape + earnings + peers)
Reshapes FundamentalsPanel to consume the additive API v1, within the app's
existing dark-glass language.

- types.ts updated to the exact v1 shape (metrics/earnings/valuation/reads +
  legacy fields preserved).
- The quarter tape is the single distinctive device: per-metric 4-cell tape
  (revenue/EPS growth, operating + FCF margin, share count) with the latest cell
  toned by the deterministic read; color is always paired with the read text.
- Restrained peer strips for Net debt/EBITDA, P/E, FCF yield: value + a
  polarity-aware percentile bar with a median marker + the read; hidden ("peers
  n/a") when industry is null (< 5 peers).
- Earnings: next date/session/countdown + last-N beat/miss arrows (▲/▼/·) with
  text aria-labels; explicit "no date" state.
- Explicit n/a, insufficient-peer, and no-earnings states; header shows the
  deterministic sentence. Removed the hard-coded "FMP" source label.

Frontend tsc -b passes; backend suite 778 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 22:05:17 +02:00
dennisthiessenandClaude Opus 4.8 fb6d39c68b fix(fundamentals): compute eps_growth_yoy read; cover same-day + price guards
- The eps_growth_yoy read was never computed, leaving that fixed by_key entry
  null even with sufficient EPS history; now growth_read() is applied to EPS
  history just like revenue.
- Tests: same-day earnings returns as next with days_until 0 (and not in
  recent); zero close guards valuation to null; eps read populated. Fixture
  seeds three fiscal years so YoY growth reads have a >=3 run. 9 API tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 22:01:24 +02:00
dennisthiessenandClaude Opus 4.8 b3dcf356a6 fix(fundamentals): API v1 review — multi-class pricing, reads contract, guards
1. Multi-class subject is priced by the REQUESTED ticker: the peer group's
   representative for the subject CIK is overridden to the requested ticker_id
   (other issuers pick a deterministic-by-symbol rep), so GOOGL's P/E uses
   GOOGL's price, not GOOG's. Differing-price GOOG/GOOGL test added.
2. reads matches the selected contract: header is null when there is no read;
   by_key is a fixed map over every metric key plus pe and fcf_yield, null when
   unavailable (was a sparse dict).
3. Earnings use the New York calendar date; same-day is UPCOMING (days_until 0),
   recent is strictly earlier.
4. Valuation is null when there is no usable price (> 0 required for P/E and
   market cap); when present, price_date is non-null.

Added a real router/API-envelope test with a seeded legacy record (the endpoint,
not just the schema merge). 6 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:49:17 +02:00
dennisthiessenandClaude Opus 4.8 459a925e36 feat(fundamentals): A4 — additive API v1 (earnings, metrics, valuation, reads)
GET /fundamentals/{symbol} now returns the additive v1 objects alongside the
unchanged legacy fields (no legacy growth mapped onto the SEC TTM metric).

- earnings: next (date/session/days_until) + recent (<=4, with surprise_pct)
  from earnings_events.
- metrics: fixed key set (value + dated history + per-metric SIC-peer industry
  object + source=sec); net_debt has no industry (size-dependent).
- valuation: P/E, FCF yield, market_cap_est computed at REQUEST TIME from the
  derived TTM inputs x the latest ohlcv close (no stored valuation); guarded to
  null on missing/invalid inputs; pe_industry / fcf_yield_industry peer stats.
- reads: deterministic outputs in a SEPARATE object (header + per-metric reads).

Peer queries are batched and CIK-deduplicated by 2-digit SIC; industry omitted
below 5 valid peers. Schema extended with optional typed sub-models; the router
merges legacy + v1 so every existing field is preserved.

Tests: 4 (full assembly incl. peer industry + valuation + additive-merge, no-cik
null metrics, <5-peers omitted, price-guarded valuation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:21:44 +02:00
dennisthiessenandClaude Opus 4.8 979b4047dc fix(fundamentals): pure-core review — tie-aware percentile, null-safe reads
1. Peer percentile is now a tie-aware rank against the OTHER issuers
   ((worse + 0.5*tied)/(peers-1)): an all-equal group maps to 50 (not 100), the
   median maps to 50, a unique best to 100, a unique worst to 0.
2. Deterministic reads use the consecutive non-null suffix ending at the latest
   point (>=3 values): a null latest or an internal gap yields no read, so a read
   never reflects a period displayed as n/a.
3. Peer filtering excludes non-finite (NaN/±inf) as well as null, including an
   invalid subject.

Tests updated + added (all-equal, median rank, non-finite, latest-null history,
internal gap). 15 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:41:18 +02:00
dennisthiessenandClaude Opus 4.8 2038b84b72 feat(fundamentals): A4 — pure peer comparison + deterministic reads
Completes the pure read-time core.

fundamentals_peers.py: median + polarity-aware favorable percentile + peer_count
for a subject within its SIC group (CIK-deduped by the caller); returns None
below MIN_PEERS=5 so the caller omits the industry object. Absolute net_debt is
intentionally NOT peer-eligible (size-dependent) — leverage compares via
net_debt_to_ebitda. HIGHER_IS_BETTER polarity map + two_digit_sic() grouping key.

fundamentals_reads.py: one shared deterministic rule set (no LLM): growth_read
(+-2pp), margin_read (latest vs mean-of-prior, +-1pp), share_count_read (+-1%),
peer_read (60/40 bands, polarity-aware phrasing per metric), header_sentence
(growth · margins · valuation, omitting empty). Tunable named constants; >=3
periods required for a series read.

Tests: 8 peer + 5 reads, anchored on the boundary cases (exactly +2.0pp, exactly
60th percentile, exactly +1.0pp margin). 13 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:25:48 +02:00
dennisthiessenandClaude Opus 4.8 256880899b fix(fundamentals): A4a review — stricter null semantics in derivation
1. net_debt requires BOTH cash and total_debt; a missing side is null, not
   treated as zero (which would be a partial, misleading value).
2. net_debt_to_ebitda is null when TTM EBITDA <= 0 — a negative denominator
   would otherwise rank a distressed issuer as favorably low-leverage.
3. The quarter tape is the CONSECUTIVE run ending at the latest period (stops at
   a gap), so trend text never compares non-adjacent quarters as if consecutive.
4. YoY growth is null when the prior-year TTM is <= 0 (e.g. loss->profit), which
   is not a meaningful percentage.

Also corrected the plan's net-debt formula to total debt − (cash + ST) matching
the positive-means-net-debt implementation. +4 tests. 10 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:20:29 +02:00
dennisthiessenandClaude Opus 4.8 a549942afe feat(fundamentals): A4a — pure read-time metric derivation
Derives the display metrics from the stored YTD snapshots at read time (no I/O,
no DB), per the A3 schema decision. Given an issuer's snapshot rows it produces:

- amendment selection (newest accepted_at per fiscal period);
- discrete quarters = YTD(Qn) - YTD(Qn-1), Q4 = YTD(FY) - YTD(Q3);
- TTM = trailing four discrete quarters; missing period -> null, never partial;
- metric series (value + 4-quarter tape, each point dated): revenue_growth_yoy,
  eps_growth_yoy, operating_margin, fcf_margin, net_debt, net_debt_to_ebitda,
  share_count_change_yoy;
- request-time valuation inputs (ttm_diluted_eps, ttm_fcf, shares_outstanding)
  for the API to combine with price.

Units per app convention (percentages = pp, leverage = multiple, dollars).
Tests: 6 (growth+Q4, margins, net-debt/EBITDA+dilution, valuation inputs,
missing-period-null, amendment selection). Verified on real Apple snapshots:
op margin 32.6%, net-debt/EBITDA 0.10, buyback -1.7%/yr, TTM EPS $8.26.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:10:52 +02:00
dennisthiessenandClaude Opus 4.8 96d3b40560 fix(sec): A3 sign-off hardening — validate per-concept units structure
- Extend the companyfacts structural check to reject a concept with a
  missing/non-dict `units` mapping (not just the top-level `facts`), so a
  partially-malformed payload fails promotion instead of silently dropping that
  concept's facts. New fixture proves it fails.
- Strengthen the newly-added-issuer test: keep latest_index equal to the prior
  run so ONLY the universe fingerprint changes the revision — proving the
  fingerprint alone prevents a new ticker from being starved/no_op'd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:04:11 +02:00
dennisthiessenandClaude Opus 4.8 8dcdcac2a6 fix(sec): A3 slice-2b review — no index cap, full discrepancy + malformed gate
1. Removed the 45-day index-walk cap: it discarded the older part of a long
   outage while still advancing source_max_date, permanently losing filings.
   The walk now covers every unprocessed date (a large gap is one-time cost).
2. Discrepancy detection meets the immutability contract: it compares ALL source
   snapshot fields (not five), read-only during stage/validate, reports the
   differing accessions + fields in validation_json, and promote emits a warning
   system event (in-transaction) — never mutating the stored row.
3. Malformed companyfacts (missing facts/units structure) are recorded separately
   and FAIL validation, instead of silently degrading to skipped rows that the
   50% backfill coverage floor could still pass.

Also corrected the stale "sum share classes" / DEI-only wording in the snapshot
model docstring and the A3 design doc to describe the us-gaap fallback.

Tests: +4 regressions (>45-day gap loses nothing, newly-added issuer backfills
without filing, malformed payload fails, shares discrepancy detected + evented).
23 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:55:58 +02:00
dennisthiessenandClaude Opus 4.8 f7ce85a33e feat(sec): A3 slice 2b — SEC fundamentals importer (shadow ingestion)
SecFundamentalsImporter (SourceImporter, source=sec_facts): populates immutable
fundamental_snapshots from Company Facts and back-fills tickers.cik/sic, driven
by the EDGAR daily index. Shadow only. Guardrails per review:

- detect_revision caches the resolved universe + exact tracked index rows and
  composes the revision from them; stage consumes those same cached inputs
  (no index/universe refetch) so promoted data matches the computed revision.
- Resolution is read-only in stage (proposals only); ticker writes happen in
  promote via apply_ticker_updates.
- validate runs the index<->Company-Facts consistency gate before any write:
  a tracked XBRL index accession missing from Company Facts fails the run
  (they lag independently) so we retry, not record null. Non-XBRL amendments
  are skipped with a recorded reason. Backfill has a coverage floor.
- promote inserts ON CONFLICT (accession) DO NOTHING (immutable), reports
  differing existing accessions without mutating, and applies ticker updates in
  the same transaction.
- Full-history backfill on first run / for newly-added issuers (include_history);
  incremental fetch only for issuers that filed.

Parser: split parse result into skipped_filings vs field_issues (coverage must
not count field warnings); header notes the us-gaap shares fallback; added
companyfacts_accessions() for the gate.

Verified live end-to-end (AAPL + GOOGL backfill): 112 snapshots, cik/sic set,
GOOGL shares via us-gaap fallback, AAPL via dei. Tests: 6 importer (backfill,
incremental, consistency-gate fail, non-XBRL skip, read-only-on-failure,
conflict-discrepancy) + parser ParseResult updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:39:46 +02:00
dennisthiessenandClaude Opus 4.8 4754dbc17b fix(sec): A3 slice-2a review — shares fallback, robust context, hardening
1. Multi-class shares: prefer the single dei:EntityCommonStockSharesOutstanding
   cover-page fact; else fall back to us-gaap:CommonStockSharesOutstanding at
   period end (Alphabet has no dei fact). Never sum class facts (companyfacts is
   non-dimensional) and never use weighted-average/diluted; conflicting values ->
   null, counted as an "ambiguous shares outstanding" note in validation. Plan's
   "sum class-specific" wording corrected. Verified live: Alphabet shares now
   populate (12.1B), Apple still uses its dei cover date.
2. Fiscal context is the majority (fy, fp) among facts ending at reportDate, with
   ties rejected — no longer the arbitrary first fact.
3. Hardening: catalog selectors require taxonomy == "us-gaap"; indexing drops
   malformed facts (missing accession/end, non-finite value) so a custom concept
   or bad date can't be selected.

Tests: +8 (dei precedence, us-gaap fallback, conflict->null, no weighted-average,
tie-context skip, foreign-taxonomy/malformed ignored, ambiguous-shares note).
14 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 16:40:05 +02:00
dennisthiessenandClaude Opus 4.8 7413de9301 feat(sec): A3 slice 2a — companyfacts -> snapshot parser
Pure parser (no I/O/DB) turning one issuer's companyfacts + submissions filing
metadata into per-accession snapshot rows for the filing's primary period.

- Period identity from end == reportDate, never fy/fp (fy/fp is the filing's
  context; comparatives repeat it).
- Duration facts stored as cumulative YTD: pick the fact whose span matches the
  fiscal-period-to-date length (Q1~3mo..FY~12mo) within tolerance; no YTD-length
  fact -> null (never a discrete masquerading as YTD).
- Balance-sheet instants at end == reportDate; shares_outstanding is the dei
  cover-page fact whose own end (cover date) is stored in shares_outstanding_date.
- Cash and debt composites are aggregate-first and mutually exclusive (each
  source tag counted at most once).
- Carries filing_date through submissions rows (snapshot.filed_date).

Verified on REAL Apple companyfacts: 44 snapshots, 0 skipped, YTD revenue
124.3B->219.7B->313.7B->416.2B across FY2025 (Q4 derives at read time), every
shares_date is the cover date != period_end. Tests: 6 fixture + 1 skip-guarded
live-invariants (monotonic YTD, cover-date shares).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 16:11:22 +02:00
dennisthiessenandClaude Opus 4.8 5939be7b7f fix(sec): A3 slice-1 review — read-only resolution, error propagation, fair-access
Addresses the slice-1 review:

1. Resolution is now read-only (A1 transaction contract). resolve_ciks /
   fetch_sic_updates compute proposals and mutate nothing; a new
   apply_ticker_updates issues the writes, called only in promote — so a failed
   validation can't leak ticker changes on the framework's failure commit.
2. Only 404 means "missing". Added SecNotFoundError; daily_index /
   latest_index_date catch only that. 403, exhausted 429, 5xx, timeouts, and
   transport/parse errors now propagate instead of looking like "no index".
3. Fair-access enforced when opening a REAL client (transport=None): reject
   blank/placeholder/non-email User-Agent and sub-0.11s spacing. Mock transports
   skip it (tests use 0 spacing).
4. submissions(include_history=False) by default — only the one-time full
   backfill fetches the history shards; SIC/incremental work makes no extra
   requests.

Plus: retry transient 5xx/network errors and honor Retry-After during the 1 GB
backfill; compose_revision rejects a missing index date (no "None:..." revision).

Re-verified live vs real SEC (fair-access validation passes, shard merge intact).
Tests: 18 (added error propagation, read-only resolution, fair-access, recent-only
submissions, reject-None revision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:49:33 +02:00
dennisthiessenandClaude Opus 4.8 cc67aebe61 feat(sec): A3 slice 1 — SEC client + CIK/SIC resolution + composite revision
First A3 implementation checkpoint (design: docs/dolt-sec-a3-design.md).

- sec_client.py: async SEC EDGAR client honoring fair-access — identifying
  User-Agent (config), request spacing < 10 req/s, exponential backoff on 429,
  and 403 -> SecForbiddenError (alert and stop, never retry-loop). Fetchers:
  company_tickers (normalised, multi-class share CIK), submissions (merges the
  paginated filings.files shards so full history is visible), companyfacts,
  daily_index (fixed-width form.idx parse), latest_index_date.
- sec_universe.py: resolve_ciks (tickers.cik backfill), refresh_sic
  (sic/sic_description), and the composite-revision pieces — universe_fingerprint
  (a new ticker changes the revision, so it's never no_op'd/starved),
  index_content_hash, compose_revision.
- config + .env.example: SEC_USER_AGENT (must be a real contact email) + spacing
  / retries / timeout.

Verified live against real SEC: AAPL->320193, GOOG==GOOGL, BRK-B resolved;
submissions shard-merge proven (131 filings back to 1993); daily index parsed.
Tests: 11 (mocked-transport parsing + 403/429 handling + resolution/fingerprint).
Full suite 713 passed. Next slice: companyfacts -> snapshot parser + importer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:15:56 +02:00
dennisthiessenandClaude Opus 4.8 b1397fa82e docs: A3 design — four review correctness fixes
Fold in the A3 design review:

1. Composite revision = latest-index-date + index-content-hash + tracked
   symbol->CIK fingerprint, so a newly added ticker forces a run instead of
   being no_op'd/starved. No backfill sentinel — absence of a prior promoted
   run triggers backfill; source_max_date records the processed index date.
2. Full history needs the paginated submissions shards: filings.recent caps at
   1000; older accessions (reportDate/acceptanceDateTime/isXBRL) live in
   filings.files[] shards (verified on Apple: recent=1000, one 1994-2015 shard).
3. Index<->Company-Facts consistency gate: they are separate SEC products that
   can lag; for every tracked isXBRL index accession, confirm it exists in
   Company Facts before promotion, else fail+retry (never record a null/partial
   snapshot). Non-XBRL amendments skipped with a recorded reason.
4. Immutable = insert-only (ON CONFLICT DO NOTHING); a differing re-fetch is a
   reported discrepancy, never a silent mutation / import_run_id replacement.

Plus deterministic, mutually-exclusive cash/debt composition (aggregate-first;
each source tag counted at most once).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:04:12 +02:00
dennisthiessenandClaude Opus 4.8 d923f41f85 docs: A3 design signed off — daily-index, primary-period, full backfill
All three decisions approved: fetch via EDGAR daily-index (not bulk zip),
one snapshot per accession for its primary period (comparative-only
restatements out of scope), full-history backfill on first run. Doc status
flipped to approved / ready to implement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 12:33:56 +02:00
dennisthiessenandClaude Opus 4.8 18fca28b7c docs: A3 design pass — SEC fundamentals importer
Design (not implementation) for phase A3, grounded in live SEC data probes.
Key findings: fp has no Q4 (derive it); fy/fp are the filing's context not each
fact's period (select by end==reportDate); SEC provides both discrete and YTD
facts (confirms stored-YTD schema); companyfacts endpoint has no ETag/
Last-Modified (conditional GET impossible); tickers are dash-form and GOOG/GOOGL
share one CIK.

Two plan deviations flagged for sign-off:
1. Fetch via the EDGAR daily-index (fetch companyfacts only for tracked issuers
   that filed) rather than the multi-GB bulk zip — lighter and restores the
   revision/no_op model.
2. One snapshot row per accession for its primary period (YTD-cumulative);
   comparative-only restatements out of scope (only real 10-K/A updates a period).

Plus a metric tag catalog, read-time derivation rules (missing period -> null),
CIK resolution, validation gates, and SEC fair-access handling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 12:10:24 +02:00
dennisthiessenandClaude Opus 4.8 5d275c8df7 fix(dolt): A2 review — subprocess timeouts, stronger initial gate, HASHOF
Addresses the A2 review:

1. Every dolt subprocess is now bounded by a hard timeout
   (dolt_command_timeout_seconds, default 600s); on expiry the process is killed
   and DoltError raised — a hung pull/sql can no longer pin the import
   connection and advisory lock indefinitely. Tested (timeout + non-zero exit).
2. Initial-load validate is stronger: besides zero-future, an initial load now
   requires a real forward horizon (>= 21d, under the ~35d observed on the
   clone) AND universe coverage >= 50% (a broken symbol join can't seed a hollow
   calendar). Subsequent runs keep the 50% collapse gate.
3. Revision uses DOLT_HASHOF('HEAD') — formally HEAD, not dolt_log-by-timestamp.
4. Free-disk floor raised 2 GB -> 5 GB (safe headroom over the ~1.7 GB clone).

Full suite 702 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:50:50 +02:00
dennisthiessenandClaude Opus 4.8 a4e33d7a39 feat(dolt): add shares_outstanding_date to fundamental_snapshots (026)
A1 carry-forward. The SEC cover-page share count
(dei:EntityCommonStockSharesOutstanding) is reported "as of" its own date, which
can differ from the fiscal period_end — store that date so market cap uses the
right point-in-time count. Migration 026 edited in place (never run with data).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:50:38 +02:00
dennisthiessenandClaude Opus 4.8 54ae8ba153 feat(dolt): A2 — DoltHub earnings importer (shadow ingestion)
A SourceImporter that ingests post-no-preference/earnings into earnings_events
for the tracked universe. Shadow by construction (nothing reads earnings_events
until A4).

- earnings_alignment.py: pure calendar<->EPS-history min-cost monotonic DP,
  reused from scripts/import_dolthub_earnings.py with identical constants (not
  extending that one-off script); symbol/session normalization; unit-tested
  against the pinned constants.
- dolt_client.py: async dolt CLI wrapper (pull / current_commit / query_csv via
  asyncio.create_subprocess_exec — never blocks the shared event loop) + disk
  guard before pull.
- dolt_earnings_importer.py: detect_revision = pull + HEAD hash; stage = query
  earnings_calendar + eps_history, dedup, align, map act_symbol->ticker_id
  (normalize both sides so dotted BRK.B joins); promote is destructive
  (delete future dolt_earnings rows + upsert; past never deleted) so validate is
  FAIL-CLOSED — blocks when the staged forward calendar is empty or has collapsed
  below 50% of what's loaded (the forward calendar is the acceptance gate).
- NOTICE: CC BY-SA 4.0 attribution; config: DOLT_BINARY / DOLT_DATA_DIR / etc.

Verified end-to-end against the real 1.68 GB clone (5 tickers: 133 events, 128
paired, forward calendar to 2026-08-26, BRK.B joined). Tests: 9 alignment + 7
importer + 1 skip-guarded real-clone smoke. Full suite 699 passed.

Remaining for A2: wire the daily ~02:30 ET shadow cron — deferred to pair with
the deploy-time dolt install + DOLT_DATA_DIR provisioning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:55:27 +02:00
dennisthiessenandClaude Opus 4.8 e5a62ca648 refactor(dolt): pass run_id to SourceImporter.promote
Both real target tables (fundamental_snapshots, earnings_events) carry an
import_run_id; stamping requires the current run's id. A2 (the earnings
importer) is the first real consumer, so promote gains a run_id argument rather
than having importers hack the running row out of the framework. Protocol +
call site updated; the A1 fake importer now stamps and asserts import_run_id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:55:11 +02:00
dennisthiessenandClaude Opus 4.8 1b821e1a1e chore: gitignore local Dolt dev clones (dolt-data/)
Local dev uses a Dolt clone of post-no-preference/earnings under dolt-data/
(git-ignored). Production keeps clones in DOLT_DATA_DIR outside the repo tree —
the deploy is rsync --delete of the tree, so a clone inside it would be unsafe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:35:51 +02:00
dennisthiessenandClaude Opus 4.8 35e44f681b docs: record A0 license decision — earnings approved (CC BY-SA 4.0, internal)
post-no-preference/earnings approved for private/internal ingestion under
CC BY-SA 4.0. Conditions the A2 importer must honor: preserve upstream license /
attribution / transformation notes; no public API, bulk export, or
redistribution; re-review before any public or commercial access. The stocks
repo (workstream B) is not covered and will be reviewed separately if B begins.
A0 rollout item marked done (dolt binary pin + DOLT_DATA_DIR still pending at
deploy time).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:22:56 +02:00
dennisthiessenandClaude Opus 4.8 fc192c9f74 docs: shares_outstanding wording + staged-representation terminology
Review items 3-4 on the plan doc:

- Replace remaining "diluted shares" (the share *count*) with point-in-time
  shares_outstanding (dei:EntityCommonStockSharesOutstanding) across schema,
  metrics catalog and market-cap note. "diluted EPS" is left as-is (correctly a
  duration fact). Adds the multi-class rule: derive the issuer-wide count from
  the consolidated cover-page figure OR by summing class-specific facts (GOOG +
  GOOGL) — never both, to avoid double counting.
- The framework stages into a representation *outside the live tables* (in-memory
  for workstream A; a file/table handle is fine if B needs it), not physical
  "staging tables" — wording now matches the implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:25:22 +02:00
dennisthiessenandClaude Opus 4.8 8b45bdb361 fix(dolt): record/alert revision-detection failures + handle cancellation
Review fixes to the import-run framework (A1):

1. detect_revision ran outside the failure handler, so a failed revision probe
   (the most likely external failure) escaped unrecorded — violating "every
   attempt is recorded". Now the running row is created FIRST, then
   detect_revision + last-revision lookup + stage + validate + promote all run
   inside the same handler; the row converts to no_op when the revision is
   unchanged. New test covers a detection exception → recorded failed + alert.

2. asyncio.CancelledError (BaseException, not caught by except Exception) left a
   permanent running row on deploy/scheduler shutdown. Now caught explicitly:
   best-effort mark failed, then re-raise the cancellation (never swallowed).
   New test asserts the run is failed and the error re-propagates.

Full suite 682 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:25:14 +02:00
dennisthiessenandClaude Opus 4.8 febd741671 feat(dolt): source-agnostic import-run framework (A1)
run_import + SourceImporter Protocol (detect_revision/stage/validate/promote)
giving every bulk importer the plan's non-negotiables, KISS:

- one run per source at a time — Postgres session-level advisory lock held on a
  single pinned engine.connect() so it survives the running-row and promotion
  commits; no-op on SQLite.
- idempotent per revision — cheap detect_revision compared to the last promoted
  run; unchanged revision records a no_op with zero writes (no fetch).
- staging (in-memory, no physical staging tables) → validate (read-only) →
  atomic promote + run-row flip in one transaction.
- failed validation or mid-run exception marks the run failed, alerts via
  system_event_service, and leaves live tables untouched.

Every attempt recorded in data_import_runs; conflicts summary in validation_json
(no conflicts table). Concrete SEC/earnings importers land in later phases.

Tests: 6 orchestration tests (no_op / promote / new-revision / failed-untouched
/ promote-exception-rollback) + deterministic advisory-key derivation. Full
suite 680 passed. Advisory-lock mutual exclusion is PG-verify-pending (SQLite
no-ops it — flagged, not covered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 00:18:27 +02:00
dennisthiessenandClaude Opus 4.8 949cbbe7c0 feat(dolt): migration 026 + models — fundamentals/earnings schema (A1 schema)
First reviewable slice of workstream A: schema only, no importers, no data.

- data_import_runs: lean batch-import audit (source/revision/status,
  row_counts_json + validation_json as Text-holding-JSON per repo convention).
- fundamental_snapshots: CIK-keyed, one immutable row per accession; stores
  per-period raw facts (duration = cumulative YTD/FY, balance-sheet =
  period-end) plus period_start/period_end/fiscal_year/fiscal_period so
  discrete quarters, Q4, TTM and YoY are derived at read time.
- earnings_events: Dolt-sourced calendar + surprise history, unique
  (ticker_id, announce_date).
- tickers: nullable cik/sic/sic_description — the ticker<->issuer join point.

fundamental_data is left untouched (cutover gated separately at A5). Models
registered in app/models/__init__.py; Ticker gains an earnings_events
relationship. Verified: create_all builds the tables, mappers configure, and
migration 026 renders valid Postgres DDL up and down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 00:07:17 +02:00
dennisthiessenandClaude Opus 4.8 e09ec4ab1d docs: add Dolt integration plan + review clarifications
Adds the Dolt bulk-data integration hand-off plan (authored in prior
session) and applies clarifications found in a pre-handoff review:

- step (c): fields refresh from three distinct sources, not "snapshots +
  close" — earnings_surprise/next_earnings_date come from earnings_events,
  not SEC facts. pe_ratio = close / TTM diluted EPS and market_cap =
  issuer-wide diluted shares × close stated as separate formulas.
- fundamental_snapshots made implementable: add period_start, fiscal_year,
  fiscal_period; duration facts store the filing's cumulative YTD/FY values,
  balance-sheet facts store period-end values. Discrete quarters, Q4, TTM
  and YoY are derived at read time (correct for non-calendar fiscal years;
  amendments never freeze a stale derived quarter).
- flag Q4 derivation / fiscal-period alignment as the primary A3 risk.
- anchor "tracked universe" to ticker_universe_service + per-run CIK
  resolution.

All code anchors in the doc verified accurate against the current tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:54:16 +02:00
114 changed files with 158768 additions and 1355 deletions
+29
View File
@@ -27,6 +27,35 @@ FINNHUB_API_KEY=
# Fundamentals Provider — Alpha Vantage (optional fallback) # Fundamentals Provider — Alpha Vantage (optional fallback)
ALPHA_VANTAGE_API_KEY= ALPHA_VANTAGE_API_KEY=
# Dolt bulk data — local clone of post-no-preference/earnings (workstream A).
# DOLT_BINARY: path to the dolt CLI (set the full path in dev if it's not on PATH,
# e.g. Windows: C:\Program Files\Dolt\bin\dolt.exe). DOLT_DATA_DIR holds the
# clones; in PRODUCTION it MUST be outside the deploy tree (deploy is
# rsync --delete) — e.g. /var/lib/signal-platform/dolt. The earnings clone lives
# at <DOLT_DATA_DIR>/<DOLT_EARNINGS_SUBDIR>. Production setup is automated by
# deploy/provision_fundamentals.sh; see docs/fundamentals-deployment.md.
DOLT_BINARY=dolt
DOLT_DATA_DIR=dolt-data
DOLT_EARNINGS_SUBDIR=earnings
# Free-space floor checked before a pull (clone is ~1.7 GB and grows). 5 GB is a
# safe production default; lower only on a space-constrained dev box.
DOLT_MIN_FREE_DISK_GB=5.0
# Hard timeout (s) on each dolt subprocess so a hung pull/sql can't pin the
# import connection + advisory lock.
DOLT_COMMAND_TIMEOUT_SECONDS=600.0
# SEC EDGAR (fundamentals, workstream A). SEC fair-access REQUIRES an identifying
# User-Agent with a REAL contact email — set it, or requests get 403'd. Stay well
# under 10 req/s (spacing below).
SEC_USER_AGENT=signal-platform/1.0 (contact: you@example.com)
SEC_REQUEST_SPACING_SECONDS=0.2
SEC_MAX_RETRIES=4
SEC_REQUEST_TIMEOUT_SECONDS=30.0
# A5 read-only parity report archive. In production keep this outside the
# rsync deployment tree, e.g. /var/lib/signal-platform/reports/fundamentals-parity.
FUNDAMENTALS_PARITY_REPORT_DIR=reports/fundamentals-parity
# Regime Monitor — FRED (VIX + HY credit spreads). Free key: https://fred.stlouisfed.org/docs/api/api_key.html # Regime Monitor — FRED (VIX + HY credit spreads). Free key: https://fred.stlouisfed.org/docs/api/api_key.html
# Optional: without it the volatility (V1) and credit (C1) pillars show as n/a. # Optional: without it the volatility (V1) and credit (C1) pillars show as n/a.
FRED_API_KEY= FRED_API_KEY=
+7
View File
@@ -39,6 +39,10 @@ alembic/versions/__pycache__/
# Generated SSL bundle # Generated SSL bundle
combined-ca-bundle.pem combined-ca-bundle.pem
# Dolt local dev clones. Production keeps clones in DOLT_DATA_DIR OUTSIDE the
# repo tree (deploy is rsync --delete of the tree); this dir is dev-only.
dolt-data/
# Local research artifacts # Local research artifacts
# Backtest reports in reports/ are tracked: they are the evidence behind the # Backtest reports in reports/ are tracked: they are the evidence behind the
# production baseline in the README. The snapshot DBs they run against are not. # production baseline in the README. The snapshot DBs they run against are not.
@@ -47,3 +51,6 @@ backtest_snapshots/
reports/*.pkl reports/*.pkl
reports/*.pk1 reports/*.pk1
reports/.cache/ reports/.cache/
# Runtime A5 parity bundles are generated on the production server. Research
# conclusions belong in docs/research, not as an ever-growing artifact archive.
reports/fundamentals-parity/
+24
View File
@@ -0,0 +1,24 @@
Third-party data attribution
============================
Earnings calendar and EPS history
---------------------------------
This application ingests the earnings calendar and EPS surprise history from the
public DoltHub repository:
post-no-preference/earnings
https://www.dolthub.com/repositories/post-no-preference/earnings
Licensed under Creative Commons Attribution-ShareAlike 4.0 International
(CC BY-SA 4.0): https://creativecommons.org/licenses/by-sa/4.0/
Use in this project: private, internal ingestion only. The data is normalized
into PostgreSQL (`earnings_events`) — the announcement calendar is aligned to the
EPS history via a minimum-cost monotonic pairing, symbols are normalized, and the
session field is mapped to bmo/amc/unknown. No public API, bulk export, or
redistribution of the data is provided. This attribution and the upstream license
are preserved per the CC BY-SA 4.0 terms. Re-review licensing before any public
or commercial access.
The post-no-preference/stocks repository (workstream B) is not used at this time
and would be reviewed separately.
+10 -3
View File
@@ -133,7 +133,7 @@ indicators.
1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years. 1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years.
2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only. 2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
3. **Market Regime** + **Regime Monitor** — breadth/trend and the v2 risk thermometer; feed no trades. 3. **Market Regime** + **Regime Monitor** — breadth/trend and the v3 risk thermometer; feed no trades.
4. **Telegram alerts** — change-driven (regime-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan. 4. **Telegram alerts** — change-driven (regime-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan.
**Near-close** (~15:30 ET MonFri) — the only full-universe qualifying observation: **Near-close** (~15:30 ET MonFri) — the only full-universe qualifying observation:
@@ -255,11 +255,18 @@ A systematic single-variable sweep (offline prod snapshot, production gate/rank/
| ATR trail multiple {1.54.0} | **Keep 3.0** | Return+Sharpe peak; ≤2.0 whipsaws out the momentum right tail; ≥2.5 is a plateau | | ATR trail multiple {1.54.0} | **Keep 3.0** | Return+Sharpe peak; ≤2.0 whipsaws out the momentum right tail; ≥2.5 is a plateau |
| SPY 200d-MA regime overlay (block entries / go flat) | **Reject** | Halves return (315%→138%) with zero drawdown benefit — the ATR trail already manages downside, and the filter blocks the recovery-phase entries that make the money | | SPY 200d-MA regime overlay (block entries / go flat) | **Reject** | Halves return (315%→138%) with zero drawdown benefit — the ATR trail already manages downside, and the filter blocks the recovery-phase entries that make the money |
| Momentum lookback: 6-1, 3-1, 12-7 (Novy-Marx), composites | **Keep residual 12-1** | 6-1/3-1 rank-IC ≈ 0; 12-7 IC 0.045 / t 1.58 — weaker than residual 12-1 (0.055 / t 1.98) | | Momentum lookback: 6-1, 3-1, 12-7 (Novy-Marx), composites | **Keep residual 12-1** | 6-1/3-1 rank-IC ≈ 0; 12-7 IC 0.045 / t 1.58 — weaker than residual 12-1 (0.055 / t 1.98) |
| Selection cutoff {70, 75, 85, 90} × book size {10, 15, 20} | **Keep 80 × 10** | Monotonically worse in both directions from 80; the 10-slot cap never binds (<10 concurrent) | | Selection cutoff {70, 75, 85, 90} × book size {10, 15, 20} | **Keep cutoff 80; capacity reopened** | The older weekly replay favored 80 × 10, but its no-cap-pressure conclusion is superseded by 519 book-full rejections versus 472 trades under the current daily gate-reset control |
| Position sizing: equal-weight, inverse-vol, risk-% sweep | **Keep 1% fixed-fractional** | See the inverse-vol warning below | | Position sizing: equal-weight, inverse-vol, risk-% sweep | **Keep 1% fixed-fractional** | See the inverse-vol warning below |
| Post-stop re-entry: immediate, fixed 25 sessions, gate resets, confirmation filters | **Keep normal gate reset for the 10-position production book** | Sharpe 1.77 vs 1.67 immediate and 1.47 cooldown 5; rerun before changing portfolio capacity | | Post-stop re-entry: immediate, fixed 25 sessions, gate resets, confirmation filters | **Keep normal gate reset for the 10-position production book** | Sharpe 1.77 vs 1.67 immediate and 1.47 cooldown 5; rerun before changing portfolio capacity |
| FIP path-smoothness as an in-book tie-breaker/filter | **Reject** (but see the lead below) | Non-monotonic across FIP quintiles within the qualified set; either half of a median split underperforms the full book — thinning the entry stream costs more compounding than the tilt returns | | FIP path-smoothness as an in-book tie-breaker/filter | **Reject** (but see the lead below) | Non-monotonic across FIP quintiles within the qualified set; either half of a median split underperforms the full book — thinning the entry stream costs more compounding than the tilt returns |
> **Capacity correction (2026-08-05):** the table's older weekly conclusion
> that the ten-slot cap never binds is superseded. Under the current daily
> gate-reset Phase A control, 472 trades were admitted and 519 qualified entries
> were rejected because the book was full (52.4% of admitted+blocked
> opportunities). Cutoff 80 remains the signal setting; portfolio capacity is
> reopened in the focused capacity-bracket study.
Two findings future sessions must not re-litigate: Two findings future sessions must not re-litigate:
- **The "inverse-vol sizing win" (July 2026) was mis-attributed — do not resurrect.** The diagnostic sized `notional = equity × 1% / vol_6m`, and the 20% notional cap bound on 95% of entries, so it actually measured "~5 positions × 20% notional each" — a concentration/risk-appetite bump economically equivalent to raising risk to 1.5%, not vol-managed sizing. Genuine inverse-vol sizing (risk budget × median-vol/vol) cuts max drawdown to 18.2% but costs ~58pp total return at flat Sharpe: a risk-preference trade, not edge. - **The "inverse-vol sizing win" (July 2026) was mis-attributed — do not resurrect.** The diagnostic sized `notional = equity × 1% / vol_6m`, and the 20% notional cap bound on 95% of entries, so it actually measured "~5 positions × 20% notional each" — a concentration/risk-appetite bump economically equivalent to raising risk to 1.5%, not vol-managed sizing. Genuine inverse-vol sizing (risk budget × median-vol/vol) cuts max drawdown to 18.2% but costs ~58pp total return at flat Sharpe: a risk-preference trade, not edge.
@@ -319,7 +326,7 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
- Activation gate — qualifies setups on a residual-momentum percentile floor (the actual selection), a headline gate-target R:R floor (prod: 2.0) and a 20% primary-target reach-probability floor (validated long-only edge) - Activation gate — qualifies setups on a residual-momentum percentile floor (the actual selection), a headline gate-target R:R floor (prod: 2.0) and a 20% primary-target reach-probability floor (validated long-only edge)
- Recommendation layer — directional confidence, conflict detection, per-target reach-probability - Recommendation layer — directional confidence, conflict detection, per-target reach-probability
- Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: 3x ATR trail with a 30-trading-day max hold; time / percent-trailing / target-stop selectable), realized track record + outcome evaluation - Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: 3x ATR trail with a 30-trading-day max hold; time / percent-trailing / target-stop selectable), realized track record + outcome evaluation
- Market-regime guard + observational State/Warning monitor (fixed-basket breadth, VIX, credit, PIT fundamentals) with a manual chronological correction study - Market-regime guard + observational State/Warning monitor (fixed-basket breadth, VIX, credit level + impulse) with a manual chronological correction study
- Telegram alerts (e.g. regime-quadrant changes) - Telegram alerts (e.g. regime-quadrant changes)
- User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary - User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary
- JWT auth with admin role, configurable registration, user access control - JWT auth with admin role, configurable registration, user access control
@@ -0,0 +1,145 @@
"""Dolt/SEC fundamentals schema — workstream A
Revision ID: 026
Revises: 025
Create Date: 2026-07-21 00:00:00.000000
Foundational schema for the Dolt bulk-data integration (workstream A): the
batch import-run audit table, the SEC-sourced immutable fundamental snapshots
(CIK-keyed, one row per accession), the Dolt earnings calendar/history, and the
SEC issuer identity columns on ``tickers``. No data is populated here — the
importers land in a later phase. ``fundamental_data`` is left untouched; its
cutover is gated separately (phase A5). ``data_import_runs`` is created first
because the other two tables carry an ``import_run_id`` FK to it.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "026"
down_revision: Union[str, None] = "025"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"data_import_runs",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("source", sa.String(length=32), nullable=False),
sa.Column("revision", sa.String(length=64), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("source_max_date", sa.Date(), nullable=True),
sa.Column("row_counts_json", sa.Text(), nullable=True),
sa.Column("validation_json", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("error_details", sa.Text(), nullable=True),
)
op.create_index(
"ix_data_import_runs_source_started", "data_import_runs", ["source", "started_at"]
)
op.create_table(
"fundamental_snapshots",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("cik", sa.String(length=10), nullable=False),
sa.Column("accession", sa.String(length=25), nullable=False),
sa.Column("form", sa.String(length=12), nullable=False),
sa.Column("filed_date", sa.Date(), nullable=False),
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("period_start", sa.Date(), nullable=True),
sa.Column("period_end", sa.Date(), nullable=False),
sa.Column("fiscal_year", sa.Integer(), nullable=False),
sa.Column("fiscal_period", sa.String(length=4), nullable=False),
# duration facts — cumulative YTD/FY
sa.Column("revenue", sa.Float(), nullable=True),
sa.Column("net_income", sa.Float(), nullable=True),
sa.Column("operating_income", sa.Float(), nullable=True),
sa.Column("diluted_eps", sa.Float(), nullable=True),
sa.Column("cfo", sa.Float(), nullable=True),
sa.Column("capex", sa.Float(), nullable=True),
sa.Column("depreciation_amortization", sa.Float(), nullable=True),
# balance-sheet facts — period-end
sa.Column("cash_and_st_investments", sa.Float(), nullable=True),
sa.Column("total_debt", sa.Float(), nullable=True),
sa.Column("shares_outstanding", sa.Float(), nullable=True),
sa.Column("shares_outstanding_date", sa.Date(), nullable=True),
sa.Column(
"import_run_id",
sa.Integer(),
sa.ForeignKey("data_import_runs.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("accession", name="uq_fundamental_snapshots_accession"),
)
op.create_index(
"ix_fundamental_snapshots_cik_period",
"fundamental_snapshots",
["cik", "fiscal_year", "fiscal_period"],
)
op.create_index(
"ix_fundamental_snapshots_cik_period_end",
"fundamental_snapshots",
["cik", "period_end"],
)
op.create_table(
"earnings_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"ticker_id",
sa.Integer(),
sa.ForeignKey("tickers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("announce_date", sa.Date(), nullable=False),
sa.Column("session", sa.String(length=10), nullable=False),
sa.Column("period_end", sa.Date(), nullable=True),
sa.Column("eps_estimate", sa.Float(), nullable=True),
sa.Column("eps_actual", sa.Float(), nullable=True),
sa.Column("source", sa.String(length=32), nullable=False),
sa.Column(
"import_run_id",
sa.Integer(),
sa.ForeignKey("data_import_runs.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("ticker_id", "announce_date", name="uq_earnings_ticker_announce"),
)
op.create_index(
"ix_earnings_events_announce_date", "earnings_events", ["announce_date"]
)
# SEC issuer identity on tickers (nullable; the only ticker<->issuer join point).
op.add_column("tickers", sa.Column("cik", sa.String(length=10), nullable=True))
op.add_column("tickers", sa.Column("sic", sa.String(length=4), nullable=True))
op.add_column(
"tickers", sa.Column("sic_description", sa.String(length=160), nullable=True)
)
def downgrade() -> None:
op.drop_column("tickers", "sic_description")
op.drop_column("tickers", "sic")
op.drop_column("tickers", "cik")
op.drop_index("ix_earnings_events_announce_date", table_name="earnings_events")
op.drop_table("earnings_events")
op.drop_index(
"ix_fundamental_snapshots_cik_period_end", table_name="fundamental_snapshots"
)
op.drop_index(
"ix_fundamental_snapshots_cik_period", table_name="fundamental_snapshots"
)
op.drop_table("fundamental_snapshots")
op.drop_index(
"ix_data_import_runs_source_started", table_name="data_import_runs"
)
op.drop_table("data_import_runs")
@@ -0,0 +1,43 @@
"""fundamental_snapshots.weighted_avg_diluted_shares — market-cap fallback
Revision ID: 027
Revises: 026
Create Date: 2026-07-24 00:00:00.000000
Multi-class issuers report the cover-page share count per share class. That is a
dimensional fact and Company Facts is non-dimensional, so it is absent entirely:
META has never tagged it, CMCSA stops in 2009, BRK-B in 2011, CHTR in 2016 (when
the Time Warner Cable deal made it multi-class). `shares_outstanding` is
therefore null for a large slice of the mega-cap universe, which silently removes
both `market_cap_est` and `fcf_yield`.
The weighted-average diluted count is always present (EPS requires it) and is
consolidated across classes. Measured against issuers where the true
point-in-time count IS available, it lands within ~0.6%: GOOGL 0.9936, MRNA
1.0045, AAPL 0.9974, MSFT 0.9978.
Stored as its own column rather than backfilled into `shares_outstanding`, so the
point-in-time column keeps its strict meaning and the fallback stays an explicit,
labelled read-time decision. Existing rows are null until a reparse.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "027"
down_revision: Union[str, None] = "026"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"fundamental_snapshots",
sa.Column("weighted_avg_diluted_shares", sa.Float(), nullable=True),
)
def downgrade() -> None:
op.drop_column("fundamental_snapshots", "weighted_avg_diluted_shares")
@@ -0,0 +1,147 @@
"""SEC filing retry queue and setup-quality gate
Revision ID: 028
Revises: 027
Create Date: 2026-08-03 00:00:00.000000
"""
from datetime import date, datetime, timezone
import json
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "028"
down_revision: Union[str, None] = "027"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"sec_filing_gaps",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("cik", sa.String(length=10), nullable=False),
sa.Column("accession", sa.String(length=25), nullable=False),
sa.Column("form", sa.String(length=12), nullable=True),
sa.Column("index_date", sa.Date(), nullable=True),
sa.Column("reason", sa.String(length=64), nullable=False),
sa.Column("coregistrant_ciks_json", sa.Text(), nullable=True),
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_attempted_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("escalated_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("accession", name="uq_sec_filing_gaps_accession"),
)
op.create_index("ix_sec_filing_gaps_cik", "sec_filing_gaps", ["cik"])
_backfill_retry_queue()
def downgrade() -> None:
op.drop_index("ix_sec_filing_gaps_cik", table_name="sec_filing_gaps")
op.drop_table("sec_filing_gaps")
def _as_date(value) -> date | None:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if isinstance(value, str):
try:
return date.fromisoformat(value)
except ValueError:
return None
return None
def _backfill_retry_queue() -> None:
"""Materialize pre-queue promoted gaps once; runtime never scans history."""
bind = op.get_bind()
runs = sa.table(
"data_import_runs",
sa.column("source", sa.String()),
sa.column("status", sa.String()),
sa.column("validation_json", sa.Text()),
sa.column("source_max_date", sa.Date()),
sa.column("started_at", sa.DateTime(timezone=True)),
)
snapshots = sa.table(
"fundamental_snapshots",
sa.column("cik", sa.String()),
sa.column("accession", sa.String()),
sa.column("filed_date", sa.Date()),
)
gaps = sa.table(
"sec_filing_gaps",
sa.column("cik", sa.String()),
sa.column("accession", sa.String()),
sa.column("form", sa.String()),
sa.column("index_date", sa.Date()),
sa.column("reason", sa.String()),
sa.column("coregistrant_ciks_json", sa.Text()),
sa.column("first_seen_at", sa.DateTime(timezone=True)),
sa.column("last_attempted_at", sa.DateTime(timezone=True)),
sa.column("escalated_at", sa.DateTime(timezone=True)),
)
snapshot_rows = bind.execute(
sa.select(snapshots.c.cik, snapshots.c.accession, snapshots.c.filed_date)
).all()
resolved_accessions = {row.accession for row in snapshot_rows}
latest_filed_by_cik: dict[str, date] = {}
for row in snapshot_rows:
if row.filed_date is not None:
current = latest_filed_by_cik.get(row.cik)
if current is None or row.filed_date > current:
latest_filed_by_cik[row.cik] = row.filed_date
audit_rows = bind.execute(
sa.select(
runs.c.validation_json,
runs.c.source_max_date,
runs.c.started_at,
).where(
runs.c.source == "sec_facts",
runs.c.status == "promoted",
runs.c.validation_json.is_not(None),
)
).all()
now = datetime.now(timezone.utc)
candidates: dict[str, dict] = {}
for audit in audit_rows:
try:
summary = json.loads(audit.validation_json)
except (TypeError, ValueError):
continue
if not isinstance(summary, dict):
continue
for item in summary.get("missing_xbrl") or []:
accession = item.get("accession")
raw_cik = item.get("cik")
if not accession or raw_cik is None or accession in resolved_accessions:
continue
cik = str(raw_cik).zfill(10)
index_date = _as_date(item.get("index_date")) or _as_date(
audit.source_max_date
)
later_filed = latest_filed_by_cik.get(cik)
if index_date is not None and later_filed is not None and later_filed > index_date:
continue
first_seen = audit.started_at or now
existing = candidates.get(accession)
if existing is not None and existing["first_seen_at"] <= first_seen:
continue
candidates[accession] = {
"cik": cik,
"accession": accession,
"form": item.get("form"),
"index_date": index_date,
"reason": item.get("reason") or "not_in_companyfacts",
"coregistrant_ciks_json": json.dumps(item.get("coregistrants") or []),
"first_seen_at": first_seen,
"last_attempted_at": first_seen,
"escalated_at": None,
}
if candidates:
op.bulk_insert(gaps, list(candidates.values()))
+28
View File
@@ -37,6 +37,34 @@ class Settings(BaseSettings):
# Fundamentals Provider — Alpha Vantage (optional fallback) # Fundamentals Provider — Alpha Vantage (optional fallback)
alpha_vantage_api_key: str = "" alpha_vantage_api_key: str = ""
# Dolt bulk-data — local clone of post-no-preference/earnings (workstream A).
# dolt_binary: full path when not on PATH (dev/Windows install). dolt_data_dir
# holds the clones; in production it MUST be outside the deploy tree (deploy is
# rsync --delete) — set DOLT_DATA_DIR to a persistent path. The earnings clone
# lives at <dolt_data_dir>/<dolt_earnings_subdir>.
dolt_binary: str = "dolt"
dolt_data_dir: str = "dolt-data"
dolt_earnings_subdir: str = "earnings"
# Headroom above the ~1.7 GB earnings clone (grows with pulls); 5 GB is a safe
# production floor — override lower only in a space-constrained dev box.
dolt_min_free_disk_gb: float = 5.0
# Bound every dolt subprocess so a hung pull/sql can't pin the import's
# connection + advisory lock indefinitely.
dolt_command_timeout_seconds: float = 600.0
# SEC EDGAR (workstream A, fundamentals). Fair-access policy REQUIRES an
# identifying User-Agent with a contact email — set a real one. Stay well
# under 10 req/s (spacing below); 403 means the UA/pattern is wrong → the
# client alerts and stops rather than retry-looping.
sec_user_agent: str = "signal-platform/1.0 (contact: set-a-real-email@example.com)"
sec_request_spacing_seconds: float = 0.2
sec_max_retries: int = 4
sec_request_timeout_seconds: float = 30.0
# A5 read-only comparison artifacts. Production must keep this outside the
# rsync deployment tree so the 5-7 day review window survives deploys.
fundamentals_parity_report_dir: str = "reports/fundamentals-parity"
# Regime Monitor — FRED (VIX level + HY credit spreads). Optional: without it # Regime Monitor — FRED (VIX level + HY credit spreads). Optional: without it
# the volatility (P5) and credit-spread (F2) signals are reported as n/a. # the volatility (P5) and credit-spread (F2) signals are reported as n/a.
fred_api_key: str = "" fred_api_key: str = ""
+8
View File
@@ -3,6 +3,9 @@ from app.models.ohlcv import OHLCVRecord
from app.models.user import User from app.models.user import User
from app.models.sentiment import SentimentScore from app.models.sentiment import SentimentScore
from app.models.fundamental import FundamentalData from app.models.fundamental import FundamentalData
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.earnings_event import EarningsEvent
from app.models.data_import_run import DataImportRun
from app.models.score import DimensionScore, CompositeScore from app.models.score import DimensionScore, CompositeScore
from app.models.sr_level import SRLevel from app.models.sr_level import SRLevel
from app.models.trade_setup import TradeSetup from app.models.trade_setup import TradeSetup
@@ -14,6 +17,7 @@ from app.models.regime_snapshot import RegimeSnapshot
from app.models.benchmark_price import BenchmarkPrice from app.models.benchmark_price import BenchmarkPrice
from app.models.signal_context_snapshot import SignalContextSnapshot from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.system_event import SystemEvent from app.models.system_event import SystemEvent
from app.models.sec_filing_gap import SecFilingGap
__all__ = [ __all__ = [
"Ticker", "Ticker",
@@ -21,6 +25,9 @@ __all__ = [
"User", "User",
"SentimentScore", "SentimentScore",
"FundamentalData", "FundamentalData",
"FundamentalSnapshot",
"EarningsEvent",
"DataImportRun",
"DimensionScore", "DimensionScore",
"CompositeScore", "CompositeScore",
"SRLevel", "SRLevel",
@@ -34,4 +41,5 @@ __all__ = [
"BenchmarkPrice", "BenchmarkPrice",
"SignalContextSnapshot", "SignalContextSnapshot",
"SystemEvent", "SystemEvent",
"SecFilingGap",
] ]
+44
View File
@@ -0,0 +1,44 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class DataImportRun(Base):
"""One row per bulk-import attempt (SEC facts / Dolt earnings / Dolt stocks).
Lean audit record for the batch import framework: every attempt is logged,
whether it promoted, was a ``no_op`` (unchanged revision), was ``deferred``
for an expected retry, or ``failed``.
``row_counts`` and ``validation`` hold JSON strings (repo convention — see
``fundamental_data.unavailable_fields_json``), not JSONB; the validation
blob carries reconciliation/discrepancy summaries so no separate conflicts
table is needed. One run per source at a time is enforced at write time by a
Postgres advisory lock keyed by ``source``.
"""
__tablename__ = "data_import_runs"
__table_args__ = (
Index("ix_data_import_runs_source_started", "source", "started_at"),
)
id: Mapped[int] = mapped_column(primary_key=True)
# sec_facts | dolt_earnings | dolt_stocks
source: Mapped[str] = mapped_column(String(32), nullable=False)
# Dolt commit hash, or SEC archive SHA-256. Null until known.
revision: Mapped[str | None] = mapped_column(String(64), nullable=True)
# running | validated | promoted | no_op | deferred | failed
status: Mapped[str] = mapped_column(String(16), nullable=False)
source_max_date: Mapped[date | None] = mapped_column(Date, nullable=True)
row_counts_json: Mapped[str | None] = mapped_column(Text, nullable=True)
validation_json: Mapped[str | None] = mapped_column(Text, nullable=True)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Failure detail, or the non-error reason when status is deferred.
error_details: Mapped[str | None] = mapped_column(Text, nullable=True)
+42
View File
@@ -0,0 +1,42 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Float, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class EarningsEvent(Base):
"""Earnings calendar + surprise history, sourced from the DoltHub earnings repo.
Forward rows (``announce_date`` > today) are the calendar; past rows are
results. Rescheduling is handled in the importer's promotion transaction:
this source's future-dated rows are deleted and re-inserted from the new
snapshot so moved/cancelled dates never linger; past rows are never deleted.
"""
__tablename__ = "earnings_events"
__table_args__ = (
UniqueConstraint("ticker_id", "announce_date", name="uq_earnings_ticker_announce"),
Index("ix_earnings_events_announce_date", "announce_date"),
)
id: Mapped[int] = mapped_column(primary_key=True)
ticker_id: Mapped[int] = mapped_column(
ForeignKey("tickers.id", ondelete="CASCADE"), nullable=False
)
announce_date: Mapped[date] = mapped_column(Date, nullable=False)
# bmo | amc | unknown (source coverage is partial)
session: Mapped[str] = mapped_column(String(10), nullable=False, default="unknown")
period_end: Mapped[date | None] = mapped_column(Date, nullable=True)
eps_estimate: Mapped[float | None] = mapped_column(Float, nullable=True)
eps_actual: Mapped[float | None] = mapped_column(Float, nullable=True)
source: Mapped[str] = mapped_column(String(32), nullable=False)
import_run_id: Mapped[int | None] = mapped_column(
ForeignKey("data_import_runs.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
ticker = relationship("Ticker", back_populates="earnings_events")
+87
View File
@@ -0,0 +1,87 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Float, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class FundamentalSnapshot(Base):
"""CIK-keyed, one immutable row per SEC accession.
Keyed by issuer (CIK), not ticker — multi-class issuers (GOOG/GOOGL) share
one CIK and one set of fundamentals; the ``tickers.cik`` column is the only
join point. Amendments are retained: every accession is a distinct immutable
row, and readers resolve (cik, fiscal_year, fiscal_period) at read time by
taking the newest ``accepted_at`` **per field**, falling back to the newest
accession that actually reports one — a partial amendment (a 10-K/A adding
Part III reports no financial facts) must not blank the period — no flags, no mutation.
**Facts are stored as the filing reports them, never as derived quarters.**
Duration facts (revenue, net_income, operating_income, diluted_eps, cfo,
capex, depreciation_amortization) hold the filing's normalized **cumulative
YTD/FY** value over (period_start -> period_end). Balance-sheet facts
(cash_and_st_investments, total_debt, shares_outstanding) are **period-end**
values. ``shares_outstanding`` is a single consolidated point-in-time count —
the ``dei:EntityCommonStockSharesOutstanding`` cover-page fact, or
``us-gaap:CommonStockSharesOutstanding`` at period end when no dei fact exists
(e.g. Alphabet). It is never a class sum (companyfacts is non-dimensional) nor
the weighted-average diluted count, since both consumers (estimated market cap,
YoY dilution read) want a point-in-time value. Discrete quarters (10-Q YTD
deltas, Q4 = FY - Q1..Q3), TTM, YoY and
the quarter tape are all derived at read time — so non-calendar fiscal years
resolve correctly and a later amendment never leaves a stale frozen quarter.
"""
__tablename__ = "fundamental_snapshots"
__table_args__ = (
UniqueConstraint("accession", name="uq_fundamental_snapshots_accession"),
Index("ix_fundamental_snapshots_cik_period", "cik", "fiscal_year", "fiscal_period"),
Index("ix_fundamental_snapshots_cik_period_end", "cik", "period_end"),
)
id: Mapped[int] = mapped_column(primary_key=True)
cik: Mapped[str] = mapped_column(String(10), nullable=False)
accession: Mapped[str] = mapped_column(String(25), nullable=False)
form: Mapped[str] = mapped_column(String(12), nullable=False) # 10-Q, 10-K, 10-K/A ...
filed_date: Mapped[date] = mapped_column(Date, nullable=False)
# Kept although PIT enforcement is deferred (one timestamp now vs painful retrofit).
accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Period identity — required to align non-calendar fiscal years and to derive
# discrete quarters from cumulative facts.
period_start: Mapped[date | None] = mapped_column(Date, nullable=True)
period_end: Mapped[date] = mapped_column(Date, nullable=False)
fiscal_year: Mapped[int] = mapped_column(nullable=False)
fiscal_period: Mapped[str] = mapped_column(String(4), nullable=False) # Q1|Q2|Q3|Q4|FY
# Duration facts — cumulative YTD/FY over (period_start -> period_end).
revenue: Mapped[float | None] = mapped_column(Float, nullable=True)
net_income: Mapped[float | None] = mapped_column(Float, nullable=True)
operating_income: Mapped[float | None] = mapped_column(Float, nullable=True)
diluted_eps: Mapped[float | None] = mapped_column(Float, nullable=True)
cfo: Mapped[float | None] = mapped_column(Float, nullable=True) # cash flow from operations
capex: Mapped[float | None] = mapped_column(Float, nullable=True)
depreciation_amortization: Mapped[float | None] = mapped_column(Float, nullable=True)
# Balance-sheet facts — period-end values.
cash_and_st_investments: Mapped[float | None] = mapped_column(Float, nullable=True)
total_debt: Mapped[float | None] = mapped_column(Float, nullable=True)
shares_outstanding: Mapped[float | None] = mapped_column(Float, nullable=True)
# The cover-page share count (dei:EntityCommonStockSharesOutstanding) is
# reported "as of" its own date, which can differ from period_end — store it
# so market cap uses the right point-in-time count.
shares_outstanding_date: Mapped[date | None] = mapped_column(Date, nullable=True)
# Weighted-average diluted count for the filing's most recent quarter — the
# market-cap fallback when the cover-page count is absent, which it always is
# for multi-class issuers (per-class facts are dimensional, and companyfacts
# is not). An average is not cumulative, so unlike the duration facts above
# this is NOT a YTD value: it is the shortest-span fact ending at period_end.
weighted_avg_diluted_shares: Mapped[float | None] = mapped_column(Float, nullable=True)
import_run_id: Mapped[int | None] = mapped_column(
ForeignKey("data_import_runs.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
+32
View File
@@ -0,0 +1,32 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class SecFilingGap(Base):
"""Active SEC filing that could not yet be reconstructed.
Rows form a small retry queue. Successful snapshot ingestion deletes the
matching row; a later valid filing supersedes it. While a current row remains,
tickers mapped to its CIK are not eligible for actionable trade setups.
"""
__tablename__ = "sec_filing_gaps"
__table_args__ = (
UniqueConstraint("accession", name="uq_sec_filing_gaps_accession"),
Index("ix_sec_filing_gaps_cik", "cik"),
)
id: Mapped[int] = mapped_column(primary_key=True)
cik: Mapped[str] = mapped_column(String(10), nullable=False)
accession: Mapped[str] = mapped_column(String(25), nullable=False)
form: Mapped[str | None] = mapped_column(String(12), nullable=True)
index_date: Mapped[date | None] = mapped_column(Date, nullable=True)
reason: Mapped[str] = mapped_column(String(64), nullable=False)
coregistrant_ciks_json: Mapped[str | None] = mapped_column(Text, nullable=True)
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_attempted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
escalated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+8
View File
@@ -14,6 +14,13 @@ class Ticker(Base):
# Company name (e.g. "Biogen Inc."); backfilled from Alpaca, nullable for # Company name (e.g. "Biogen Inc."); backfilled from Alpaca, nullable for
# symbols Alpaca doesn't know. # symbols Alpaca doesn't know.
name: Mapped[str | None] = mapped_column(String(120), nullable=True) name: Mapped[str | None] = mapped_column(String(120), nullable=True)
# SEC issuer identity, refreshed by the SEC fundamentals import from
# company_tickers.json / submissions. The only ticker<->issuer join point;
# multi-class tickers (GOOG/GOOGL) share these values. Nullable: not every
# symbol resolves to a CIK (e.g. ADRs, foreign issuers not in SEC data).
cik: Mapped[str | None] = mapped_column(String(10), nullable=True)
sic: Mapped[str | None] = mapped_column(String(4), nullable=True)
sic_description: Mapped[str | None] = mapped_column(String(160), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False DateTime(timezone=True), default=datetime.utcnow, nullable=False
) )
@@ -28,3 +35,4 @@ class Ticker(Base):
trade_setups = relationship("TradeSetup", back_populates="ticker", cascade="all, delete-orphan") trade_setups = relationship("TradeSetup", back_populates="ticker", cascade="all, delete-orphan")
watchlist_entries = relationship("WatchlistEntry", back_populates="ticker", cascade="all, delete-orphan") watchlist_entries = relationship("WatchlistEntry", back_populates="ticker", cascade="all, delete-orphan")
ingestion_progress = relationship("IngestionProgress", back_populates="ticker", cascade="all, delete-orphan", uselist=False) ingestion_progress = relationship("IngestionProgress", back_populates="ticker", cascade="all, delete-orphan", uselist=False)
earnings_events = relationship("EarningsEvent", back_populates="ticker", cascade="all, delete-orphan")
+52
View File
@@ -13,6 +13,7 @@ from app.schemas.admin import (
AlertConfigUpdate, AlertConfigUpdate,
CreateUserRequest, CreateUserRequest,
DataCleanupRequest, DataCleanupRequest,
FundamentalsCutoverConfigUpdate,
JobTriggerRequest, JobTriggerRequest,
JobToggle, JobToggle,
RecommendationConfigUpdate, RecommendationConfigUpdate,
@@ -137,6 +138,27 @@ async def list_settings(
) )
@router.get("/admin/settings/fundamentals-cutover", response_model=APIEnvelope)
async def get_fundamentals_cutover_settings(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
config = await admin_service.get_fundamentals_cutover_config(db)
return APIEnvelope(status="success", data=config)
@router.put("/admin/settings/fundamentals-cutover", response_model=APIEnvelope)
async def update_fundamentals_cutover_settings(
body: FundamentalsCutoverConfigUpdate,
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
config = await admin_service.update_fundamentals_cutover_config(
db, body.enabled
)
return APIEnvelope(status="success", data=config)
@router.get("/admin/settings/recommendations", response_model=APIEnvelope) @router.get("/admin/settings/recommendations", response_model=APIEnvelope)
async def get_recommendation_settings( async def get_recommendation_settings(
_admin: User = Depends(require_admin), _admin: User = Depends(require_admin),
@@ -453,6 +475,36 @@ async def toggle_job(
) )
@router.get("/admin/fundamentals-parity", response_model=APIEnvelope)
async def get_fundamentals_parity_report(
_admin: User = Depends(require_admin),
):
"""Latest read-only A5 source/score comparison, or null before first run."""
return APIEnvelope(
status="success", data=admin_service.get_fundamentals_parity_report()
)
@router.get("/admin/fundamentals-parity/csv", response_model=APIEnvelope)
async def get_fundamentals_parity_csv(
_admin: User = Depends(require_admin),
):
"""Latest flattened A5 report for an authenticated browser download."""
artifact = admin_service.get_fundamentals_parity_csv()
data = None if artifact is None else {"filename": artifact[0], "content": artifact[1]}
return APIEnvelope(status="success", data=data)
@router.get("/admin/fundamentals-parity/json", response_model=APIEnvelope)
async def get_fundamentals_parity_json(
_admin: User = Depends(require_admin),
):
"""Canonical A5 JSON artifact for an authenticated browser download."""
artifact = admin_service.get_fundamentals_parity_json()
data = None if artifact is None else {"filename": artifact[0], "content": artifact[1]}
return APIEnvelope(status="success", data=data)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# System events (operational warnings / errors) # System events (operational warnings / errors)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+16 -6
View File
@@ -9,6 +9,8 @@ from app.dependencies import get_db, require_access
from app.schemas.common import APIEnvelope from app.schemas.common import APIEnvelope
from app.schemas.fundamental import FundamentalResponse from app.schemas.fundamental import FundamentalResponse
from app.services.fundamental_service import get_fundamental from app.services.fundamental_service import get_fundamental
from app.services.fundamentals_api_service import build_fundamentals_v1
from app.services import fundamentals_quality_service
router = APIRouter(tags=["fundamentals"]) router = APIRouter(tags=["fundamentals"])
@@ -30,14 +32,14 @@ async def read_fundamentals(
_user=Depends(require_access), _user=Depends(require_access),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> APIEnvelope: ) -> APIEnvelope:
"""Get latest fundamental data for a symbol.""" """Get latest fundamental data for a symbol (legacy fields + additive v1)."""
record = await get_fundamental(db, symbol) record = await get_fundamental(db, symbol)
v1 = await build_fundamentals_v1(db, symbol)
quality = await fundamentals_quality_service.ticker_quality(db, symbol)
if record is None: legacy: dict = {}
data = FundamentalResponse(symbol=symbol.strip().upper()) if record is not None:
else: legacy = dict(
data = FundamentalResponse(
symbol=symbol.strip().upper(),
pe_ratio=record.pe_ratio, pe_ratio=record.pe_ratio,
revenue_growth=record.revenue_growth, revenue_growth=record.revenue_growth,
earnings_surprise=record.earnings_surprise, earnings_surprise=record.earnings_surprise,
@@ -47,4 +49,12 @@ async def read_fundamentals(
unavailable_fields=_parse_unavailable_fields(record.unavailable_fields_json), unavailable_fields=_parse_unavailable_fields(record.unavailable_fields_json),
) )
data = FundamentalResponse(
symbol=symbol.strip().upper(),
setup_eligible=quality.eligible,
setup_block_code=quality.code,
setup_block_reason=quality.message,
**legacy,
**v1,
)
return APIEnvelope(status="success", data=data.model_dump()) return APIEnvelope(status="success", data=data.model_dump())
+265 -5
View File
@@ -40,7 +40,17 @@ from app.services import (
sentiment_service, sentiment_service,
settings_store, settings_store,
shadow_book_service, shadow_book_service,
fundamentals_parity_service,
fundamental_data_refresh_service,
) )
from app.services.data_import import (
STATUS_DEFERRED,
STATUS_FAILED,
SourceImporter,
run_import,
)
from app.services.dolt_earnings_importer import DoltEarningsImporter
from app.services.sec_fundamentals_importer import SecFundamentalsImporter
from app.services.alert_service import dispatch_alerts from app.services.alert_service import dispatch_alerts
from app.services.backtest_service import ( from app.services.backtest_service import (
BACKTEST_TARGET_MODELS, BACKTEST_TARGET_MODELS,
@@ -93,6 +103,9 @@ _JOB_NAMES = [
"data_backfill", "data_backfill",
"sentiment_collector", "sentiment_collector",
"fundamental_collector", "fundamental_collector",
"dolt_earnings_import",
"sec_fundamentals_import",
"fundamentals_parity_report",
"rr_scanner", "rr_scanner",
"ticker_universe_sync", "ticker_universe_sync",
"alerts", "alerts",
@@ -499,13 +512,14 @@ async def collect_ohlcv(
job_name: str = "data_collector", job_name: str = "data_collector",
*, *,
refetch_days: int = 0, refetch_days: int = 0,
refresh_sr: bool = True,
) -> None: ) -> None:
"""Fetch latest daily OHLCV for all tracked tickers. """Fetch latest daily OHLCV for all tracked tickers.
Uses AlpacaOHLCVProvider. Processes each ticker independently. Uses AlpacaOHLCVProvider. Processes each ticker independently.
On rate limit, records last successful ticker for resume. On rate limit, records last successful ticker for resume.
Start date is resolved by ingestion progress: Start date is resolved by ingestion progress:
- existing ticker: resume from last_ingested_date + 1 - existing ticker: overlap last_ingested_date so partial bars refresh
- new ticker: backfill the configured history window - new ticker: backfill the configured history window
``full_backfill`` forces every ticker to re-fetch the full ``full_backfill`` forces every ticker to re-fetch the full
@@ -567,6 +581,7 @@ async def collect_ohlcv(
try: try:
result = await ingestion_service.fetch_and_ingest( result = await ingestion_service.fetch_and_ingest(
db, provider, symbol, start_date=backfill_start, end_date=end_date, db, provider, symbol, start_date=backfill_start, end_date=end_date,
refresh_sr=refresh_sr,
) )
_last_successful[job_name] = symbol _last_successful[job_name] = symbol
processed += 1 processed += 1
@@ -606,6 +621,11 @@ async def collect_ohlcv(
_runtime_finish(job_name, "error", processed=processed, total=total, message=str(exc)) _runtime_finish(job_name, "error", processed=processed, total=total, message=str(exc))
async def collect_ohlcv_for_scan() -> None:
"""Near-close fetch; the scanner immediately rebuilds S/R per ticker."""
await collect_ohlcv(refresh_sr=False)
async def backfill_ohlcv() -> None: async def backfill_ohlcv() -> None:
"""Deep historical backfill: re-fetch the full ``settings.ohlcv_history_days`` """Deep historical backfill: re-fetch the full ``settings.ohlcv_history_days``
window for every ticker, ignoring incremental resume. window for every ticker, ignoring incremental resume.
@@ -645,7 +665,7 @@ async def run_shadow_book() -> None:
if not await _is_job_enabled(db, job_name): if not await _is_job_enabled(db, job_name):
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled") _log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled") _runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
return return False
if not await shadow_book_service.is_enabled(db): if not await shadow_book_service.is_enabled(db):
_log_event(logging.INFO, "job_skipped", job=job_name, reason="not enabled in settings") _log_event(logging.INFO, "job_skipped", job=job_name, reason="not enabled in settings")
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Not enabled") _runtime_finish(job_name, "skipped", processed=0, total=1, message="Not enabled")
@@ -818,6 +838,22 @@ async def collect_fundamentals() -> None:
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled") _log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
_runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled") _runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled")
return return
if await fundamental_data_refresh_service.is_enabled(db):
message = "SEC + Dolt fundamentals cutover is active"
_log_event(
logging.INFO,
"job_skipped",
job=job_name,
reason="sec_dolt_cutover_active",
)
_runtime_finish(
job_name,
"skipped",
processed=0,
total=0,
message=message,
)
return
symbols = await _get_fundamental_priority_tickers(db) symbols = await _get_fundamental_priority_tickers(db)
if not symbols: if not symbols:
@@ -912,6 +948,184 @@ async def collect_fundamentals() -> None:
_runtime_finish(job_name, "error", processed=processed, total=total, message=str(exc)) _runtime_finish(job_name, "error", processed=processed, total=total, message=str(exc))
# ---------------------------------------------------------------------------
# Jobs: shadow fundamentals sources
# ---------------------------------------------------------------------------
async def _run_shadow_import(job_name: str, importer: SourceImporter) -> bool:
"""Run an importer and return whether its scheduled job was enabled.
The SEC wrapper uses the return value to run its activated local cache step
after deferred, failed, no-op, promoted, or source-locked attempts while honoring
the job-level disable switch.
"""
_log_event(logging.INFO, "job_start", job=job_name)
_runtime_start(job_name, total=1)
try:
async with async_session_factory() as db:
if not await _is_job_enabled(db, job_name):
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
return
run = await run_import(importer)
if run is None:
message = "Another import for this source is already running"
_log_event(logging.INFO, "job_skipped", job=job_name, reason="source_locked")
_runtime_finish(job_name, "skipped", processed=0, total=1, message=message)
return True
revision = f" · {run.revision[:12]}" if run.revision else ""
message = f"{run.status}{revision}"
if run.status == STATUS_DEFERRED:
message = run.error_details or message
_log_event(logging.INFO, "job_deferred", job=job_name, message=message)
_runtime_finish(job_name, "deferred", processed=0, total=1, message=message)
return True
if run.status == STATUS_FAILED:
message = run.error_details or message
_log_event(logging.ERROR, "job_error", job=job_name, message=message)
_runtime_finish(job_name, "error", processed=0, total=1, message=message)
return True
_log_event(
logging.INFO,
"job_complete",
job=job_name,
import_status=run.status,
revision=run.revision,
)
_runtime_finish(job_name, "completed", processed=1, total=1, message=message)
return True
except asyncio.CancelledError:
_runtime_finish(job_name, "error", processed=0, total=1, message="Cancelled")
raise
except Exception as exc:
_log_event(
logging.ERROR,
"job_error",
job=job_name,
error_type=type(exc).__name__,
message=str(exc),
)
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
return True
async def run_dolt_earnings_import() -> None:
"""Pull and import the Dolt earnings calendar/results feed in shadow."""
await _run_shadow_import("dolt_earnings_import", DoltEarningsImporter())
async def run_sec_fundamentals_import() -> None:
"""Import SEC facts, then run the activated local compat-cache refresh.
The refresh is deliberately separate from the network import result. Once
activated it therefore still runs from stored snapshots/earnings/prices when
SEC is unavailable, unchanged, or another SEC import owns the source lock.
"""
job_name = "sec_fundamentals_import"
job_enabled = await _run_shadow_import(job_name, SecFundamentalsImporter())
if not job_enabled:
return
try:
async with async_session_factory() as db:
summary = await fundamental_data_refresh_service.refresh_if_enabled(db)
except asyncio.CancelledError:
_runtime_finish(
job_name, "error", processed=0, total=1, message="Cancelled"
)
raise
except Exception as exc:
message = f"Local fundamental_data refresh failed: {exc}"
_log_event(
logging.ERROR,
"fundamental_data_refresh_error",
job=job_name,
error_type=type(exc).__name__,
message=str(exc),
)
_runtime_finish(job_name, "error", processed=0, total=1, message=message)
return
if not summary["enabled"]:
_log_event(
logging.INFO,
"fundamental_data_refresh_skipped",
job=job_name,
reason="cutover_disabled",
setting=fundamental_data_refresh_service.ACTIVATION_KEY,
)
return
_log_event(
logging.INFO,
"fundamental_data_refresh_complete",
job=job_name,
**summary,
)
runtime = get_job_runtime_snapshot(job_name)
if runtime.get("status") == "completed":
import_message = runtime.get("message") or "import completed"
cache_message = (
f"cache {summary['refreshed']} · "
f"{summary['score_inputs_changed']} score inputs changed"
)
_runtime_finish(
job_name,
"completed",
processed=1,
total=1,
message=f"{import_message} · {cache_message}",
)
async def run_fundamentals_parity_report() -> None:
"""Generate the A5 comparison bundle without mutating live fundamentals/scores."""
job_name = "fundamentals_parity_report"
_log_event(logging.INFO, "job_start", job=job_name)
_runtime_start(job_name, total=1)
try:
async with async_session_factory() as db:
if not await _is_job_enabled(db, job_name):
_runtime_finish(
job_name, "skipped", processed=0, total=1, message="Disabled"
)
return
report, artifacts = await fundamentals_parity_service.generate_and_store(
db, settings.fundamentals_parity_report_dir
)
summary = report["summary"]
message = (
f"{summary['universe_count']} tickers · "
f"{summary['fundamental_score_material_changes']} material score changes"
)
_runtime_finish(job_name, "completed", processed=1, total=1, message=message)
_log_event(
logging.INFO,
"job_complete",
job=job_name,
generated_at=report["generated_at"],
json_path=artifacts["json"],
csv_path=artifacts["csv"],
)
except asyncio.CancelledError:
_runtime_finish(job_name, "error", processed=0, total=1, message="Cancelled")
raise
except Exception as exc:
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
_log_event(
logging.ERROR,
"job_error",
job=job_name,
error_type=type(exc).__name__,
message=str(exc),
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Job: R:R Scanner # Job: R:R Scanner
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1289,8 +1503,8 @@ _DAILY_PIPELINE_STEPS = [
("alerts", "dispatch_alerts_job"), ("alerts", "dispatch_alerts_job"),
] ]
# Near-close (~15:30 ET MonFri): refresh in-progress day-t bars (already how # Near-close (~15:30 ET MonFri): refresh in-progress day-t bars (incremental
# the intraday pipeline keeps the dashboard live), then the only daily # ingestion overlaps the latest stored session), then the only daily
# qualifying R:R scan, then Telegram immediately so manual fills can still hit # qualifying R:R scan, then Telegram immediately so manual fills can still hit
# MOC cutoffs (~15:50/15:55). Under a 15-minute delayed SIP feed a 15:30 scan # MOC cutoffs (~15:50/15:55). Under a 15-minute delayed SIP feed a 15:30 scan
# may see ~15:15 prices — immaterial for a 12-1 momentum signal. # may see ~15:15 prices — immaterial for a 12-1 momentum signal.
@@ -1301,7 +1515,7 @@ _DAILY_PIPELINE_STEPS = [
_NEAR_CLOSE_PIPELINE_STEPS = [ _NEAR_CLOSE_PIPELINE_STEPS = [
# Must land today's in-progress bar (~20 min behind live), or the scan falls # Must land today's in-progress bar (~20 min behind live), or the scan falls
# back to the previous close and execution degrades to the stale_close floor. # back to the previous close and execution degrades to the stale_close floor.
("data_collector", "collect_ohlcv"), ("data_collector", "collect_ohlcv_for_scan"),
("rr_scanner", "scan_rr"), ("rr_scanner", "scan_rr"),
# Straight after the scan so shadow entries mark at the same near-close # Straight after the scan so shadow entries mark at the same near-close
# prices the discretionary book is looking at. # prices the discretionary book is looking at.
@@ -1452,6 +1666,11 @@ SCHEDULE_DEFAULTS: dict[str, str] = {
"schedule_timezone": "America/New_York", "schedule_timezone": "America/New_York",
# Morning data/display refresh (no qualifying R:R scan). # Morning data/display refresh (no qualifying R:R scan).
"schedule_daily_pipeline_cron": "0 2 * * *", "schedule_daily_pipeline_cron": "0 2 * * *",
# Bulk source imports. The SEC job writes the legacy compat cache only after
# the explicit, default-off A5 cutover setting is enabled.
"schedule_dolt_earnings_cron": "30 2 * * *",
"schedule_sec_fundamentals_cron": "0 4 * * *",
"schedule_fundamentals_parity_cron": "30 5 * * *",
# Fetch in-progress bars → scan → Telegram (manual MOC window). # Fetch in-progress bars → scan → Telegram (manual MOC window).
"schedule_near_close_pipeline_cron": "30 15 * * mon-fri", "schedule_near_close_pipeline_cron": "30 15 * * mon-fri",
# Fetch final bars → outcome eval (must not run on the partial near-close bar). # Fetch final bars → outcome eval (must not run on the partial near-close bar).
@@ -1465,6 +1684,9 @@ SCHEDULE_DEFAULTS: dict[str, str] = {
# job id -> schedule setting key # job id -> schedule setting key
_CRON_JOBS: dict[str, str] = { _CRON_JOBS: dict[str, str] = {
"daily_pipeline": "schedule_daily_pipeline_cron", "daily_pipeline": "schedule_daily_pipeline_cron",
"dolt_earnings_import": "schedule_dolt_earnings_cron",
"sec_fundamentals_import": "schedule_sec_fundamentals_cron",
"fundamentals_parity_report": "schedule_fundamentals_parity_cron",
"near_close_pipeline": "schedule_near_close_pipeline_cron", "near_close_pipeline": "schedule_near_close_pipeline_cron",
"after_close_pipeline": "schedule_after_close_pipeline_cron", "after_close_pipeline": "schedule_after_close_pipeline_cron",
"intraday_pipeline": "schedule_intraday_pipeline_cron", "intraday_pipeline": "schedule_intraday_pipeline_cron",
@@ -1549,6 +1771,39 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
_cron_trigger(cfg["schedule_daily_pipeline_cron"], tz, "schedule_daily_pipeline_cron"), _cron_trigger(cfg["schedule_daily_pipeline_cron"], tz, "schedule_daily_pipeline_cron"),
id="daily_pipeline", name="Morning Pipeline", replace_existing=True, id="daily_pipeline", name="Morning Pipeline", replace_existing=True,
) )
scheduler.add_job(
run_dolt_earnings_import,
_cron_trigger(
cfg["schedule_dolt_earnings_cron"],
tz,
"schedule_dolt_earnings_cron",
),
id="dolt_earnings_import",
name="Dolt Earnings Import (shadow)",
replace_existing=True,
)
scheduler.add_job(
run_sec_fundamentals_import,
_cron_trigger(
cfg["schedule_sec_fundamentals_cron"],
tz,
"schedule_sec_fundamentals_cron",
),
id="sec_fundamentals_import",
name="SEC Fundamentals Import",
replace_existing=True,
)
scheduler.add_job(
run_fundamentals_parity_report,
_cron_trigger(
cfg["schedule_fundamentals_parity_cron"],
tz,
"schedule_fundamentals_parity_cron",
),
id="fundamentals_parity_report",
name="Fundamentals Parity Report (read-only)",
replace_existing=True,
)
scheduler.add_job( scheduler.add_job(
run_near_close_pipeline, run_near_close_pipeline,
_cron_trigger( _cron_trigger(
@@ -1622,6 +1877,11 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
"cron": cfg["schedule_daily_pipeline_cron"], "cron": cfg["schedule_daily_pipeline_cron"],
"steps": [name for name, _ in _DAILY_PIPELINE_STEPS], "steps": [name for name, _ in _DAILY_PIPELINE_STEPS],
}, },
dolt_earnings_import={"cron": cfg["schedule_dolt_earnings_cron"]},
sec_fundamentals_import={"cron": cfg["schedule_sec_fundamentals_cron"]},
fundamentals_parity_report={
"cron": cfg["schedule_fundamentals_parity_cron"]
},
near_close_pipeline={ near_close_pipeline={
"cron": cfg["schedule_near_close_pipeline_cron"], "cron": cfg["schedule_near_close_pipeline_cron"],
"steps": [name for name, _ in _NEAR_CLOSE_PIPELINE_STEPS], "steps": [name for name, _ in _NEAR_CLOSE_PIPELINE_STEPS],
+8
View File
@@ -73,11 +73,19 @@ class ActivationConfigUpdate(BaseModel):
exclude_neutral: bool | None = None exclude_neutral: bool | None = None
class FundamentalsCutoverConfigUpdate(BaseModel):
"""Switch the legacy fundamentals cache from quota APIs to SEC/Dolt."""
enabled: bool
class ScheduleConfigUpdate(BaseModel): class ScheduleConfigUpdate(BaseModel):
"""Cron schedule for the pipelines + fundamentals. Crons are 5-field """Cron schedule for the pipelines + fundamentals. Crons are 5-field
(min hour dom month dow); timezone is an IANA name (e.g. America/New_York).""" (min hour dom month dow); timezone is an IANA name (e.g. America/New_York)."""
schedule_timezone: str | None = Field(default=None, max_length=64) schedule_timezone: str | None = Field(default=None, max_length=64)
schedule_daily_pipeline_cron: str | None = Field(default=None, max_length=120) schedule_daily_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_dolt_earnings_cron: str | None = Field(default=None, max_length=120)
schedule_sec_fundamentals_cron: str | None = Field(default=None, max_length=120)
schedule_fundamentals_parity_cron: str | None = Field(default=None, max_length=120)
schedule_near_close_pipeline_cron: str | None = Field(default=None, max_length=120) schedule_near_close_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_after_close_pipeline_cron: str | None = Field(default=None, max_length=120) schedule_after_close_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_intraday_pipeline_cron: str | None = Field(default=None, max_length=120) schedule_intraday_pipeline_cron: str | None = Field(default=None, max_length=120)
+77 -1
View File
@@ -7,8 +7,75 @@ from datetime import date, datetime
from pydantic import BaseModel from pydantic import BaseModel
class MetricIndustry(BaseModel):
label: str
median: float
favorable_percentile: int # 0-100, polarity-aware (higher = more favorable)
peer_count: int
class MetricHistoryPoint(BaseModel):
period_end: str # YYYY-MM-DD
value: float | None
class MetricItem(BaseModel):
key: str
value: float | None = None
history: list[MetricHistoryPoint] = []
industry: MetricIndustry | None = None
period_end: str | None = None
filed_date: str | None = None
caveat: str | None = None
source: str = "sec"
class EarningsNext(BaseModel):
date: str
session: str
days_until: int
class EarningsRecent(BaseModel):
announce_date: str
period_end: str | None = None
eps_estimate: float | None = None
eps_actual: float | None = None
surprise_pct: float | None = None
class EarningsObject(BaseModel):
next: EarningsNext | None = None
recent: list[EarningsRecent] = []
class Valuation(BaseModel):
pe: float | None = None
fcf_yield: float | None = None
market_cap_est: float | None = None
pe_industry: MetricIndustry | None = None
fcf_yield_industry: MetricIndustry | None = None
price_date: str | None = None
class FundamentalsReads(BaseModel):
"""Deterministic text outputs, separate from the numeric metrics.
``by_key`` is a fixed map over every metric key plus ``pe`` and ``fcf_yield``,
each a read string or null. ``header`` is null when there is no read at all."""
header: str | None = None
by_key: dict[str, str | None] = {}
class FundamentalResponse(BaseModel): class FundamentalResponse(BaseModel):
"""Envelope-ready fundamental data response.""" """Envelope-ready fundamental data response.
Legacy fields are preserved unchanged (they come from ``fundamental_data`` /
the legacy providers). The additive v1 objects — earnings, metrics, valuation,
reads — are SEC/Dolt-derived and independent; a null legacy field is never
mapped onto the new SEC metrics and vice-versa.
"""
symbol: str symbol: str
pe_ratio: float | None = None pe_ratio: float | None = None
@@ -18,3 +85,12 @@ class FundamentalResponse(BaseModel):
next_earnings_date: date | None = None next_earnings_date: date | None = None
fetched_at: datetime | None = None fetched_at: datetime | None = None
unavailable_fields: dict[str, str] = {} unavailable_fields: dict[str, str] = {}
# --- additive v1 (always present; empty/null when unavailable) ---
earnings: EarningsObject | None = None
metrics: list[MetricItem] | None = None
valuation: Valuation | None = None
reads: FundamentalsReads | None = None
setup_eligible: bool = True
setup_block_code: str | None = None
setup_block_reason: str | None = None
+4
View File
@@ -53,3 +53,7 @@ class PaperTradeResponse(BaseModel):
# when the trailing exit policy is active. # when the trailing exit policy is active.
trailing_stop: float | None = None trailing_stop: float | None = None
trailing_distance_pct: float | None = None trailing_distance_pct: float | None = None
# Trading sessions represented by post-entry OHLCV bars. These are populated
# only while the active exit policy has a max-hold rule.
sessions_held: int | None = None
sessions_remaining: int | None = None
+56 -1
View File
@@ -17,7 +17,7 @@ from app.models.settings import SystemSetting
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup from app.models.trade_setup import TradeSetup
from app.models.user import User from app.models.user import User
from app.services import settings_store from app.services import fundamental_data_refresh_service, settings_store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -159,6 +159,28 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin
return setting return setting
# ---------------------------------------------------------------------------
# Fundamentals source cutover
# ---------------------------------------------------------------------------
async def get_fundamentals_cutover_config(db: AsyncSession) -> dict[str, bool]:
"""Return the explicit A5 cache-cutover switch (default off)."""
return {"enabled": await fundamental_data_refresh_service.is_enabled(db)}
async def update_fundamentals_cutover_config(
db: AsyncSession, enabled: bool
) -> dict[str, bool]:
"""Activate or pause SEC/Dolt writes to the legacy fundamentals cache."""
await settings_store.upsert_setting(
db,
fundamental_data_refresh_service.ACTIVATION_KEY,
"true" if enabled else "false",
)
await db.commit()
return await get_fundamentals_cutover_config(db)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Activation thresholds # Activation thresholds
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -612,6 +634,9 @@ VALID_JOB_NAMES = {
"benchmark_collector", "benchmark_collector",
"sentiment_collector", "sentiment_collector",
"fundamental_collector", "fundamental_collector",
"dolt_earnings_import",
"sec_fundamentals_import",
"fundamentals_parity_report",
"rr_scanner", "rr_scanner",
"ticker_universe_sync", "ticker_universe_sync",
"outcome_evaluator", "outcome_evaluator",
@@ -633,6 +658,9 @@ JOB_LABELS = {
"benchmark_collector": "Benchmark Collector", "benchmark_collector": "Benchmark Collector",
"sentiment_collector": "Sentiment Collector", "sentiment_collector": "Sentiment Collector",
"fundamental_collector": "Fundamental Collector", "fundamental_collector": "Fundamental Collector",
"dolt_earnings_import": "Dolt Earnings Import (shadow)",
"sec_fundamentals_import": "SEC Fundamentals Import",
"fundamentals_parity_report": "Fundamentals Parity Report (read-only)",
"rr_scanner": "R:R Scanner", "rr_scanner": "R:R Scanner",
"ticker_universe_sync": "Ticker Universe Sync", "ticker_universe_sync": "Ticker Universe Sync",
"outcome_evaluator": "Outcome Evaluator", "outcome_evaluator": "Outcome Evaluator",
@@ -771,3 +799,30 @@ async def toggle_job(db: AsyncSession, job_name: str, enabled: bool) -> SystemSe
key = f"job_{job_name}_enabled" key = f"job_{job_name}_enabled"
return await update_setting(db, key, str(enabled).lower()) return await update_setting(db, key, str(enabled).lower())
def get_fundamentals_parity_report() -> dict | None:
"""Return the latest compact A5 summary, if the job has run."""
from app.config import settings
from app.services.fundamentals_parity_service import load_latest
report = load_latest(settings.fundamentals_parity_report_dir)
if report is not None:
report.pop("rows", None) # full per-ticker data is download-only
return report
def get_fundamentals_parity_csv() -> tuple[str, str] | None:
"""Return the latest A5 CSV filename and content for authenticated download."""
from app.config import settings
from app.services.fundamentals_parity_service import load_latest_csv
return load_latest_csv(settings.fundamentals_parity_report_dir)
def get_fundamentals_parity_json() -> tuple[str, str] | None:
"""Return the canonical A5 JSON artifact for authenticated download."""
from app.config import settings
from app.services.fundamentals_parity_service import load_latest_json
return load_latest_json(settings.fundamentals_parity_report_dir)
+2 -2
View File
@@ -97,8 +97,8 @@ SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML par
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary # Hysteresis (a deadband around each divider) stops a point sitting on a boundary
# from flip-flopping; the cooldown caps how often a genuine change can re-alert. # from flip-flopping; the cooldown caps how often a genuine change can re-alert.
QUAD_TYPE = "regime_quadrant" QUAD_TYPE = "regime_quadrant"
QUAD_X_DIV = 60.0 # v2 State divider (backend response is authoritative) QUAD_X_DIV = 50.0 # v3 State divider (backend response is authoritative)
QUAD_Y_DIV = 60.0 # v2 Warning divider QUAD_Y_DIV = 40.0 # v3 Warning divider; the axes have different ranges
QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider
QUAD_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts QUAD_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts
QUAD_LABELS = { QUAD_LABELS = {
+432 -32
View File
@@ -1320,6 +1320,7 @@ def _replay_candidates_for_period(
cadence: str = DEFAULT_BACKTEST_CADENCE, cadence: str = DEFAULT_BACKTEST_CADENCE,
include_short_candidates: bool = False, include_short_candidates: bool = False,
include_universe_rank_observations: bool = False, include_universe_rank_observations: bool = False,
outcome_horizon_sessions: int = HORIZON,
) -> list[dict]: ) -> list[dict]:
"""Slim picklable replay used by local event studies. """Slim picklable replay used by local event studies.
@@ -1343,10 +1344,13 @@ def _replay_candidates_for_period(
) )
] ]
cadence = validate_backtest_cadence(cadence) cadence = validate_backtest_cadence(cadence)
replay_horizon = int(outcome_horizon_sessions)
if replay_horizon < 0:
raise ValueError('outcome_horizon_sessions must be non-negative')
candidates: list[dict] = [] candidates: list[dict] = []
for i in range( for i in range(
MIN_LOOKBACK - 1, MIN_LOOKBACK - 1,
len(bars) - HORIZON, len(bars) - replay_horizon,
backtest_step_sessions(cadence), backtest_step_sessions(cadence),
): ):
if bars[i].date < start_date: if bars[i].date < start_date:
@@ -1942,6 +1946,7 @@ def _make_gate_reset_reentry_fn(
cadence: str, cadence: str,
qualified_fn: Callable[[dict], bool] | None = None, qualified_fn: Callable[[dict], bool] | None = None,
ranking_key: str = PRODUCTION_PERCENTILE_KEY, ranking_key: str = PRODUCTION_PERCENTILE_KEY,
evaluation_horizon_sessions: int = HORIZON,
) -> Callable[[str, int, dict, Any], dict | None]: ) -> Callable[[str, int, dict, Any], dict | None]:
"""Build the production post-stop gate-reset callback. """Build the production post-stop gate-reset callback.
@@ -1959,11 +1964,18 @@ def _make_gate_reset_reentry_fn(
evaluation_ords: dict[str, set[int]] = {} evaluation_ords: dict[str, set[int]] = {}
step_sessions = backtest_step_sessions(cadence) step_sessions = backtest_step_sessions(cadence)
evaluation_horizon = int(evaluation_horizon_sessions)
if evaluation_horizon < 0:
raise ValueError('evaluation_horizon_sessions must be non-negative')
for symbol, columns in prices.items(): for symbol, columns in prices.items():
ordinals = columns[0] ordinals = columns[0]
evaluation_ords[symbol] = { evaluation_ords[symbol] = {
int(ordinals[index]) int(ordinals[index])
for index in range(MIN_LOOKBACK - 1, len(ordinals) - HORIZON, step_sessions) for index in range(
MIN_LOOKBACK - 1,
len(ordinals) - evaluation_horizon,
step_sessions,
)
} }
qualified_by_symbol_date: dict[tuple[str, int], dict] = {} qualified_by_symbol_date: dict[tuple[str, int], dict] = {}
@@ -2010,7 +2022,7 @@ def _simulate_portfolio(
*, *,
qualified_fn: Callable[[dict], bool] | None = None, qualified_fn: Callable[[dict], bool] | None = None,
ranking_key: str = PRODUCTION_PERCENTILE_KEY, ranking_key: str = PRODUCTION_PERCENTILE_KEY,
max_positions: int = SIM_MAX_POSITIONS, max_positions: int | None = SIM_MAX_POSITIONS,
risk_per_trade: float = SIM_RISK_PER_TRADE, risk_per_trade: float = SIM_RISK_PER_TRADE,
atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER, atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER,
cost_per_side: float = COST_PER_SIDE, cost_per_side: float = COST_PER_SIDE,
@@ -2034,6 +2046,12 @@ def _simulate_portfolio(
corr_lookback: int = 120, corr_lookback: int = 120,
corr_action: str = "skip", corr_action: str = "skip",
corr_min_overlap: int = 60, corr_min_overlap: int = 60,
min_initial_risk_fraction: float | None = None,
weekly_top_n_rebalance: bool = False,
daily_rank_map: dict[tuple[str, str], dict[str, float | None]] | None = None,
measurement_start_date: date | None = None,
hard_end_date: date | None = None,
include_capacity_diagnostics: bool = False,
) -> dict | None: ) -> dict | None:
"""Replay the qualified setups as ONE capital-constrained book and report """Replay the qualified setups as ONE capital-constrained book and report
portfolio economics from the daily equity curve (return, CAGR, drawdown, portfolio economics from the daily equity curve (return, CAGR, drawdown,
@@ -2083,6 +2101,20 @@ def _simulate_portfolio(
raise ValueError("corr_action must be 'skip' or 'half_size'") raise ValueError("corr_action must be 'skip' or 'half_size'")
if vol_target is not None and vol_target <= 0: if vol_target is not None and vol_target <= 0:
raise ValueError("vol_target must be positive when set") raise ValueError("vol_target must be positive when set")
if max_positions is not None and int(max_positions) <= 0:
raise ValueError("max_positions must be positive or None")
if min_initial_risk_fraction is not None and not (
0.0 < float(min_initial_risk_fraction) < 1.0
):
raise ValueError("min_initial_risk_fraction must be between 0 and 1")
if weekly_top_n_rebalance and (
max_positions is None or daily_rank_map is None
):
raise ValueError(
"weekly_top_n_rebalance requires max_positions and daily_rank_map"
)
if weekly_top_n_rebalance and fill_mode != FILL_MODE_CLOSE:
raise ValueError("weekly_top_n_rebalance requires fill_mode=close")
clamp_lo, clamp_hi = float(vol_clamp[0]), float(vol_clamp[1]) clamp_lo, clamp_hi = float(vol_clamp[0]), float(vol_clamp[1])
if clamp_lo <= 0 or clamp_hi < clamp_lo: if clamp_lo <= 0 or clamp_hi < clamp_lo:
raise ValueError("vol_clamp must satisfy 0 < lo <= hi") raise ValueError("vol_clamp must satisfy 0 < lo <= hi")
@@ -2094,8 +2126,26 @@ def _simulate_portfolio(
entries_by_ord: dict[int, list[dict]] = defaultdict(list) entries_by_ord: dict[int, list[dict]] = defaultdict(list)
start_ord = start_date.toordinal() if start_date is not None else None start_ord = start_date.toordinal() if start_date is not None else None
measurement_start_ord = (
measurement_start_date.toordinal()
if measurement_start_date is not None
else start_ord
)
hard_end_ord = hard_end_date.toordinal() if hard_end_date is not None else None
# Explicit simulator/holdout end dates are exclusive split boundaries. # Explicit simulator/holdout end dates are exclusive split boundaries.
end_ord = end_date.toordinal() if end_date is not None else None end_ord = end_date.toordinal() if end_date is not None else None
if (
start_ord is not None
and measurement_start_ord is not None
and measurement_start_ord < start_ord
):
raise ValueError("measurement_start_date cannot precede start_date")
if (
hard_end_ord is not None
and measurement_start_ord is not None
and hard_end_ord <= measurement_start_ord
):
raise ValueError("hard_end_date must follow measurement_start_date")
for c in candidates: for c in candidates:
if not qualified_fn(c) or c.get("direction") != "long": if not qualified_fn(c) or c.get("direction") != "long":
continue continue
@@ -2104,6 +2154,8 @@ def _simulate_portfolio(
continue continue
if end_ord is not None and entry_ord >= end_ord: if end_ord is not None and entry_ord >= end_ord:
continue # holdout/validation: entries strictly before the split continue # holdout/validation: entries strictly before the split
if hard_end_ord is not None and entry_ord >= hard_end_ord:
continue
if not c.get("entry") or not c.get("stop"): if not c.get("entry") or not c.get("stop"):
continue continue
entries_by_ord[entry_ord].append(c) entries_by_ord[entry_ord].append(c)
@@ -2116,7 +2168,12 @@ def _simulate_portfolio(
} }
first_ord = start_ord if start_ord is not None else min(entries_by_ord) first_ord = start_ord if start_ord is not None else min(entries_by_ord)
calendar = sorted({o for cols in prices.values() for o in cols[0] if o >= first_ord}) full_calendar = sorted({o for cols in prices.values() for o in cols[0]})
calendar = [
o
for o in full_calendar
if o >= first_ord and (hard_end_ord is None or o < hard_end_ord)
]
if not calendar: if not calendar:
return None return None
@@ -2124,6 +2181,7 @@ def _simulate_portfolio(
# fill lag). Prevents trailing flat-cash after the last resolvable entry — # fill lag). Prevents trailing flat-cash after the last resolvable entry —
# the clear-air train-window bug — for train, validation, and full-period # the clear-air train-window bug — for train, validation, and full-period
# books alike (including max-hold sweeps out to 90 days). # books alike (including max-hold sweeps out to 90 days).
if hard_end_ord is None:
last_signal_ord = max(entries_by_ord) last_signal_ord = max(entries_by_ord)
resolve_pad = hold_days + (1 if fill_mode in DELAYED_FILL_MODES else 0) resolve_pad = hold_days + (1 if fill_mode in DELAYED_FILL_MODES else 0)
cut = bisect.bisect_left(calendar, last_signal_ord) + resolve_pad + 1 cut = bisect.bisect_left(calendar, last_signal_ord) + resolve_pad + 1
@@ -2131,13 +2189,31 @@ def _simulate_portfolio(
if not calendar: if not calendar:
return None return None
weekly_rebalance_ords: set[int] = set()
for index, session_ord in enumerate(full_calendar):
session_date = date.fromordinal(session_ord)
iso = session_date.isocalendar()
if index + 1 < len(full_calendar):
next_iso = date.fromordinal(full_calendar[index + 1]).isocalendar()
if (iso.year, iso.week) != (next_iso.year, next_iso.week):
weekly_rebalance_ords.add(session_ord)
elif session_date.weekday() == 4:
weekly_rebalance_ords.add(session_ord)
cash = SIM_STARTING_CAPITAL cash = SIM_STARTING_CAPITAL
positions: dict[str, dict] = {} positions: dict[str, dict] = {}
curve: list[tuple[int, float]] = [] curve: list[tuple[int, float]] = []
trades: list[dict] = [] trades: list[dict] = []
skipped_full = 0 skipped_full = 0
measurement_skipped_full = 0
skipped_cooldown = 0 skipped_cooldown = 0
skipped_corr = 0 skipped_corr = 0
skipped_min_initial_risk = 0
measurement_skipped_min_initial_risk = 0
opened_positions = 0
measurement_opened_positions = 0
weekly_rank_rejected_entries = 0
measurement_weekly_rank_rejected_entries = 0
skipped_missing_fill = 0 skipped_missing_fill = 0
skipped_gap_cap = 0 skipped_gap_cap = 0
cooldown_until_index: dict[str, int] = {} cooldown_until_index: dict[str, int] = {}
@@ -2152,6 +2228,12 @@ def _simulate_portfolio(
vol_scalars: list[float] = [] vol_scalars: list[float] = []
overnight_slippage_pct: list[float] = [] overnight_slippage_pct: list[float] = []
pending_delayed: list[dict] = [] pending_delayed: list[dict] = []
measurement_start_equity: float | None = None
measurement_start_position_count: int | None = None
capacity_samples: list[dict[str, float | int]] = []
weekly_rebalance_events: list[dict] = []
rebalance_exit_index: dict[str, tuple[int, int]] = {}
rebalance_reentry_events: list[dict] = []
def _bar(sym: str, o: int): def _bar(sym: str, o: int):
idx = index_of.get(sym, {}).get(o) idx = index_of.get(sym, {}).get(o)
@@ -2221,6 +2303,13 @@ def _simulate_portfolio(
cost = proceeds * cost_rate cost = proceeds * cost_rate
cash += proceeds - cost cash += proceeds - cost
risk = pos["entry"] - pos["initial_stop"] risk = pos["entry"] - pos["initial_stop"]
initial_risk_dollars = pos["shares"] * risk
net_pnl = (
proceeds
- pos["shares"] * pos["entry"]
- cost
- pos["entry_cost"]
)
trades.append({ trades.append({
"symbol": sym, "symbol": sym,
"entry_ord": pos["entry_ord"], "entry_ord": pos["entry_ord"],
@@ -2229,8 +2318,13 @@ def _simulate_portfolio(
"initial_stop": pos["initial_stop"], "initial_stop": pos["initial_stop"],
"active_stop": pos["stop"], "active_stop": pos["stop"],
"fill": fill, "fill": fill,
"pnl": proceeds - pos["shares"] * pos["entry"] - cost - pos["entry_cost"], "shares": pos["shares"],
"initial_risk_dollars": initial_risk_dollars,
"pnl": net_pnl,
"r": (fill - pos["entry"]) / risk if risk > 0 else 0.0, "r": (fill - pos["entry"]) / risk if risk > 0 else 0.0,
"net_r": net_pnl / initial_risk_dollars
if initial_risk_dollars > 0
else 0.0,
"hold": pos["bars_held"], "hold": pos["bars_held"],
"reason": reason, "reason": reason,
"stop_refreshes": pos["stop_refreshes"], "stop_refreshes": pos["stop_refreshes"],
@@ -2245,6 +2339,13 @@ def _simulate_portfolio(
cooldown_sessions = max(0, int(reentry_cooldown_sessions)) cooldown_sessions = max(0, int(reentry_cooldown_sessions))
for calendar_index, o in enumerate(calendar): for calendar_index, o in enumerate(calendar):
in_measurement = (
measurement_start_ord is None or o >= measurement_start_ord
)
if in_measurement and measurement_start_equity is None:
measurement_start_equity = _marked_equity()
measurement_start_position_count = len(positions)
# 1) exits on today's bars (stop intraday, target intraday, time at close) # 1) exits on today's bars (stop intraday, target intraday, time at close)
for sym in list(positions): for sym in list(positions):
pos = positions[sym] pos = positions[sym]
@@ -2358,6 +2459,82 @@ def _simulate_portfolio(
reverse=True, reverse=True,
) )
weekly_selected_entries: list[dict] | None = None
if weekly_top_n_rebalance and o in weekly_rebalance_ords:
assert max_positions is not None
assert daily_rank_map is not None
asof = date.fromordinal(o).isoformat()
protected: set[str] = set()
ranked_pool: list[tuple[float, int, str, dict | None]] = []
for sym in positions:
rank_row = daily_rank_map.get((sym, asof))
current_rank = (
rank_row.get("strategy_rank") if rank_row is not None else None
)
if current_rank is None or _bar(sym, o) is None:
protected.add(sym)
continue
ranked_pool.append((float(current_rank), 0, sym, None))
entrants_by_symbol: dict[str, dict] = {}
for candidate in signal_todays:
sym = str(candidate["symbol"])
if sym in positions or sym in entrants_by_symbol:
continue
entrants_by_symbol[sym] = candidate
eligible_entrants = 0
for sym, candidate in entrants_by_symbol.items():
rank_row = daily_rank_map.get((sym, asof))
current_rank = (
rank_row.get("strategy_rank") if rank_row is not None else None
)
if current_rank is None:
continue
eligible_entrants += 1
ranked_pool.append((float(current_rank), 1, sym, candidate))
available_slots = max(0, int(max_positions) - len(protected))
ranked_pool.sort(key=lambda row: (-row[0], row[1], row[2]))
selected = ranked_pool[:available_slots]
selected_holding_symbols = {
sym for _rank, kind, sym, _candidate in selected if kind == 0
}
weekly_selected_entries = [
candidate
for _rank, kind, _sym, candidate in selected
if kind == 1 and candidate is not None
]
selected_entrant_symbols = {
str(candidate["symbol"]) for candidate in weekly_selected_entries
}
rejected_now = max(0, eligible_entrants - len(selected_entrant_symbols))
weekly_rank_rejected_entries += rejected_now
if in_measurement:
measurement_weekly_rank_rejected_entries += rejected_now
exited_symbols: list[str] = []
for sym in list(positions):
if sym in protected or sym in selected_holding_symbols:
continue
bar = _bar(sym, o)
if bar is None:
continue
_close_trade(sym, float(bar.close), "weekly_rebalance")
rebalance_exit_index[sym] = (calendar_index, o)
exited_symbols.append(sym)
weekly_rebalance_events.append({
"ord": o,
"fresh_entrant_pool": len(entrants_by_symbol),
"rank_eligible_entrant_pool": eligible_entrants,
"selected_entrants": len(selected_entrant_symbols),
"replacements": len(exited_symbols),
"exited_symbols": sorted(exited_symbols),
"selected_entrant_symbols": sorted(selected_entrant_symbols),
"measurement": in_measurement,
})
equity = _marked_equity()
if fill_mode in DELAYED_FILL_MODES: if fill_mode in DELAYED_FILL_MODES:
fill_candidates = sorted( fill_candidates = sorted(
pending_delayed, pending_delayed,
@@ -2366,7 +2543,11 @@ def _simulate_portfolio(
) )
pending_delayed = [] pending_delayed = []
else: else:
fill_candidates = signal_todays fill_candidates = (
weekly_selected_entries
if weekly_selected_entries is not None
else signal_todays
)
def _corr_scale_for(sym: str, asof_idx: int) -> float | None: def _corr_scale_for(sym: str, asof_idx: int) -> float | None:
"""1.0 ok, 0.5 half-size, None = skip. Missing history → uncorrelated.""" """1.0 ok, 0.5 half-size, None = skip. Missing history → uncorrelated."""
@@ -2411,15 +2592,21 @@ def _simulate_portfolio(
corr_scale: float, corr_scale: float,
fill_bar: Any | None, fill_bar: Any | None,
) -> None: ) -> None:
nonlocal cash, equity, skipped_full, skipped_cooldown, post_stop_events nonlocal cash, equity, skipped_full, measurement_skipped_full
nonlocal skipped_cooldown, post_stop_events
nonlocal skipped_min_initial_risk
nonlocal measurement_skipped_min_initial_risk
nonlocal opened_positions, measurement_opened_positions
sym = c["symbol"] sym = c["symbol"]
if sym in positions: if sym in positions:
return return
if calendar_index < cooldown_until_index.get(sym, -1): if calendar_index < cooldown_until_index.get(sym, -1):
skipped_cooldown += 1 skipped_cooldown += 1
return return
if len(positions) >= max_positions: if max_positions is not None and len(positions) >= max_positions:
skipped_full += 1 skipped_full += 1
if in_measurement:
measurement_skipped_full += 1
return return
risk_ps = entry - stop risk_ps = entry - stop
if risk_ps <= 0 or entry <= 0: if risk_ps <= 0 or entry <= 0:
@@ -2436,6 +2623,16 @@ def _simulate_portfolio(
(equity * SIM_NOTIONAL_CAP) / entry, (equity * SIM_NOTIONAL_CAP) / entry,
max(cash, 0.0) / (entry * (1.0 + cost_rate)), max(cash, 0.0) / (entry * (1.0 + cost_rate)),
) )
initial_risk_dollars = shares * risk_ps
if (
min_initial_risk_fraction is not None
and initial_risk_dollars
< equity * float(min_initial_risk_fraction)
):
skipped_min_initial_risk += 1
if in_measurement:
measurement_skipped_min_initial_risk += 1
return
if shares * entry < 1.0: if shares * entry < 1.0:
return return
entry_cost = shares * entry * cost_rate entry_cost = shares * entry * cost_rate
@@ -2475,6 +2672,21 @@ def _simulate_portfolio(
"vol_scalar": scalar, "vol_scalar": scalar,
"corr_scale": corr_scale, "corr_scale": corr_scale,
} }
opened_positions += 1
if in_measurement:
measurement_opened_positions += 1
prior_rebalance_exit = rebalance_exit_index.pop(sym, None)
if prior_rebalance_exit is not None:
prior_exit_index, prior_exit_ord = prior_rebalance_exit
rebalance_reentry_events.append({
"symbol": sym,
"exit_ord": prior_exit_ord,
"exit_calendar_index": prior_exit_index,
"reentry_calendar_index": calendar_index,
"wait_sessions": calendar_index - prior_exit_index,
"reentry_ord": entry_ord,
"measurement": in_measurement,
})
# next_open only: fill is at the open, so the rest of the bar can stop out. # next_open only: fill is at the open, so the rest of the bar can stop out.
# stale_close fills at the close — same-day stop after entry does not apply. # stale_close fills at the close — same-day stop after entry does not apply.
# bars_held stays 0 on the fill day (matches close-fill cadence). # bars_held stays 0 on the fill day (matches close-fill cadence).
@@ -2576,7 +2788,25 @@ def _simulate_portfolio(
# Queue today's signals for the next session's fill. # Queue today's signals for the next session's fill.
pending_delayed.extend(signal_todays) pending_delayed.extend(signal_todays)
curve.append((o, _marked_equity())) marked_equity = _marked_equity()
if in_measurement and include_capacity_diagnostics:
gross_notional = sum(
pos["shares"] * pos["last_close"] for pos in positions.values()
)
capacity_samples.append({
"positions": len(positions),
"cash_pct": cash / marked_equity * 100.0
if marked_equity > 0
else 0.0,
"gross_exposure_pct": gross_notional / marked_equity * 100.0
if marked_equity > 0
else 0.0,
"at_capacity": int(
max_positions is not None
and len(positions) >= max_positions
),
})
curve.append((o, marked_equity))
# Close whatever is still open at its last mark so final equity is realized. # Close whatever is still open at its last mark so final equity is realized.
for sym in list(positions): for sym in list(positions):
@@ -2584,32 +2814,57 @@ def _simulate_portfolio(
final_equity = cash final_equity = cash
curve[-1] = (calendar[-1], final_equity) curve[-1] = (calendar[-1], final_equity)
total_return_pct = (final_equity / SIM_STARTING_CAPITAL - 1.0) * 100.0 metric_start_ord = (
years = (calendar[-1] - calendar[0]) / 365.25 measurement_start_ord if measurement_start_ord is not None else calendar[0]
)
metric_curve = [(day_ord, eq) for day_ord, eq in curve if day_ord >= metric_start_ord]
if not metric_curve:
return None
metric_base_equity = (
measurement_start_equity
if measurement_start_date is not None and measurement_start_equity is not None
else SIM_STARTING_CAPITAL
)
total_return_pct = (final_equity / metric_base_equity - 1.0) * 100.0
years = (calendar[-1] - metric_start_ord) / 365.25
cagr_pct = ( cagr_pct = (
((final_equity / SIM_STARTING_CAPITAL) ** (1.0 / years) - 1.0) * 100.0 ((final_equity / metric_base_equity) ** (1.0 / years) - 1.0) * 100.0
if years > 0.25 and final_equity > 0 if years > 0.25 and final_equity > 0
else None else None
) )
peak = float("-inf") peak = float("-inf")
max_dd = 0.0 max_dd = 0.0
for _, eq in curve: drawdown_equities = (
[metric_base_equity, *(eq for _, eq in metric_curve)]
if measurement_start_date is not None
else [eq for _, eq in metric_curve]
)
for eq in drawdown_equities:
peak = max(peak, eq) peak = max(peak, eq)
if peak > 0: if peak > 0:
max_dd = max(max_dd, (peak - eq) / peak) max_dd = max(max_dd, (peak - eq) / peak)
rets = [b / a - 1.0 for (_, a), (_, b) in zip(curve, curve[1:]) if a > 0] return_equities = (
[metric_base_equity, *(eq for _, eq in metric_curve)]
if measurement_start_date is not None
else [eq for _, eq in metric_curve]
)
rets = [
b / a - 1.0
for a, b in zip(return_equities, return_equities[1:])
if a > 0
]
diag = sharpe_diagnostics(rets) diag = sharpe_diagnostics(rets)
sharpe = diag["sharpe"] sharpe = diag["sharpe"]
# Per-calendar-year returns off the equity curve — shows whether every year # Per-calendar-year returns off the equity curve — shows whether every year
# contributed or one exceptional stretch carried the result. # contributed or one exceptional stretch carried the result.
yearly: list[dict] = [] yearly: list[dict] = []
year_start_eq = curve[0][1] year_start_eq = metric_base_equity
cur_year = date.fromordinal(curve[0][0]).year cur_year = date.fromordinal(metric_start_ord).year
last_eq = curve[0][1] last_eq = metric_base_equity
for o, eq in curve: for o, eq in metric_curve:
y = date.fromordinal(o).year y = date.fromordinal(o).year
if y != cur_year: if y != cur_year:
yearly.append({ yearly.append({
@@ -2628,24 +2883,29 @@ def _simulate_portfolio(
), ),
}) })
pnls = [t["pnl"] for t in trades] metric_trades = [
trade for trade in trades if trade["entry_ord"] >= metric_start_ord
]
pnls = [t["pnl"] for t in metric_trades]
wins = sum(1 for p in pnls if p > 0) wins = sum(1 for p in pnls if p > 0)
reason_counts = { reason_counts = {
reason: sum(1 for t in trades if t["reason"] == reason) reason: sum(1 for t in metric_trades if t["reason"] == reason)
for reason in sorted({t["reason"] for t in trades}) for reason in sorted({t["reason"] for t in metric_trades})
} }
spy_pct = None spy_pct = None
if spy_closes: if spy_closes:
from app.services.benchmark_service import benchmark_return_pct from app.services.benchmark_service import benchmark_return_pct
spy_pct = benchmark_return_pct( spy_pct = benchmark_return_pct(
spy_closes, date.fromordinal(calendar[0]), date.fromordinal(calendar[-1]) spy_closes,
date.fromordinal(metric_start_ord),
date.fromordinal(calendar[-1]),
) )
curve_payload: list[dict] | None = None curve_payload: list[dict] | None = None
benchmark_payload: list[dict] | None = None benchmark_payload: list[dict] | None = None
if include_curve: if include_curve:
curve_base = curve[0][1] if curve else SIM_STARTING_CAPITAL curve_base = metric_base_equity
curve_payload = [ curve_payload = [
{ {
"date": date.fromordinal(o).isoformat(), "date": date.fromordinal(o).isoformat(),
@@ -2654,12 +2914,12 @@ def _simulate_portfolio(
if curve_base > 0 if curve_base > 0
else None, else None,
} }
for o, eq in curve for o, eq in metric_curve
] ]
if spy_closes: if spy_closes:
benchmark_payload = [] benchmark_payload = []
base_spy = None base_spy = None
for o, _ in curve: for o, _ in metric_curve:
d = date.fromordinal(o) d = date.fromordinal(o)
close = spy_closes.get(d) close = spy_closes.get(d)
if close is None or close <= 0: if close is None or close <= 0:
@@ -2678,6 +2938,8 @@ def _simulate_portfolio(
calmar = float(cagr_pct) / max_dd_pct calmar = float(cagr_pct) / max_dd_pct
result = { result = {
"starting_capital": SIM_STARTING_CAPITAL, "starting_capital": SIM_STARTING_CAPITAL,
"measurement_start_equity": round(metric_base_equity, 2),
"measurement_start_positions": measurement_start_position_count or 0,
"cost_per_side_pct": round(cost_rate * 100.0, 3), "cost_per_side_pct": round(cost_rate * 100.0, 3),
"fill_mode": fill_mode, "fill_mode": fill_mode,
"final_equity": round(final_equity, 2), "final_equity": round(final_equity, 2),
@@ -2691,23 +2953,161 @@ def _simulate_portfolio(
"n_returns": diag["n_returns"], "n_returns": diag["n_returns"],
"return_skew": diag["return_skew"], "return_skew": diag["return_skew"],
"return_kurtosis": diag["return_kurtosis"], "return_kurtosis": diag["return_kurtosis"],
"trades": len(trades), "trades": len(metric_trades),
"win_rate": round(wins / len(trades) * 100.0, 1) if trades else None, "win_rate": (
round(wins / len(metric_trades) * 100.0, 1)
if metric_trades
else None
),
"avg_trade_pnl": round(sum(pnls) / len(pnls), 2) if pnls else None, "avg_trade_pnl": round(sum(pnls) / len(pnls), 2) if pnls else None,
"best_trade_r": round(max(t["r"] for t in trades), 2) if trades else None, "best_trade_r": (
"worst_trade_r": round(min(t["r"] for t in trades), 2) if trades else None, round(max(t["r"] for t in metric_trades), 2)
if metric_trades
else None
),
"worst_trade_r": (
round(min(t["r"] for t in metric_trades), 2)
if metric_trades
else None
),
"best_trade_pnl": round(max(pnls), 2) if pnls else None, "best_trade_pnl": round(max(pnls), 2) if pnls else None,
"worst_trade_pnl": round(min(pnls), 2) if pnls else None, "worst_trade_pnl": round(min(pnls), 2) if pnls else None,
"avg_hold_days": ( "avg_hold_days": (
round(sum(t["hold"] for t in trades) / len(trades), 1) if trades else None round(
sum(t["hold"] for t in metric_trades) / len(metric_trades),
1,
)
if metric_trades
else None
), ),
"exit_reasons": reason_counts, "exit_reasons": reason_counts,
"skipped_book_full": skipped_full, "skipped_book_full": skipped_full,
"spy_return_pct": round(spy_pct, 1) if spy_pct is not None else None, "spy_return_pct": round(spy_pct, 1) if spy_pct is not None else None,
"yearly_returns": yearly, "yearly_returns": yearly,
"start_date": date.fromordinal(calendar[0]).isoformat(), "start_date": date.fromordinal(metric_start_ord).isoformat(),
"end_date": date.fromordinal(calendar[-1]).isoformat(), "end_date": date.fromordinal(calendar[-1]).isoformat(),
} }
if measurement_start_date is not None:
result["simulation_start_date"] = date.fromordinal(calendar[0]).isoformat()
if hard_end_date is not None:
result["hard_end_date_exclusive"] = hard_end_date.isoformat()
if measurement_start_date is not None:
result["measurement_skipped_book_full"] = measurement_skipped_full
result["measurement_opened_positions"] = measurement_opened_positions
if min_initial_risk_fraction is not None:
result["min_initial_risk_fraction"] = float(min_initial_risk_fraction)
result["skipped_min_initial_risk"] = skipped_min_initial_risk
result["measurement_skipped_min_initial_risk"] = (
measurement_skipped_min_initial_risk
)
if include_capacity_diagnostics:
measured_opened = (
measurement_opened_positions
if measurement_start_date is not None
else opened_positions
)
measured_full = (
measurement_skipped_full
if measurement_start_date is not None
else skipped_full
)
capacity_opportunities = measured_opened + measured_full
result["opened_positions"] = measured_opened
result["capacity_opportunities"] = capacity_opportunities
result["blocked_fraction"] = (
round(measured_full / capacity_opportunities, 6)
if capacity_opportunities
else 0.0
)
result["avg_positions"] = (
round(
sum(float(sample["positions"]) for sample in capacity_samples)
/ len(capacity_samples),
4,
)
if capacity_samples
else 0.0
)
result["peak_positions"] = (
max(int(sample["positions"]) for sample in capacity_samples)
if capacity_samples
else 0
)
result["sessions_at_capacity"] = sum(
int(sample["at_capacity"]) for sample in capacity_samples
)
result["sessions_measured"] = len(capacity_samples)
result["avg_cash_pct"] = (
round(
sum(float(sample["cash_pct"]) for sample in capacity_samples)
/ len(capacity_samples),
4,
)
if capacity_samples
else None
)
result["avg_gross_exposure_pct"] = (
round(
sum(
float(sample["gross_exposure_pct"])
for sample in capacity_samples
)
/ len(capacity_samples),
4,
)
if capacity_samples
else None
)
if weekly_top_n_rebalance:
measured_events = [
event for event in weekly_rebalance_events if event["measurement"]
]
measured_reentries = [
event for event in rebalance_reentry_events if event["measurement"]
]
result["weekly_rank_rejected_entries"] = (
measurement_weekly_rank_rejected_entries
if measurement_start_date is not None
else weekly_rank_rejected_entries
)
result["weekly_rebalance_events"] = [
{
**{
key: value
for key, value in event.items()
if key not in {"ord", "measurement"}
},
"date": date.fromordinal(event["ord"]).isoformat(),
}
for event in measured_events
]
result["rebalance_reentry_events"] = [
{
**{
key: value
for key, value in event.items()
if key
not in {
"exit_ord",
"reentry_ord",
"measurement",
"exit_calendar_index",
"reentry_calendar_index",
}
},
"exit_date": date.fromordinal(event["exit_ord"]).isoformat(),
"reentry_date": date.fromordinal(
event["reentry_ord"]
).isoformat(),
}
for event in measured_reentries
]
for session_limit in (5, 10, 20):
result[f"rebalance_reentries_within_{session_limit}_sessions"] = sum(
1
for event in measured_reentries
if int(event["wait_sessions"]) <= session_limit
)
if vol_target is not None: if vol_target is not None:
result["vol_target"] = vol_target result["vol_target"] = vol_target
result["vol_lookback"] = int(vol_lookback) result["vol_lookback"] = int(vol_lookback)
@@ -2782,7 +3182,7 @@ def _simulate_portfolio(
"entry_date": date.fromordinal(trade["entry_ord"]).isoformat(), "entry_date": date.fromordinal(trade["entry_ord"]).isoformat(),
"exit_date": date.fromordinal(trade["exit_ord"]).isoformat(), "exit_date": date.fromordinal(trade["exit_ord"]).isoformat(),
} }
for trade in trades for trade in metric_trades
] ]
return result return result
+17 -6
View File
@@ -72,15 +72,25 @@ def _breadth_from_closes(
return _breadth_with_counts(closes_by_symbol, window, min_tickers)[0] return _breadth_with_counts(closes_by_symbol, window, min_tickers)[0]
# Breadth deterioration counts fully when price masks it (true divergence, the
# dangerous pre-top case) and at CONFIRMED_FLOOR when price falls with it.
# v2 used a hard ``price_ret >= 0`` cliff, which zeroed the sensor during every
# decline -- so on 2026-07-24, with the basket shedding 10 percentage points
# above their 200-DMA in 20 sessions, Warning read exactly 0. Breadth *level*
# lives in State but breadth *velocity* appears nowhere else, so partial credit
# here is not double counting.
DIVERGENCE_CONFIRMED_FLOOR = 0.35
DIVERGENCE_TAPER_PCT = 3.0
def compute_divergence_series( def compute_divergence_series(
breadth: dict[date, float], benchmark_closes: Series, lookback: int = 20 breadth: dict[date, float], benchmark_closes: Series, lookback: int = 20
) -> dict[date, float]: ) -> dict[date, float]:
"""Early-warning score (0-100, high = fragile) per date. """Early-warning score (0-100, high = fragile) per date.
This is deliberately a pure divergence: it is positive only when benchmark A 20 percentage-point breadth deterioration maps to 100 when the benchmark
price holds/rises while breadth falls. Absolute low breadth belongs in the is flat or rising, tapering to ``DIVERGENCE_CONFIRMED_FLOOR`` of that once
State score, so it is not counted again here. A 20 percentage-point breadth the benchmark is down ``DIVERGENCE_TAPER_PCT`` or more over the window.
deterioration maps to 100.
""" """
bench = {d: c for d, c in benchmark_closes} bench = {d: c for d, c in benchmark_closes}
common = sorted(d for d in bench if d in breadth) common = sorted(d for d in bench if d in breadth)
@@ -93,8 +103,9 @@ def compute_divergence_series(
price_ret = (bench[d] / price_past - 1.0) * 100.0 # % price_ret = (bench[d] / price_past - 1.0) * 100.0 # %
breadth_chg = breadth[d] - breadth[d0] # percentage points breadth_chg = breadth[d] - breadth[d0] # percentage points
deterioration = max(0.0, -breadth_chg) deterioration = max(0.0, -breadth_chg)
score = deterioration * 5.0 if price_ret >= 0 else 0.0 taper = max(0.0, min(1.0, (price_ret + DIVERGENCE_TAPER_PCT) / DIVERGENCE_TAPER_PCT))
out[d] = max(0.0, min(100.0, round(score, 2))) gate = DIVERGENCE_CONFIRMED_FLOOR + (1.0 - DIVERGENCE_CONFIRMED_FLOOR) * taper
out[d] = max(0.0, min(100.0, round(deterioration * 5.0 * gate, 2)))
return out return out
+348
View File
@@ -0,0 +1,348 @@
"""Source-agnostic batch import framework (Dolt/SEC bulk data → PostgreSQL).
Every bulk importer (SEC facts, Dolt earnings, later Dolt stocks) plugs into
``run_import`` and gets, for free, the plan's non-negotiables:
- **One run per source at a time** a Postgres *session-level* advisory lock
keyed by source. It is held on a single pinned connection for the whole run,
so it survives the intermediate commits (the ``running`` row, then the
promotion) and only releases at the end. No-op on non-Postgres (tests).
- **Idempotent per revision** the cheap ``detect_revision`` probe is compared
against the last *promoted* run; an unchanged revision records a ``no_op``
with **zero row changes** (no expensive fetch, no writes).
- **Staging then atomic promotion** the importer stages into an in-memory
object (no physical staging tables), validation reads it, and only a passing
run calls ``promote`` whose writes commit together with the run-row flip to
``promoted`` in a single transaction.
- **Failure is inert** a failed validation or a mid-run exception marks the
run ``failed``, alerts via the system-events path, and leaves the live tables
exactly as they were (nothing is written before ``promote``).
Every attempt promoted, no_op, or failed is recorded in ``data_import_runs``.
KISS: no conflicts table (summaries go in ``validation_json``), no revision
table (idempotency queries the last run), no aggregate tables.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from typing import Any, Protocol, runtime_checkable
from sqlalchemy import exists, select, text
from sqlalchemy.engine import Engine # noqa: F401 (typing only)
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.database import engine as app_engine
from app.models.data_import_run import DataImportRun
from app.services import system_event_service
logger = logging.getLogger(__name__)
# data_import_runs.status values
STATUS_RUNNING = "running"
STATUS_VALIDATED = "validated"
STATUS_PROMOTED = "promoted"
STATUS_NO_OP = "no_op"
STATUS_DEFERRED = "deferred"
STATUS_FAILED = "failed"
_MAX_ERROR_LEN = 4000
@dataclass
class ValidationResult:
"""Outcome of an importer's validation gates.
``summary`` is serialized into ``validation_json`` (reconciliation /
discrepancy details live here no separate conflicts table). ``validate``
MUST be read-only: it reads the staged object and, if needed, live tables
for comparison, but writes nothing that invariant is what makes a failed
run leave the dataset untouched.
"""
ok: bool
summary: dict[str, Any] = field(default_factory=dict)
source_max_date: date | None = None
messages: list[str] = field(default_factory=list)
# Expected source-side lag: retry without an immediate error alert. Sources
# can bound the quiet period with deferred_alert_after_days. Only meaningful
# when ok=False.
retryable: bool = False
deferred_alert_after_days: int | None = None
deferred_alert_messages: list[str] = field(default_factory=list)
@runtime_checkable
class SourceImporter(Protocol):
"""Interface a concrete bulk importer implements. All methods receive the
session bound to the lock-holding connection; ``stage`` and ``validate``
never write to live tables, only ``promote`` does."""
source: str # sec_facts | dolt_earnings | dolt_stocks
async def detect_revision(self, db: AsyncSession) -> str | None:
"""Cheap probe of the source revision (Dolt commit / SEC archive SHA).
Returns the revision id, or None when it can't be determined cheaply
(in which case idempotency is skipped and the run always stages)."""
...
async def stage(self, db: AsyncSession) -> Any:
"""Download/parse into an in-memory staged representation. No writes to
live tables."""
...
async def validate(self, db: AsyncSession, staged: Any) -> ValidationResult:
"""Run the source's validation gates against ``staged``. Read-only."""
...
async def promote(self, db: AsyncSession, staged: Any, run_id: int) -> dict[str, int]:
"""Apply ``staged`` to the live tables. Called inside the promotion
transaction; the caller commits. ``run_id`` is the current
``data_import_runs.id`` so written rows can be stamped with their
``import_run_id``. Returns row-count deltas."""
...
def _advisory_key(source: str) -> int:
"""Deterministic signed 64-bit key for a source's advisory lock."""
digest = hashlib.blake2b(source.encode("utf-8"), digest_size=8).digest()
return int.from_bytes(digest, "big", signed=True)
async def _last_promoted_revision(db: AsyncSession, source: str) -> str | None:
"""Revision of the most recent *promoted* run for ``source`` (the revision
currently loaded), or None if none has promoted yet."""
row = await db.execute(
select(DataImportRun.revision)
.where(
DataImportRun.source == source,
DataImportRun.status == STATUS_PROMOTED,
)
.order_by(DataImportRun.id.desc())
.limit(1)
)
return row.scalar_one_or_none()
async def _promotion_state_since(
db: AsyncSession, source: str, cutoff: datetime
) -> str:
promoted = (
DataImportRun.source == source,
DataImportRun.status == STATUS_PROMOTED,
)
ever, recent = (
await db.execute(
select(
exists().where(*promoted),
exists().where(*promoted, DataImportRun.started_at >= cutoff),
)
)
).one()
return "recent" if recent else "stale" if ever else "never"
def _now() -> datetime:
return datetime.now(timezone.utc)
async def _alert(
db: AsyncSession,
source: str,
code: str,
messages: list[str],
*,
severity: str = "error",
dedup_hours: int = 24,
) -> None:
try:
await system_event_service.log_event(
db,
severity=severity,
source="data_import",
code=f"{source}_{code}",
message=(("; ".join(messages)) or code)[:_MAX_ERROR_LEN],
dedup_key=f"data_import:{source}:{code}",
dedup_hours=dedup_hours,
)
except Exception: # noqa: BLE001 — alerting must never mask the real outcome
logger.exception("Failed to emit data_import alert %s/%s", source, code)
async def run_import(
importer: SourceImporter,
*,
engine: AsyncEngine | None = None,
force: bool = False,
) -> DataImportRun | None:
"""Run one import for ``importer``.
Returns the recorded ``DataImportRun`` (promoted / no_op / deferred /
failed), or None
when the per-source advisory lock is already held (another run is active).
``force`` runs even when the revision is unchanged. The revision tracks the
*source*, so a re-import driven by a change on our side a parser fix that
makes stored rows stale is a no_op under the normal gate. Manually invoked
only; scheduled jobs must leave it False so an unchanged source stays a no_op.
"""
engine = engine or app_engine
source = importer.source
is_pg = engine.dialect.name == "postgresql"
key = _advisory_key(source)
async with engine.connect() as conn:
# Bind the session to this one connection so the session-level advisory
# lock persists across our commits. expire_on_commit must be set here —
# the app factory's setting doesn't carry to a directly-built session.
session = AsyncSession(bind=conn, expire_on_commit=False)
try:
if is_pg:
got = (
await session.execute(
text("SELECT pg_try_advisory_lock(:k)"), {"k": key}
)
).scalar()
await session.commit()
if not got:
logger.info("data_import %s: lock held, skipping", source)
return None
# Record the attempt FIRST — before the external revision probe, the
# most likely failure — so anything below is recorded and alerted and
# never escapes unrecorded. Revision is filled in once detected.
run = DataImportRun(
source=source,
status=STATUS_RUNNING,
started_at=_now(),
)
session.add(run)
await session.commit()
await session.refresh(run)
try:
revision = await importer.detect_revision(session)
run.revision = revision
last_rev = await _last_promoted_revision(session, source)
if not force and revision is not None and revision == last_rev:
run.status = STATUS_NO_OP
run.completed_at = _now()
await session.commit()
logger.info("data_import %s: no_op (revision %s)", source, revision)
return run
staged = await importer.stage(session)
result = await importer.validate(session, staged)
run.source_max_date = result.source_max_date
run.validation_json = json.dumps(result.summary, default=str)
if not result.ok:
run.error_details = ("; ".join(result.messages))[:_MAX_ERROR_LEN]
run.completed_at = _now()
if result.retryable:
run.status = STATUS_DEFERRED
await session.commit()
alert_days = result.deferred_alert_after_days
if alert_days is not None:
alert_days = max(1, alert_days)
cutoff = run.started_at - timedelta(days=alert_days)
promotion_state = await _promotion_state_since(
session, source, cutoff
)
if promotion_state != "recent":
history = (
f"{source} import has never promoted successfully"
if promotion_state == "never"
else f"{source} import has not promoted successfully "
f"within {alert_days} day(s)"
)
await _alert(
session,
source,
"deferred_stale",
[
f"{history}; import remains deferred",
*result.deferred_alert_messages,
f"Current deferral: "
f"{run.error_details or 'validation deferred'}",
],
severity="warning",
dedup_hours=alert_days * 24,
)
logger.info(
"data_import %s: deferred for retry: %s",
source,
result.messages,
)
return run
run.status = STATUS_FAILED
await session.commit()
await _alert(session, source, "validation_failed", result.messages)
logger.warning(
"data_import %s: validation failed: %s",
source,
result.messages,
)
return run
# Promotion: importer writes + run-row flip in one transaction.
row_counts = await importer.promote(session, staged, run.id)
run.status = STATUS_PROMOTED
run.row_counts_json = json.dumps(row_counts, default=str)
run.completed_at = _now()
await session.commit()
await session.refresh(run)
logger.info(
"data_import %s: promoted (revision %s, rows %s)",
source,
revision,
row_counts,
)
return run
except asyncio.CancelledError:
# Deploy / scheduler shutdown: best-effort mark failed so no
# ``running`` row lingers, then let the cancellation propagate —
# never swallow it.
try:
await session.rollback()
run.status = STATUS_FAILED
run.error_details = "cancelled"
run.completed_at = _now()
await session.commit()
except BaseException: # noqa: BLE001 — best-effort during teardown
logger.warning(
"data_import %s: could not record cancellation", source
)
raise
except Exception as exc: # noqa: BLE001 — record + alert, don't crash the job
await session.rollback()
run.status = STATUS_FAILED
run.error_details = repr(exc)[:_MAX_ERROR_LEN]
run.completed_at = _now()
try:
await session.commit()
except Exception: # noqa: BLE001
logger.exception("data_import %s: failed to record failure", source)
await _alert(session, source, "import_error", [repr(exc)])
logger.exception("data_import %s: import error", source)
return run
finally:
if is_pg:
try:
await session.execute(
text("SELECT pg_advisory_unlock(:k)"), {"k": key}
)
await session.commit()
except Exception: # noqa: BLE001
logger.exception("data_import %s: failed to release lock", source)
await session.close()
+105
View File
@@ -0,0 +1,105 @@
"""Minimal async client for a local Dolt clone.
The application never runs a long-lived Dolt sql-server; it shells out to the
`dolt` CLI against a persistent clone and reads results as CSV. Every call goes
through ``asyncio.create_subprocess_exec`` because the scheduler shares one event
loop with the API (`app/scheduler.py:73`) a blocking `subprocess.run` here
would stall request handling.
Production keeps the clone in ``DOLT_DATA_DIR`` outside the deploy tree; the
binary path and data dir are configured (see ``app/config.py``). Read via
``dolt sql -r csv``; refresh with ``pull`` and record the resulting commit hash
as the import revision.
"""
from __future__ import annotations
import asyncio
import csv
import io
import logging
import shutil
from pathlib import Path
logger = logging.getLogger(__name__)
# Default subprocess timeout. A hung `dolt pull`/`sql` would otherwise pin the
# import's connection and its advisory lock indefinitely, so every call is
# bounded; callers may override per operation.
DEFAULT_TIMEOUT = 600.0
class DoltError(RuntimeError):
"""A dolt subprocess failed, timed out, or exited non-zero."""
async def _run(
binary: str, args: list[str], *, cwd: Path, timeout: float = DEFAULT_TIMEOUT
) -> str:
proc = await asyncio.create_subprocess_exec(
binary,
*args,
cwd=str(cwd),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
try:
await proc.wait()
except ProcessLookupError:
pass
raise DoltError(f"dolt {args[0] if args else ''} timed out after {timeout:.0f}s")
if proc.returncode != 0:
raise DoltError(
f"dolt {' '.join(args)} failed ({proc.returncode}): "
f"{stderr.decode('utf-8', 'replace').strip()[:500]}"
)
return stdout.decode("utf-8", "replace")
def ensure_free_disk(path: Path, min_free_gb: float) -> None:
"""Raise if free space at ``path`` is below the threshold (checked before a
pull that could grow the clone). Uses the nearest existing ancestor so it
works before the clone dir exists."""
probe = path
while not probe.exists() and probe.parent != probe:
probe = probe.parent
free_gb = shutil.disk_usage(probe).free / (1024**3)
if free_gb < min_free_gb:
raise DoltError(
f"insufficient disk for dolt at {path}: {free_gb:.1f} GB free "
f"< {min_free_gb:.1f} GB required"
)
async def pull(repo_dir: Path, *, binary: str, timeout: float = DEFAULT_TIMEOUT) -> None:
"""`dolt pull` the persistent clone to the latest upstream revision."""
await _run(binary, ["pull"], cwd=repo_dir, timeout=timeout)
async def current_commit(
repo_dir: Path, *, binary: str, timeout: float = DEFAULT_TIMEOUT
) -> str:
"""The HEAD commit hash of the clone — used as the import revision.
Uses ``DOLT_HASHOF('HEAD')`` (which formally identifies HEAD) rather than
ordering ``dolt_log`` by timestamp."""
rows = await query_csv(
repo_dir, "SELECT DOLT_HASHOF('HEAD') AS commit_hash", binary=binary, timeout=timeout
)
if not rows or not rows[0].get("commit_hash"):
raise DoltError("could not read HEAD commit hash")
return rows[0]["commit_hash"]
async def query_csv(
repo_dir: Path, sql: str, *, binary: str, timeout: float = DEFAULT_TIMEOUT
) -> list[dict[str, str]]:
"""Run a read query and parse the CSV result into a list of dict rows."""
out = await _run(binary, ["sql", "-q", sql, "-r", "csv"], cwd=repo_dir, timeout=timeout)
if not out.strip():
return []
return list(csv.DictReader(io.StringIO(out)))
+357
View File
@@ -0,0 +1,357 @@
"""Production importer for the DoltHub post-no-preference/earnings calendar.
A ``SourceImporter`` (see ``app/services/data_import.py``) that pulls the local
Dolt clone, aligns the announcement calendar to the EPS history with the pure DP
in ``earnings_alignment`` (reused from the research script, not extending it),
and writes ``earnings_events`` for the tracked universe.
Shadow by construction: nothing reads ``earnings_events`` until the API/panel
lands (A4), so writing it does not touch production behavior.
**Promotion is destructive** future-dated rows for this source are deleted and
re-inserted every run so reschedules/cancellations never linger. The forward
calendar is the project's acceptance gate, so ``validate`` is fail-closed: it
blocks promotion when the staged future set is empty or has collapsed relative
to what's already loaded.
Attribution: the earnings data is CC BY-SA 4.0 from post-no-preference/earnings.
See the repo ``NOTICE``. Internal use only no redistribution.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
from sqlalchemy import case, delete, func, select
from app.config import settings
from app.database import insert_for_session
from app.models.earnings_event import EarningsEvent
from app.models.ticker import Ticker
from app.services import dolt_client, earnings_alignment
from app.services.data_import import ValidationResult
logger = logging.getLogger(__name__)
SOURCE = "dolt_earnings"
# Earliest announcement date to import (matches the research backfill window).
WINDOW_START = date(2020, 1, 22)
# Alignment tolerances (research defaults): an announcement may lead its period
# end by up to 14 days or lag it by up to 90.
MAX_LAG_DAYS = 90
MAX_LEAD_DAYS = 14
# Fail promotion if the staged forward calendar drops below this fraction of the
# currently-loaded forward calendar (guards the destructive re-insert against a
# partial parse / symbol-mapping regression).
MIN_FUTURE_RATIO = 0.5
# Initial-load gates (when nothing is loaded yet — the ratio gate has no baseline).
# The source publishes a forward calendar; require a real horizon, not one stray
# future row. 21 days is a conservative floor under the ~35d horizon observed on
# the live clone.
MIN_FORWARD_HORIZON_DAYS = 21
# ...and require the symbol join to reach most of the tracked universe, so a
# broken/normalization-dropped join can't seed a hollow calendar.
MIN_INITIAL_COVERAGE = 0.5
_CAL_SQL = (
"SELECT act_symbol, `date`, `when` FROM earnings_calendar "
f"WHERE `date` >= '{WINDOW_START.isoformat()}'"
)
_HIST_SQL = (
"SELECT act_symbol, period_end_date, reported, estimate FROM eps_history "
f"WHERE period_end_date >= '{(WINDOW_START.replace(year=WINDOW_START.year - 1)).isoformat()}'"
)
@dataclass
class StagedEarnings:
rows: list[dict[str, Any]]
stats: dict[str, Any] = field(default_factory=dict)
future_count: int = 0
max_announce_date: date | None = None
def _now() -> datetime:
return datetime.now(timezone.utc)
class DoltEarningsImporter:
source = SOURCE
def __init__(
self,
*,
repo_dir: Path | str | None = None,
binary: str | None = None,
today: date | None = None,
do_pull: bool = True,
dolt: Any = dolt_client,
) -> None:
self.repo_dir = Path(
repo_dir
or (Path(settings.dolt_data_dir) / settings.dolt_earnings_subdir)
)
self.binary = binary or settings.dolt_binary
self.today = today or _now().date()
self.do_pull = do_pull
self._dolt = dolt # injectable for tests
# -- SourceImporter protocol -------------------------------------------
async def detect_revision(self, db) -> str | None:
timeout = settings.dolt_command_timeout_seconds
if self.do_pull:
dolt_client.ensure_free_disk(self.repo_dir, settings.dolt_min_free_disk_gb)
await self._dolt.pull(self.repo_dir, binary=self.binary, timeout=timeout)
return await self._dolt.current_commit(
self.repo_dir, binary=self.binary, timeout=timeout
)
async def stage(self, db) -> StagedEarnings:
universe = await self._load_universe(db) # {normalised symbol: ticker_id}
timeout = settings.dolt_command_timeout_seconds
cal_raw = await self._dolt.query_csv(
self.repo_dir, _CAL_SQL, binary=self.binary, timeout=timeout
)
hist_raw = await self._dolt.query_csv(
self.repo_dir, _HIST_SQL, binary=self.binary, timeout=timeout
)
_require_columns(cal_raw, {"act_symbol", "date", "when"}, "earnings_calendar")
_require_columns(
hist_raw, {"act_symbol", "period_end_date", "reported", "estimate"}, "eps_history"
)
cal_parsed = _parse_calendar(cal_raw, universe)
hist_parsed = _parse_history(hist_raw, universe)
calendar, cal_stats = earnings_alignment.dedup_calendar(cal_parsed)
history, hist_stats = earnings_alignment.dedup_history(hist_parsed)
period_lower = WINDOW_START.replace(year=WINDOW_START.year - 1)
rows: list[dict[str, Any]] = []
matched = unmatched = 0
for symbol, events in calendar.items():
ticker_id = universe[symbol]
periods = [
p for p in history.get(symbol, []) if p["period_end_date"] >= period_lower
]
matches, unmatched_events, _ = earnings_alignment.align_symbol(
events, periods, max_lag_days=MAX_LAG_DAYS, max_lead_days=MAX_LEAD_DAYS
)
matched += len(matches)
unmatched += len(unmatched_events)
matched_by_event = {e: p for e, p in matches}
for e_idx, event in enumerate(events):
p_idx = matched_by_event.get(e_idx)
period = periods[p_idx] if p_idx is not None else None
rows.append(
{
"ticker_id": ticker_id,
"symbol": symbol,
"announce_date": event["announce_date"],
"session": event["session"],
"period_end": period["period_end_date"] if period else None,
"eps_estimate": period["eps_estimate"] if period else None,
"eps_actual": period["eps_actual"] if period else None,
}
)
future_rows = [r for r in rows if r["announce_date"] > self.today]
tickers_with_future = {r["ticker_id"] for r in future_rows}
stats = {
"calendar": cal_stats,
"eps_history": hist_stats,
"universe_size": len(universe),
"symbols_with_calendar": len(calendar),
"matched_events": matched,
"unmatched_events": unmatched,
"tracked_tickers_with_future_date": len(tickers_with_future),
}
return StagedEarnings(
rows=rows,
stats=stats,
future_count=len(future_rows),
max_announce_date=max((r["announce_date"] for r in rows), default=None),
)
async def validate(self, db, staged: StagedEarnings) -> ValidationResult:
# Promote deletes+reinserts the forward calendar, so this gate is
# fail-closed. The forward calendar is the project's acceptance gate.
messages: list[str] = []
current_future = await self._current_future_count(db)
universe_size = int(staged.stats.get("universe_size", 0) or 0)
coverage = (
staged.stats.get("symbols_with_calendar", 0) / universe_size
if universe_size
else 0.0
)
horizon_days = (
(staged.max_announce_date - self.today).days if staged.max_announce_date else 0
)
if staged.future_count == 0:
messages.append("no future-dated earnings rows staged")
elif current_future == 0:
# Initial load: no baseline for the ratio gate, so require a real
# forward horizon and broad universe coverage instead of one stray row.
if horizon_days < MIN_FORWARD_HORIZON_DAYS:
messages.append(
f"forward horizon only {horizon_days}d < {MIN_FORWARD_HORIZON_DAYS}d "
"on initial load"
)
if coverage < MIN_INITIAL_COVERAGE:
messages.append(
f"initial universe coverage {coverage:.0%} "
f"< {MIN_INITIAL_COVERAGE:.0%} — symbol join likely broken"
)
elif staged.future_count < current_future * MIN_FUTURE_RATIO:
messages.append(
f"forward calendar collapsed: staged {staged.future_count} future rows "
f"< {MIN_FUTURE_RATIO:.0%} of current {current_future}"
)
keys = [(r["ticker_id"], r["announce_date"]) for r in staged.rows]
if len(keys) != len(set(keys)):
messages.append("duplicate (ticker_id, announce_date) in staged set")
summary = {
**staged.stats,
"staged_rows": len(staged.rows),
"future_rows": staged.future_count,
"current_future_rows": current_future,
"forward_horizon_days": horizon_days,
"universe_coverage": round(coverage, 3),
}
return ValidationResult(
ok=not messages,
summary=summary,
source_max_date=staged.max_announce_date,
messages=messages,
)
async def promote(self, db, staged: StagedEarnings, run_id: int) -> dict[str, int]:
# Rescheduling: drop this source's future rows, then upsert the staged
# set. Past rows (results) are never deleted; moved/cancelled future
# dates simply don't reappear.
deleted = (
await db.execute(
delete(EarningsEvent).where(
EarningsEvent.source == SOURCE,
EarningsEvent.announce_date > self.today,
)
)
).rowcount or 0
now = _now()
for r in staged.rows:
stmt = insert_for_session(db, EarningsEvent).values(
ticker_id=r["ticker_id"],
announce_date=r["announce_date"],
session=r["session"],
period_end=r["period_end"],
eps_estimate=r["eps_estimate"],
eps_actual=r["eps_actual"],
source=SOURCE,
import_run_id=run_id,
created_at=now,
)
# Preserve a non-null prior EPS/period-end if a re-pairing comes back
# null; prefer a known session over 'unknown'.
stmt = stmt.on_conflict_do_update(
index_elements=["ticker_id", "announce_date"],
set_={
"session": case(
(stmt.excluded.session != "unknown", stmt.excluded.session),
else_=EarningsEvent.session,
),
"period_end": func.coalesce(
stmt.excluded.period_end, EarningsEvent.period_end
),
"eps_estimate": func.coalesce(
stmt.excluded.eps_estimate, EarningsEvent.eps_estimate
),
"eps_actual": func.coalesce(
stmt.excluded.eps_actual, EarningsEvent.eps_actual
),
"source": stmt.excluded.source,
"import_run_id": stmt.excluded.import_run_id,
},
)
await db.execute(stmt)
return {"deleted_future": int(deleted), "upserted": len(staged.rows)}
# -- helpers -----------------------------------------------------------
async def _load_universe(self, db) -> dict[str, int]:
rows = (await db.execute(select(Ticker.id, Ticker.symbol))).all()
return {
earnings_alignment.normalise_symbol(symbol): tid
for tid, symbol in rows
if symbol
}
async def _current_future_count(self, db) -> int:
return (
await db.execute(
select(func.count())
.select_from(EarningsEvent)
.where(
EarningsEvent.source == SOURCE,
EarningsEvent.announce_date > self.today,
)
)
).scalar_one()
def _require_columns(rows: list[dict[str, str]], required: set[str], table: str) -> None:
"""Upstream schema-change gate: a missing column stops the run (→ failed)."""
if not rows:
return
present = set(rows[0].keys())
missing = required - present
if missing:
raise ValueError(f"{table}: upstream schema change, missing columns {sorted(missing)}")
def _parse_calendar(raw: list[dict[str, str]], universe: dict[str, int]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for row in raw:
symbol = earnings_alignment.normalise_symbol(row.get("act_symbol"))
raw_date = str(row.get("date") or "")[:10]
if symbol not in universe or not raw_date:
continue
announce_date = date.fromisoformat(raw_date)
if announce_date < WINDOW_START:
continue
out.append(
{
"symbol": symbol,
"announce_date": announce_date,
"session": earnings_alignment.normalise_session(row.get("when")),
}
)
return out
def _parse_history(raw: list[dict[str, str]], universe: dict[str, int]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for row in raw:
symbol = earnings_alignment.normalise_symbol(row.get("act_symbol"))
raw_date = str(row.get("period_end_date") or "")[:10]
if symbol not in universe or not raw_date:
continue
out.append(
{
"symbol": symbol,
"period_end_date": date.fromisoformat(raw_date),
"eps_actual": earnings_alignment.safe_number(row.get("reported")),
"eps_estimate": earnings_alignment.safe_number(row.get("estimate")),
}
)
return out
+207
View File
@@ -0,0 +1,207 @@
"""Pure calendar<->EPS-history alignment for the DoltHub earnings source.
The earnings repo keeps the announcement calendar (`earnings_calendar`) and the
reported/estimate EPS history (`eps_history`) in separate tables with no shared
key the calendar has announce dates, the history has period-end dates. This
module reproduces the research importer's **minimum-cost monotonic alignment**
(`scripts/import_dolthub_earnings.py`) as pure, DB-free, unit-testable functions
so the production importer can reuse it without extending that one-off script.
Constants and cost function are kept identical to the research script; the DP is
what pairs each announcement with the quarter it reported, tolerating gaps on
either side. Do not tune these without re-validating surprise-history pairing.
"""
from __future__ import annotations
import math
from collections import defaultdict
from datetime import date
from typing import Any
# Alignment costs — identical to scripts/import_dolthub_earnings.py.
SKIP_EVENT_COST = 45.0
SKIP_PERIOD_COST = 45.0
_TYPICAL_ANNOUNCE_LAG_DAYS = 30 # announcements land ~a month after period end
_MISSING_SESSION_PENALTY = 3.0
# Session normalization → the three values the schema/API promise.
_SESSION_ALIASES = {
"before market open": "bmo",
"before open": "bmo",
"bmo": "bmo",
"after market close": "amc",
"after close": "amc",
"amc": "amc",
}
def normalise_symbol(value: Any) -> str:
"""Upper-case, trim, and map dots to dashes so the DoltHub `act_symbol`
(`BF.B`) and the app's `tickers.symbol` join after the same normalization."""
return str(value or "").strip().upper().replace(".", "-")
def normalise_session(value: Any) -> str:
"""Map the source `when` text to bmo | amc | unknown. Anything not clearly a
pre-open or post-close session (including 'during market hours' and blanks)
collapses to 'unknown' the schema/API only promise those three."""
cleaned = str(value or "").strip().lower().replace("_", " ").replace("-", " ")
return _SESSION_ALIASES.get(cleaned, "unknown")
def safe_number(value: Any) -> float | None:
if value is None or str(value).strip() == "":
return None
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) else None
def dedup_calendar(
rows: list[dict[str, Any]],
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, int]]:
"""Collapse to one row per (symbol, announce_date), preferring a known
session over 'unknown'. Rows must be pre-parsed:
{symbol, announce_date: date, session}. Returns {symbol: [events sorted by
date]} and dedup stats."""
by_key: dict[tuple[str, date], dict[str, Any]] = {}
duplicate_rows = 0
restated_rows = 0
for row in rows:
key = (row["symbol"], row["announce_date"])
previous = by_key.get(key)
if previous is None:
by_key[key] = row
continue
duplicate_rows += 1
prev_known = previous["session"] != "unknown"
new_known = row["session"] != "unknown"
if prev_known and new_known and previous["session"] != row["session"]:
restated_rows += 1
# Prefer a row that carries a known session.
if new_known:
by_key[key] = row
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in by_key.values():
grouped[row["symbol"]].append(row)
for events in grouped.values():
events.sort(key=lambda item: item["announce_date"])
return grouped, {
"deduped_rows": len(by_key),
"duplicate_rows": duplicate_rows,
"restated_rows": restated_rows,
}
def dedup_history(
rows: list[dict[str, Any]],
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, int]]:
"""Collapse to one row per (symbol, period_end_date), preferring the row with
more non-null EPS fields. Rows must be pre-parsed:
{symbol, period_end_date: date, eps_actual, eps_estimate}."""
fields = ("eps_actual", "eps_estimate")
by_key: dict[tuple[str, date], dict[str, Any]] = {}
duplicate_rows = 0
restated_rows = 0
for row in rows:
key = (row["symbol"], row["period_end_date"])
previous = by_key.get(key)
if previous is None:
by_key[key] = row
continue
duplicate_rows += 1
if any(
previous.get(f) is not None
and row.get(f) is not None
and previous[f] != row[f]
for f in fields
):
restated_rows += 1
prev_score = sum(previous.get(f) is not None for f in fields)
new_score = sum(row.get(f) is not None for f in fields)
if new_score >= prev_score:
by_key[key] = row
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in by_key.values():
grouped[row["symbol"]].append(row)
for periods in grouped.values():
periods.sort(key=lambda item: item["period_end_date"])
return grouped, {
"deduped_rows": len(by_key),
"duplicate_rows": duplicate_rows,
"restated_rows": restated_rows,
}
def match_cost(event: dict[str, Any], period: dict[str, Any]) -> float:
delta = (event["announce_date"] - period["period_end_date"]).days
penalty = _MISSING_SESSION_PENALTY if event.get("session") == "unknown" else 0.0
return float(abs(delta - _TYPICAL_ANNOUNCE_LAG_DAYS)) + penalty
def align_symbol(
events: list[dict[str, Any]],
periods: list[dict[str, Any]],
*,
max_lag_days: int,
max_lead_days: int,
) -> tuple[list[tuple[int, int]], list[int], list[int]]:
"""Minimum-cost monotonic calendar-to-period alignment for one symbol.
Both lists must be sorted ascending (by announce_date / period_end_date). A
match is allowed only when ``-max_lead_days <= announce_date - period_end <=
max_lag_days``. Returns (matches, unmatched_event_indices,
unmatched_period_indices).
"""
n_events = len(events)
n_periods = len(periods)
scores = [[0.0] * (n_periods + 1) for _ in range(n_events + 1)]
choices = [[""] * (n_periods + 1) for _ in range(n_events + 1)]
for e in range(n_events - 1, -1, -1):
scores[e][n_periods] = scores[e + 1][n_periods] + SKIP_EVENT_COST
choices[e][n_periods] = "event"
for p in range(n_periods - 1, -1, -1):
scores[n_events][p] = scores[n_events][p + 1] + SKIP_PERIOD_COST
choices[n_events][p] = "period"
for e in range(n_events - 1, -1, -1):
for p in range(n_periods - 1, -1, -1):
options = [
(scores[e + 1][p] + SKIP_EVENT_COST, 2, "event"),
(scores[e][p + 1] + SKIP_PERIOD_COST, 1, "period"),
]
delta = (events[e]["announce_date"] - periods[p]["period_end_date"]).days
if -max_lead_days <= delta <= max_lag_days:
options.append(
(scores[e + 1][p + 1] + match_cost(events[e], periods[p]), 0, "match")
)
score, _, choice = min(options)
scores[e][p] = score
choices[e][p] = choice
matches: list[tuple[int, int]] = []
unmatched_events: list[int] = []
unmatched_periods: list[int] = []
e = p = 0
while e < n_events or p < n_periods:
if e >= n_events:
unmatched_periods.extend(range(p, n_periods))
break
if p >= n_periods:
unmatched_events.extend(range(e, n_events))
break
choice = choices[e][p]
if choice == "match":
matches.append((e, p))
e += 1
p += 1
elif choice == "period":
unmatched_periods.append(p)
p += 1
else:
unmatched_events.append(e)
e += 1
return matches, unmatched_events, unmatched_periods
+83 -17
View File
@@ -28,6 +28,10 @@ DRAWDOWN_LOOKBACK = 252
HORIZON_DAYS = 20 HORIZON_DAYS = 20
WARN_PERCENTILE = 80.0 WARN_PERCENTILE = 80.0
TRAIN_FRACTION = 0.70 TRAIN_FRACTION = 0.70
# Below this many holdout corrections, recall is one event away from a very
# different headline and should not be read as a property of the score.
MIN_EVENTS_FOR_CONFIDENCE = 8
SENSOR_MISMATCH_TOLERANCE = 0.10
def _median(values: list[float]) -> float | None: def _median(values: list[float]) -> float | None:
@@ -149,30 +153,72 @@ def _warning_series(
breadth_divergence: dict[date, float], breadth_divergence: dict[date, float],
dates: list[date], dates: list[date],
config: dict, config: dict,
) -> dict[date, float]: oas_series: rms.Series | None = None,
"""Technical Warning score used historically (fundamentals have no PIT history).""" ) -> tuple[dict[date, float], dict[date, int]]:
"""Warning score per session plus how many sensors backed it.
v2 re-derived this by hand from ``WARNING_WEIGHTS`` and so would have kept
measuring the old construct after a scoring change. Since v3 dropped
fundamentals from the score, this is now exactly the live Warning score
rather than a technical-only approximation of it.
The sensor count matters because the score renormalises over whatever is
available: a session backed by two sensors is not drawn from the same
distribution as one backed by three, and the frozen threshold assumes it is.
"""
tickers = config["tickers"] tickers = config["tickers"]
smh_full = prices.get(tickers["leaders"][0], []) smh_full = prices.get(tickers["leaders"][0], [])
spy_full = prices.get(tickers["market"], []) spy_full = prices.get(tickers["market"], [])
out: dict[date, float] = {} out: dict[date, float] = {}
backing: dict[date, int] = {}
for session in dates: for session in dates:
divergence = breadth_divergence.get(session) sensors = rms.warning_sensor_scores(
relative = rms.p4_relative_strength( breadth_divergence.get(session),
rms._closes_asof(smh_full, session), rms._closes_asof(smh_full, session),
rms._closes_asof(spy_full, session), rms._closes_asof(spy_full, session),
rms._window_asof(oas_series, session, rms.HY_OAS_WINDOW_DAYS),
) )
values: list[tuple[float, float]] = [] score = rms.score_warning_sensors(sensors)
if divergence is not None: if score is not None:
values.append((divergence, rms.WARNING_WEIGHTS["breadth_divergence"])) out[session] = round(score, 2)
if relative is not None: backing[session] = sum(1 for value in sensors.values() if value is not None)
values.append((relative, rms.WARNING_WEIGHTS["relative_strength"])) return out, backing
if values:
out[session] = round(
sum(value * weight for value, weight in values) def _reliability(
/ sum(weight for _, weight in values), dates: list[date],
2, split: int,
) backing: dict[date, int],
return out events_detected: int,
events_in_holdout: int,
) -> dict:
"""How far the headline metrics can actually be trusted.
Two things repeatedly invite over-reading this report:
* The holdout carries only the corrections that fall in the last 30% of the
sample. A "2/4" is one event away from "3/4", and in practice the events
that flip are decided by where the frozen threshold happens to land rather
than by whether the score saw anything.
* The score renormalises over available sensors, so a training window that
predates a sensor's history freezes a threshold on a different construct
than the holdout is measured against.
"""
expected = len(rms.WARNING_WEIGHTS)
train = [backing[d] for d in dates[:split] if d in backing]
holdout = [backing[d] for d in dates[split:] if d in backing]
train_full = sum(1 for n in train if n == expected) / len(train) if train else 0.0
holdout_full = sum(1 for n in holdout if n == expected) / len(holdout) if holdout else 0.0
return {
"events_detected": events_detected,
"events_in_holdout": events_in_holdout,
"minimum_events": MIN_EVENTS_FOR_CONFIDENCE,
"underpowered": events_in_holdout < MIN_EVENTS_FOR_CONFIDENCE,
"sensors_expected": expected,
"train_full_sensor_share": round(train_full * 100, 1),
"holdout_full_sensor_share": round(holdout_full * 100, 1),
"sensor_coverage_mismatch": abs(train_full - holdout_full) > SENSOR_MISMATCH_TOLERANCE,
}
async def run_event_study( async def run_event_study(
@@ -195,7 +241,13 @@ async def run_event_study(
db, config["breadth_basket"], window=200, min_tickers=20 db, config["breadth_basket"], window=200, min_tickers=20
) )
divergence = breadth_service.compute_divergence_series(breadth, benchmark) divergence = breadth_service.compute_divergence_series(breadth, benchmark)
warning = _warning_series(prices, divergence, dates, config) oas_series = await rms._fetch_fred_series("BAMLH0A0HYM2", start, end)
warning, backing = _warning_series(prices, divergence, dates, config, oas_series)
# The credit sensor cannot reach back as far as the price history does (the
# upstream series is capped at ~3 years), so the earlier part of the sample
# scores on W1+W2 alone via renormalisation. Report where W3 starts rather
# than letting the threshold quietly straddle two sensor sets.
credit_from = oas_series[0][0].isoformat() if oas_series else None
split = max(1, min(len(dates) - 1, int(len(dates) * TRAIN_FRACTION))) split = max(1, min(len(dates) - 1, int(len(dates) * TRAIN_FRACTION)))
train_values = [warning[d] for d in dates[:split] if d in warning] train_values = [warning[d] for d in dates[:split] if d in warning]
@@ -212,6 +264,8 @@ async def run_event_study(
metrics["false_alarms"] / (holdout_sessions / 252.0), 2 metrics["false_alarms"] / (holdout_sessions / 252.0), 2
) )
reliability = _reliability(dates, split, backing, len(all_events), len(holdout_events))
basket_asof = date.fromisoformat(config["basket_asof"]) basket_asof = date.fromisoformat(config["basket_asof"])
retrospective = dates[split] < basket_asof retrospective = dates[split] < basket_asof
evaluation = "exploratory" if retrospective else "holdout" evaluation = "exploratory" if retrospective else "holdout"
@@ -225,6 +279,13 @@ async def run_event_study(
f"{metrics['events_warned']}/{metrics['events']} 10% corrections; " f"{metrics['events_warned']}/{metrics['events']} 10% corrections; "
f"{metrics['events_missed']} missed, {metrics['false_alarms_per_year']:.1f} " f"{metrics['events_missed']} missed, {metrics['false_alarms_per_year']:.1f} "
f"false alarms/year, {lead_text}. " f"false alarms/year, {lead_text}. "
f"{metrics['events']} of {reliability['events_detected']} detected corrections "
f"fall in the test period"
+ (
"; too few to read recall as a property of the score."
if reliability["underpowered"]
else "."
)
) )
per_event = metrics.pop("per_event") per_event = metrics.pop("per_event")
@@ -243,6 +304,7 @@ async def run_event_study(
"train_fraction": TRAIN_FRACTION, "train_fraction": TRAIN_FRACTION,
"warn_percentile": WARN_PERCENTILE, "warn_percentile": WARN_PERCENTILE,
"warn_threshold": round(warn_threshold, 1), "warn_threshold": round(warn_threshold, 1),
"credit_sensor_from": credit_from,
"basket_hash": rms._basket_hash(config["breadth_basket"]), "basket_hash": rms._basket_hash(config["breadth_basket"]),
"basket_asof": config["basket_asof"], "basket_asof": config["basket_asof"],
}, },
@@ -255,6 +317,7 @@ async def run_event_study(
"holdout_sessions": holdout_sessions, "holdout_sessions": holdout_sessions,
}, },
"metrics": metrics, "metrics": metrics,
"reliability": reliability,
"events": per_event, "events": per_event,
"recent_breadth": [ "recent_breadth": [
{"date": d.isoformat(), "breadth": breadth[d], "warning": warning.get(d)} {"date": d.isoformat(), "breadth": breadth[d], "warning": warning.get(d)}
@@ -266,8 +329,11 @@ async def run_event_study(
"event": "regime_event_study_complete", "event": "regime_event_study_complete",
"evaluation": evaluation, "evaluation": evaluation,
"events": metrics["events"], "events": metrics["events"],
"events_detected": reliability["events_detected"],
"warned": metrics["events_warned"], "warned": metrics["events_warned"],
"false_alarms_per_year": metrics["false_alarms_per_year"], "false_alarms_per_year": metrics["false_alarms_per_year"],
"underpowered": reliability["underpowered"],
"sensor_coverage_mismatch": reliability["sensor_coverage_mismatch"],
})) }))
return report return report
@@ -0,0 +1,179 @@
"""A5 activation: refresh the legacy fundamentals cache from local bulk data."""
from __future__ import annotations
import json
from datetime import date, datetime, timezone
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import insert_for_session
from app.models.fundamental import FundamentalData
from app.models.score import CompositeScore, DimensionScore
from app.services import fundamentals_candidate_service, settings_store
# Absence is deliberately false. Production activation therefore requires one
# explicit, durable SystemSetting change after the A5 evidence is approved.
ACTIVATION_KEY = "fundamental_data_sec_dolt_cutover_enabled"
_SCORE_FIELDS = ("pe_ratio", "revenue_growth", "earnings_surprise")
async def is_enabled(db: AsyncSession) -> bool:
raw = await settings_store.get_value(db, ACTIVATION_KEY, "false")
return str(raw).strip().lower() == "true"
async def refresh_if_enabled(
db: AsyncSession,
*,
now: datetime | None = None,
today: date | None = None,
) -> dict[str, Any]:
"""Refresh atomically when activated; otherwise perform no writes."""
if not await is_enabled(db):
return {
"enabled": False,
"refreshed": 0,
"score_inputs_changed": 0,
"dimension_scores_staled": 0,
"composite_scores_staled": 0,
}
return await refresh(db, now=now, today=today)
async def refresh(
db: AsyncSession,
*,
now: datetime | None = None,
today: date | None = None,
) -> dict[str, Any]:
"""Replace every ticker's compat-cache row in one database transaction.
Candidate values are assembled before the first write and use only local
PostgreSQL tables. A failure rolls the whole refresh back. Only changes to
the three scoring inputs invalidate cached scores; market cap and the next
earnings date are display-only.
"""
refreshed_at = now or datetime.now(timezone.utc)
candidates = await fundamentals_candidate_service.build_candidates(
db, today=today
)
ticker_ids = [candidate.ticker_id for candidate in candidates]
existing = await _existing_by_ticker(db, ticker_ids)
changed_ids = {
candidate.ticker_id
for candidate in candidates
if _score_inputs_changed(existing.get(candidate.ticker_id), candidate)
}
for candidate in candidates:
unavailable_json = json.dumps(
candidate.unavailable_fields, sort_keys=True
)
stmt = insert_for_session(db, FundamentalData).values(
ticker_id=candidate.ticker_id,
pe_ratio=candidate.pe_ratio,
revenue_growth=candidate.revenue_growth,
earnings_surprise=candidate.earnings_surprise,
market_cap=candidate.market_cap,
next_earnings_date=candidate.next_earnings_date,
fetched_at=refreshed_at,
unavailable_fields_json=unavailable_json,
)
await db.execute(
stmt.on_conflict_do_update(
index_elements=["ticker_id"],
set_={
"pe_ratio": stmt.excluded.pe_ratio,
"revenue_growth": stmt.excluded.revenue_growth,
"earnings_surprise": stmt.excluded.earnings_surprise,
"market_cap": stmt.excluded.market_cap,
"next_earnings_date": stmt.excluded.next_earnings_date,
"fetched_at": stmt.excluded.fetched_at,
"unavailable_fields_json": (
stmt.excluded.unavailable_fields_json
),
},
)
)
dimension_ids = await _fundamental_dimension_ids(db, changed_ids)
composite_ids = await _composite_ids(db, changed_ids)
if dimension_ids:
await db.execute(
update(DimensionScore)
.where(DimensionScore.ticker_id.in_(dimension_ids))
.values(is_stale=True)
)
if composite_ids:
await db.execute(
update(CompositeScore)
.where(CompositeScore.ticker_id.in_(composite_ids))
.values(is_stale=True)
)
await db.commit()
return {
"enabled": True,
"refreshed": len(candidates),
"score_inputs_changed": len(changed_ids),
"dimension_scores_staled": len(dimension_ids),
"composite_scores_staled": len(composite_ids),
}
async def _existing_by_ticker(
db: AsyncSession, ticker_ids: list[int]
) -> dict[int, FundamentalData]:
if not ticker_ids:
return {}
rows = (
await db.execute(
select(FundamentalData).where(
FundamentalData.ticker_id.in_(ticker_ids)
)
)
).scalars()
return {row.ticker_id: row for row in rows}
async def _fundamental_dimension_ids(
db: AsyncSession, ticker_ids: set[int]
) -> set[int]:
if not ticker_ids:
return set()
rows = await db.execute(
select(DimensionScore.ticker_id).where(
DimensionScore.ticker_id.in_(ticker_ids),
DimensionScore.dimension == "fundamental",
)
)
return set(rows.scalars())
async def _composite_ids(
db: AsyncSession, ticker_ids: set[int]
) -> set[int]:
if not ticker_ids:
return set()
rows = await db.execute(
select(CompositeScore.ticker_id).where(
CompositeScore.ticker_id.in_(ticker_ids)
)
)
return set(rows.scalars())
def _score_inputs_changed(
existing: FundamentalData | None,
candidate: fundamentals_candidate_service.CandidateFundamentals,
) -> bool:
if existing is None:
return True
return any(
getattr(existing, field) != getattr(candidate, field)
for field in _SCORE_FIELDS
)
+344
View File
@@ -0,0 +1,344 @@
"""Assemble the additive fundamentals API v1 objects (earnings, metrics,
valuation, reads) from SEC snapshots + Dolt earnings + the latest price.
Strictly additive: the router merges these into the existing FundamentalResponse
without touching legacy fields. Valuation ratios are computed at REQUEST TIME from
the stored snapshots + the latest ohlcv close (no stored valuation). Peer stats are
batched and CIK-deduplicated; invalid valuation inputs are guarded to null.
"""
from __future__ import annotations
import math
from collections import defaultdict
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.earnings_event import EarningsEvent
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.ohlcv import OHLCVRecord
from app.models.ticker import Ticker
from app.services import fundamentals_derivation as deriv
from app.services import fundamentals_peers as peers
from app.services import fundamentals_reads as reads
# The fixed metric row set — every key always present, value null when unavailable.
METRIC_KEYS = (
"revenue_growth_yoy", "eps_growth_yoy", "operating_margin", "fcf_margin",
"net_debt", "net_debt_to_ebitda", "share_count_change_yoy",
)
async def build_fundamentals_v1(db: AsyncSession, symbol: str, *, today: date | None = None) -> dict[str, Any]:
today = today or _ny_today()
ticker = await _ticker_by_symbol(db, symbol)
earnings = await _build_earnings(db, ticker.id, today) if ticker else _empty_earnings()
if ticker is None or not ticker.cik:
# No SEC identity: metrics present but null, valuation null, empty reads.
return {"earnings": earnings, "metrics": _empty_metrics(), "valuation": None,
"reads": _empty_reads()}
subject_cik = ticker.cik
derived = deriv.derive((await _snapshots_for(db, [subject_cik])).get(subject_cik, []))
two = peers.two_digit_sic(ticker.sic)
peer_derived: dict[str, deriv.DerivedFundamentals] = {}
peer_price_by_cik: dict[str, tuple[float, date] | None] = {}
if two:
# Subject's representative is the REQUESTED ticker (so its price is used for
# the subject in the peer set); other issuers pick a deterministic-by-symbol rep.
group = await _peer_group(db, two, subject_cik, ticker.id)
peer_snaps = await _snapshots_for(db, list(group))
peer_derived = {cik: deriv.derive(rows) for cik, rows in peer_snaps.items()}
closes = await _latest_closes(db, set(group.values()))
peer_price_by_cik = {cik: closes.get(tid) for cik, tid in group.items()}
subject_price = await _latest_close(db, ticker.id)
metrics = _build_metrics(derived, peer_derived, two)
valuation = _build_valuation(derived, subject_price, peer_derived, peer_price_by_cik, two)
reads_obj = _build_reads(metrics, valuation)
return {"earnings": earnings, "metrics": metrics, "valuation": valuation, "reads": reads_obj}
# -- earnings ----------------------------------------------------------------
async def _build_earnings(db, ticker_id: int, today: date) -> dict[str, Any]:
rows = (await db.execute(
select(EarningsEvent).where(EarningsEvent.ticker_id == ticker_id)
)).scalars().all()
# Same-day earnings are UPCOMING (days_until 0); recent is strictly earlier.
upcoming = sorted((e for e in rows if e.announce_date >= today), key=lambda e: e.announce_date)
past = sorted((e for e in rows if e.announce_date < today), key=lambda e: e.announce_date, reverse=True)
nxt = None
if upcoming:
e = upcoming[0]
nxt = {"date": e.announce_date.isoformat(), "session": e.session,
"days_until": (e.announce_date - today).days}
recent = [{
"announce_date": e.announce_date.isoformat(),
"period_end": _iso(e.period_end),
"eps_estimate": e.eps_estimate,
"eps_actual": e.eps_actual,
"surprise_pct": _surprise_pct(e.eps_estimate, e.eps_actual),
} for e in past[:4]]
return {"next": nxt, "recent": recent}
def _surprise_pct(estimate, actual):
if estimate is None or actual is None or estimate == 0:
return None
return round((actual - estimate) / abs(estimate) * 100.0, 2)
# -- metrics -----------------------------------------------------------------
def _build_metrics(derived, peer_derived, two: str | None) -> list[dict[str, Any]]:
out = []
for key in METRIC_KEYS:
series = derived.metrics.get(key)
value = series.value if series else None
history = [{"period_end": _iso(p.period_end), "value": p.value} for p in (series.history if series else [])]
industry = None
if two and peer_derived and key in peers.HIGHER_IS_BETTER:
group_values = [
(pd.metrics.get(key).value if pd.metrics.get(key) else None)
for pd in peer_derived.values()
]
stat = peers.peer_stat_for(key, value, group_values)
if stat:
industry = {"label": f"SIC {two} peers", "median": round(stat.median, 4),
"favorable_percentile": stat.favorable_percentile, "peer_count": stat.peer_count}
out.append({
"key": key,
"value": value,
"history": history,
"industry": industry,
"period_end": _iso(series.period_end) if series else None,
"filed_date": _iso(series.filed_date) if series else None,
"caveat": series.caveat if series else None,
"source": "sec",
})
return out
# -- valuation (request-time) ------------------------------------------------
def _build_valuation(derived, subject_price, peer_derived, peer_price_by_cik, two) -> dict[str, Any] | None:
if derived.latest_period_end is None:
return None # no snapshots yet
price = subject_price[0] if subject_price else None
price_date = subject_price[1] if subject_price else None
if not _finite(price) or price <= 0:
return None # no usable price -> valuation null (approved contract)
pe = _pe(price, derived.ttm_diluted_eps)
market_cap = _market_cap(price, derived.shares_outstanding)
fcf_yield = _fcf_yield(derived.ttm_fcf, market_cap)
pe_industry = fcf_yield_industry = None
if two and peer_derived:
pe_values = [_pe(_p(peer_price_by_cik.get(cik)), pd.ttm_diluted_eps) for cik, pd in peer_derived.items()]
fy_values = [
_fcf_yield(pd.ttm_fcf, _market_cap(_p(peer_price_by_cik.get(cik)), pd.shares_outstanding))
for cik, pd in peer_derived.items()
]
pe_industry = _industry("pe", pe, pe_values, two)
fcf_yield_industry = _industry("fcf_yield", fcf_yield, fy_values, two)
return {
"pe": _round(pe, 2),
"fcf_yield": _round(fcf_yield, 2),
"market_cap_est": _round(market_cap, 0),
# market_cap_est and fcf_yield both rest on the share count. When it came
# from the weighted-average diluted fallback (multi-class issuers, whose
# per-class cover-page count is absent from companyfacts), say so rather
# than presenting a period average as a point-in-time count.
"shares_estimated": bool(
market_cap is not None and derived.shares_outstanding_estimated
),
# A null P/E is ambiguous: no earnings data, or earnings we deliberately
# suppressed. Only the latter carries a caveat, so a split-contaminated
# TTM says why instead of looking like missing data.
"pe_caveat": derived.ttm_diluted_eps_caveat if pe is None else None,
"pe_industry": pe_industry,
"fcf_yield_industry": fcf_yield_industry,
"price_date": _iso(price_date),
}
def _pe(price, ttm_eps):
if not _finite(price) or price <= 0 or not _finite(ttm_eps) or ttm_eps <= 0:
return None
return price / ttm_eps
def _market_cap(price, shares):
if not _finite(price) or price <= 0 or not _finite(shares) or shares <= 0:
return None
return price * shares
def _fcf_yield(ttm_fcf, market_cap):
if not _finite(ttm_fcf) or not _finite(market_cap) or market_cap <= 0:
return None
return ttm_fcf / market_cap * 100.0
def _industry(key, subject, group_values, two):
stat = peers.peer_stat_for(key, subject, group_values)
if stat is None:
return None
return {"label": f"SIC {two} peers", "median": round(stat.median, 4),
"favorable_percentile": stat.favorable_percentile, "peer_count": stat.peer_count}
# -- reads -------------------------------------------------------------------
_READ_KEYS = METRIC_KEYS + ("pe", "fcf_yield")
def _build_reads(metrics: list[dict], valuation: dict | None) -> dict[str, Any]:
by_metric = {m["key"]: m for m in metrics}
def hist(key):
return [_Pt(p["value"]) for p in by_metric.get(key, {}).get("history", [])]
growth = reads.growth_read(hist("revenue_growth_yoy"))
eps_growth = reads.growth_read(hist("eps_growth_yoy"))
op_margin = reads.margin_read(hist("operating_margin"))
fcf_margin = reads.margin_read(hist("fcf_margin"))
share = reads.share_count_read(by_metric.get("share_count_change_yoy", {}).get("value"))
leverage = reads.peer_read("net_debt_to_ebitda", _pct(by_metric.get("net_debt_to_ebitda", {}).get("industry")))
pe_read = reads.peer_read("pe", _pct(valuation.get("pe_industry"))) if valuation else None
fcf_yield_read = reads.peer_read("fcf_yield", _pct(valuation.get("fcf_yield_industry"))) if valuation else None
# Fixed by_key map over every metric + pe + fcf_yield (null where unavailable).
by_key: dict[str, str | None] = {k: None for k in _READ_KEYS}
by_key.update({
"revenue_growth_yoy": growth,
"eps_growth_yoy": eps_growth,
"operating_margin": op_margin,
"fcf_margin": fcf_margin,
"share_count_change_yoy": share,
"net_debt_to_ebitda": leverage,
"pe": pe_read,
"fcf_yield": fcf_yield_read,
})
header = reads.header_sentence(growth, op_margin, pe_read or fcf_yield_read) or None
return {"header": header, "by_key": by_key}
def _empty_reads() -> dict[str, Any]:
return {"header": None, "by_key": {k: None for k in _READ_KEYS}}
class _Pt:
__slots__ = ("value",)
def __init__(self, value):
self.value = value
def _pct(industry: dict | None):
return industry.get("favorable_percentile") if industry else None
# -- queries -----------------------------------------------------------------
async def _ticker_by_symbol(db, symbol: str) -> Ticker | None:
return (await db.execute(
select(Ticker).where(Ticker.symbol == symbol.strip().upper())
)).scalar_one_or_none()
async def _snapshots_for(db, ciks) -> dict[str, list]:
out: dict[str, list] = defaultdict(list)
if not ciks:
return out
rows = (await db.execute(
select(FundamentalSnapshot).where(FundamentalSnapshot.cik.in_(list(ciks)))
)).scalars().all()
for r in rows:
out[r.cik].append(r)
return out
async def _peer_group(db, two: str, subject_cik: str, subject_tid: int) -> dict[str, int]:
"""{cik: representative ticker_id} for tracked issuers in the 2-digit SIC group,
CIK-deduplicated. Each issuer's representative is its lexicographically-smallest
symbol (deterministic), EXCEPT the subject issuer, which uses the requested
ticker so a multi-class subject (GOOGL) is priced by the requested class, not
an arbitrary sibling (GOOG)."""
rows = (await db.execute(
select(Ticker.cik, Ticker.id, Ticker.symbol)
.where(Ticker.cik.is_not(None), func.substr(Ticker.sic, 1, 2) == two)
)).all()
rep: dict[str, tuple[int, str]] = {}
for cik, tid, sym in rows:
key = sym or ""
if cik not in rep or key < rep[cik][1]:
rep[cik] = (tid, key)
group = {cik: tid for cik, (tid, _) in rep.items()}
if subject_cik in group:
group[subject_cik] = subject_tid # requested ticker prices the subject
return group
async def _latest_closes(db, ticker_ids: set[int]) -> dict[int, tuple[float, date]]:
if not ticker_ids:
return {}
latest = (
select(OHLCVRecord.ticker_id, func.max(OHLCVRecord.date).label("d"))
.where(OHLCVRecord.ticker_id.in_(list(ticker_ids)))
.group_by(OHLCVRecord.ticker_id)
.subquery()
)
rows = (await db.execute(
select(OHLCVRecord.ticker_id, OHLCVRecord.close, OHLCVRecord.date).join(
latest, (OHLCVRecord.ticker_id == latest.c.ticker_id) & (OHLCVRecord.date == latest.c.d)
)
)).all()
return {tid: (close, d) for tid, close, d in rows}
async def _latest_close(db, ticker_id: int) -> tuple[float, date] | None:
return (await _latest_closes(db, {ticker_id})).get(ticker_id)
# -- helpers -----------------------------------------------------------------
def _empty_metrics() -> list[dict[str, Any]]:
return [{"key": k, "value": None, "history": [], "industry": None,
"period_end": None, "filed_date": None, "caveat": None,
"source": "sec"} for k in METRIC_KEYS]
def _empty_earnings() -> dict[str, Any]:
return {"next": None, "recent": []}
def _p(price_tuple):
return price_tuple[0] if price_tuple else None
def _finite(v) -> bool:
return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
def _round(v, ndigits):
return round(v, ndigits) if _finite(v) else None
def _iso(d) -> str | None:
return d.isoformat() if d else None
def _ny_today() -> date:
"""Today's New York calendar date — the market's day, not the server's."""
return datetime.now(ZoneInfo("America/New_York")).date()
@@ -0,0 +1,288 @@
"""Local SEC/Dolt candidate values for the legacy fundamentals cache.
This is the single read path shared by the A5 parity report and the activated
``fundamental_data`` refresh. It never contacts SEC or Dolt: every input comes
from PostgreSQL, so price- and earnings-driven values can still refresh when an
upstream import is unchanged or unavailable.
"""
from __future__ import annotations
import math
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.earnings_event import EarningsEvent
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.ohlcv import OHLCVRecord
from app.models.ticker import Ticker
from app.services import fundamentals_derivation as deriv
@dataclass(frozen=True)
class CandidateFundamentals:
ticker_id: int
symbol: str
cik: str | None
pe_ratio: float | None
revenue_growth: float | None
earnings_surprise: float | None
market_cap: float | None
next_earnings_date: date | None
price_date: date | None
unavailable_fields: dict[str, str] = field(default_factory=dict)
async def build_candidates(
db: AsyncSession,
*,
today: date | None = None,
) -> list[CandidateFundamentals]:
"""Derive current cache candidates using only already-stored data."""
today = today or datetime.now(ZoneInfo("America/New_York")).date()
tickers = list(
(await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars()
)
if not tickers:
return []
ticker_ids = [ticker.id for ticker in tickers]
ciks = sorted({ticker.cik for ticker in tickers if ticker.cik})
derived_by_cik = await _derived_by_cik(db, ciks)
closes_by_ticker = await _latest_closes(db, ticker_ids)
surprise_by_ticker, next_by_ticker = await _earnings_values(
db, ticker_ids, today
)
out: list[CandidateFundamentals] = []
for ticker in tickers:
derived = derived_by_cik.get(ticker.cik) if ticker.cik else None
close = closes_by_ticker.get(ticker.id)
price = close[0] if close is not None else None
price_date = close[1] if close is not None else None
growth_series = (
derived.metrics.get("revenue_growth_yoy")
if derived is not None
else None
)
pe_ratio = (
_pe(price, derived.ttm_diluted_eps)
if derived is not None
else None
)
revenue_growth = (
float(growth_series.value)
if growth_series is not None and _finite(growth_series.value)
else None
)
earnings_surprise = surprise_by_ticker.get(ticker.id)
market_cap = (
_market_cap(price, derived.shares_outstanding)
if derived is not None
else None
)
next_earnings_date = next_by_ticker.get(ticker.id)
out.append(
CandidateFundamentals(
ticker_id=ticker.id,
symbol=ticker.symbol,
cik=ticker.cik,
pe_ratio=pe_ratio,
revenue_growth=revenue_growth,
earnings_surprise=earnings_surprise,
market_cap=market_cap,
next_earnings_date=next_earnings_date,
price_date=price_date,
unavailable_fields=_availability_metadata(
derived=derived,
price=price,
pe_ratio=pe_ratio,
revenue_growth=revenue_growth,
earnings_surprise=earnings_surprise,
market_cap=market_cap,
next_earnings_date=next_earnings_date,
),
)
)
return out
async def _derived_by_cik(
db: AsyncSession, ciks: list[str]
) -> dict[str, deriv.DerivedFundamentals]:
if not ciks:
return {}
grouped: dict[str, list[FundamentalSnapshot]] = defaultdict(list)
rows = (
await db.execute(
select(FundamentalSnapshot).where(FundamentalSnapshot.cik.in_(ciks))
)
).scalars()
for row in rows:
grouped[row.cik].append(row)
return {cik: deriv.derive(grouped.get(cik, [])) for cik in ciks}
async def _latest_closes(
db: AsyncSession, ticker_ids: list[int]
) -> dict[int, tuple[float, date]]:
latest = (
select(
OHLCVRecord.ticker_id,
func.max(OHLCVRecord.date).label("max_date"),
)
.where(OHLCVRecord.ticker_id.in_(ticker_ids))
.group_by(OHLCVRecord.ticker_id)
.subquery()
)
rows = (
await db.execute(
select(
OHLCVRecord.ticker_id,
OHLCVRecord.close,
OHLCVRecord.date,
).join(
latest,
(OHLCVRecord.ticker_id == latest.c.ticker_id)
& (OHLCVRecord.date == latest.c.max_date),
)
)
).all()
return {
ticker_id: (float(close), close_date)
for ticker_id, close, close_date in rows
if _finite(close)
}
async def _earnings_values(
db: AsyncSession,
ticker_ids: list[int],
today: date,
) -> tuple[dict[int, float], dict[int, date]]:
rows = (
await db.execute(
select(EarningsEvent)
.where(EarningsEvent.ticker_id.in_(ticker_ids))
.order_by(EarningsEvent.ticker_id, EarningsEvent.announce_date.desc())
)
).scalars()
surprises: dict[int, float] = {}
upcoming: dict[int, date] = {}
for row in rows:
if row.announce_date >= today:
current = upcoming.get(row.ticker_id)
if current is None or row.announce_date < current:
upcoming[row.ticker_id] = row.announce_date
continue
if row.ticker_id in surprises:
continue
surprise = _surprise(row.eps_estimate, row.eps_actual)
if surprise is not None:
surprises[row.ticker_id] = surprise
return surprises, upcoming
def _availability_metadata(
*,
derived: deriv.DerivedFundamentals | None,
price: float | None,
pe_ratio: float | None,
revenue_growth: float | None,
earnings_surprise: float | None,
market_cap: float | None,
next_earnings_date: date | None,
) -> dict[str, str]:
metadata: dict[str, str] = {}
if pe_ratio is not None:
metadata["source_pe_ratio"] = "sec_facts+ohlcv_records"
elif derived is None or derived.latest_period_end is None:
metadata["pe_ratio"] = "no SEC fundamental snapshots"
elif not _finite(price) or price <= 0:
metadata["pe_ratio"] = "no usable PostgreSQL close"
elif derived.ttm_diluted_eps_caveat:
metadata["pe_ratio"] = derived.ttm_diluted_eps_caveat
else:
metadata["pe_ratio"] = "no positive SEC-derived TTM diluted EPS"
if revenue_growth is not None:
metadata["source_revenue_growth"] = "sec_facts"
else:
metadata["revenue_growth"] = "SEC-derived TTM revenue growth unavailable"
if earnings_surprise is not None:
metadata["source_earnings_surprise"] = "dolt_earnings"
else:
metadata["earnings_surprise"] = (
"no completed earnings event with actual and nonzero estimate"
)
if market_cap is not None:
metadata["source_market_cap"] = "sec_facts+ohlcv_records"
if derived is not None and derived.shares_outstanding_estimated:
metadata["market_cap_estimated"] = (
"shares use the SEC weighted-average diluted fallback"
)
elif derived is None or derived.latest_period_end is None:
metadata["market_cap"] = "no SEC fundamental snapshots"
elif not _finite(price) or price <= 0:
metadata["market_cap"] = "no usable PostgreSQL close"
else:
metadata["market_cap"] = "SEC-derived shares outstanding unavailable"
if next_earnings_date is not None:
metadata["source_next_earnings_date"] = "dolt_earnings"
else:
metadata["next_earnings_date"] = "no upcoming earnings event"
return metadata
def _surprise(
estimate: float | None,
actual: float | None,
) -> float | None:
if not _finite(estimate) or not _finite(actual) or estimate == 0:
return None
return (float(actual) - float(estimate)) / abs(float(estimate)) * 100.0
def _pe(price: float | None, ttm_eps: float | None) -> float | None:
if (
not _finite(price)
or price <= 0
or not _finite(ttm_eps)
or ttm_eps <= 0
):
return None
return float(price) / float(ttm_eps)
def _market_cap(
price: float | None,
shares_outstanding: float | None,
) -> float | None:
if (
not _finite(price)
or price <= 0
or not _finite(shares_outstanding)
or shares_outstanding <= 0
):
return None
return float(price) * float(shares_outstanding)
def _finite(value: Any) -> bool:
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(value)
)
+396
View File
@@ -0,0 +1,396 @@
"""Pure read-time derivation of fundamental metrics from stored snapshots.
`fundamental_snapshots` stores one immutable row per accession with **cumulative
YTD** duration facts and period-end balance-sheet instants (A3). This module
derives everything the UI/API shows discrete quarters, Q4, TTM, YoY growth,
margins, leverage, dilution, and the quarter tape at read time, per the plan's
schema decision. No I/O, no DB: it takes an issuer's snapshot rows (ORM rows or
any objects with the same attributes) and returns structured metrics.
Rules:
- **Amendment selection:** for each (fiscal_year, fiscal_period), the newest
`accepted_at` wins **per field**, falling back to the newest row that actually
reports one. A partial amendment (a 10-K/A adding Part III carries no financial
facts) must not blank the period.
- **Discrete quarter** = YTD(Qn) YTD(Qn1); Q1 = YTD(Q1); **Q4 = YTD(FY)
YTD(Q3)**. Any missing period the derived value is null, never partial.
- **TTM** = sum of the trailing four discrete quarters ending at a period.
- Units follow app convention: percentages are percentage points (21.0 = 21%),
net-debt/EBITDA is a multiple, net debt is dollars.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from types import SimpleNamespace
from typing import Any, Iterable
_FP_TO_Q = {"Q1": 1, "Q2": 2, "Q3": 3, "FY": 4}
_Q_TO_FP = {1: "Q1", 2: "Q2", 3: "Q3", 4: "FY"}
_PREV_FP = {"Q2": "Q1", "Q3": "Q2", "FY": "Q3"}
TAPE_LEN = 4 # quarter-tape length
SPLIT_SUSPECT_SHARE_CHANGE_PCT = 25.0
SPLIT_SENSITIVE_CAVEAT = (
"Not comparable: share count changed at least 25%; possible split or "
"corporate action."
)
# Duration (flow) fields differenced from YTD into discrete quarters + summed to TTM.
_FLOW_FIELDS = (
"revenue", "net_income", "operating_income", "diluted_eps", "cfo", "capex",
"depreciation_amortization",
)
# Reported facts resolved independently across a period's accessions (see
# _merge_amendments); period identity/provenance is taken from the newest one.
_MERGED_FIELDS = (
*_FLOW_FIELDS,
"cash_and_st_investments", "total_debt", "shares_outstanding",
"shares_outstanding_date", "weighted_avg_diluted_shares",
# period_start is set alongside revenue by the parser, so it follows the same
# fallback: a bare amendment reports neither and must not blank it.
"period_start",
)
_CARRIED_FIELDS = (
"fiscal_year", "fiscal_period", "period_end", "filed_date",
"accepted_at", "form", "accession", "cik",
)
@dataclass
class MetricPoint:
period_end: date
value: float | None
@dataclass
class MetricSeries:
value: float | None = None
history: list[MetricPoint] = field(default_factory=list) # oldest -> newest, <= TAPE_LEN
period_end: date | None = None
filed_date: date | None = None
caveat: str | None = None
@dataclass
class DerivedFundamentals:
metrics: dict[str, MetricSeries] = field(default_factory=dict)
# request-time valuation inputs (ratios are computed in the API with price)
ttm_diluted_eps: float | None = None
# Set when ttm_diluted_eps was suppressed rather than simply unavailable.
ttm_diluted_eps_caveat: str | None = None
ttm_fcf: float | None = None
shares_outstanding: float | None = None
# True when shares_outstanding came from the weighted-average diluted count
# because the point-in-time cover-page count was absent (always so for
# multi-class issuers). Consumers must label anything derived from it as
# estimated — it is a period average, not a point-in-time count.
shares_outstanding_estimated: bool = False
latest_period_end: date | None = None
latest_filed_date: date | None = None
def _prev_q(fy: int, q: int) -> tuple[int, int]:
return (fy, q - 1) if q > 1 else (fy - 1, 4)
def derive(snapshots: Iterable[Any]) -> DerivedFundamentals:
selected = _select_latest_per_period(snapshots)
result = DerivedFundamentals()
if not selected:
return result
# Discrete quarter values per flow field: {field: {(fy, q): value}}.
discrete = {f: _discrete_quarters(selected, f) for f in _FLOW_FIELDS}
quarters = _ordered_quarters(selected) # chronological (fy, q) with a row
latest = quarters[-1]
latest_row = selected[(latest[0], _Q_TO_FP[latest[1]])]
result.latest_period_end = latest_row.period_end
result.latest_filed_date = latest_row.filed_date
result.shares_outstanding = getattr(latest_row, "shares_outstanding", None)
if result.shares_outstanding is None:
# Multi-class issuers (META, CMCSA, BRK-B, CHTR, FOXA, NWSA, LEN) report
# the cover-page count per class, which is dimensional and so absent from
# companyfacts — leaving market cap and FCF yield silently unavailable for
# some of the largest names. The weighted-average diluted count is always
# present and within ~0.6% of the true count where both exist, so fall
# back to it and mark the result estimated rather than show nothing.
result.shares_outstanding = getattr(latest_row, "weighted_avg_diluted_shares", None)
result.shares_outstanding_estimated = result.shares_outstanding is not None
result.ttm_diluted_eps = _ttm(discrete["diluted_eps"], *latest)
ttm_cfo = _ttm(discrete["cfo"], *latest)
ttm_capex = _ttm(discrete["capex"], *latest)
result.ttm_fcf = None if ttm_cfo is None or ttm_capex is None else ttm_cfo - ttm_capex
# tape = the CONSECUTIVE run of up to TAPE_LEN quarters ending at the latest,
# stopping at a gap — so trend text never compares non-adjacent periods.
tape = _consecutive_suffix(quarters, TAPE_LEN)
result.metrics = {
"revenue_growth_yoy": _yoy_growth_series(discrete["revenue"], selected, tape),
"eps_growth_yoy": _yoy_growth_series(discrete["diluted_eps"], selected, tape),
"operating_margin": _margin_series(discrete["operating_income"], discrete["revenue"], selected, tape),
"fcf_margin": _fcf_margin_series(discrete, selected, tape),
"net_debt": _instant_series(selected, tape, _net_debt),
"net_debt_to_ebitda": _leverage_series(selected, discrete, tape),
"share_count_change_yoy": _share_change_series(selected, tape),
}
# TTM EPS sums four quarters of *per-share* values, so a split inside that
# window mixes pre- and post-split units — the same distortion the guard
# already catches for the series, and the one that produced BKNG's P/E of
# 1.10. Left unguarded it does not merely mislead: a nonsense-low P/E clamps
# to a perfect 100 fundamental sub-score, so it must null out like the rest.
if _guard_split_sensitive_metrics(result.metrics):
result.ttm_diluted_eps = None
result.ttm_diluted_eps_caveat = SPLIT_SENSITIVE_CAVEAT
for series in result.metrics.values():
series.period_end = latest_row.period_end
series.filed_date = latest_row.filed_date
return result
# -- period selection --------------------------------------------------------
def _select_latest_per_period(snapshots: Iterable[Any]) -> dict[tuple[int, str], Any]:
grouped: dict[tuple[int, str], list[Any]] = {}
for row in snapshots:
fp = getattr(row, "fiscal_period", None)
fy = getattr(row, "fiscal_year", None)
if fp not in _FP_TO_Q or fy is None:
continue
grouped.setdefault((fy, fp), []).append(row)
return {key: _merge_amendments(rows) for key, rows in grouped.items()}
def _merge_amendments(rows: list[Any]) -> Any:
"""Resolve one period from its accessions: newest wins, per field.
Amendments are frequently partial a 10-K/A filed only to add Part III
reports no financial facts at all. Taking the newest accession wholesale
would blank every field it omits and null the period downstream (and with
it TTM and YoY, which need an unbroken quarter chain), so each field falls
back to the newest accession that actually reports it.
Only rows sharing the newest row's ``period_end`` are merged. A same-key row
covering a *different* period is a mislabelled filing, not an amendment, and
blending the two would silently mix fiscal years.
"""
if len(rows) == 1:
return rows[0]
ordered = sorted(rows, key=_amendment_order, reverse=True) # newest first
newest = ordered[0]
same_period = [
row
for row in ordered
if getattr(row, "period_end", None) == getattr(newest, "period_end", None)
]
if len(same_period) == 1:
return newest
merged = SimpleNamespace(**{name: getattr(newest, name, None) for name in _CARRIED_FIELDS})
for name in _MERGED_FIELDS:
merged_value = None
for row in same_period: # newest first
value = getattr(row, name, None)
if value is not None:
merged_value = value
break
setattr(merged, name, merged_value)
return merged
def _amendment_order(row: Any) -> tuple[bool, Any]:
# (has-timestamp, timestamp) so a row without one sorts oldest instead of
# raising when compared against a row that has one.
accepted = _accepted(row)
return (accepted is not None, accepted)
def _accepted(row: Any):
return getattr(row, "accepted_at", None) or getattr(row, "filed_date", None)
def _ordered_quarters(selected: dict[tuple[int, str], Any]) -> list[tuple[int, int]]:
return sorted((fy, _FP_TO_Q[fp]) for (fy, fp) in selected)
def _consecutive_suffix(quarters: list[tuple[int, int]], n: int) -> list[tuple[int, int]]:
"""The run of up to n quarters ending at the latest, walking back only through
adjacent periods (stop at the first gap). Returned oldest -> newest."""
if not quarters:
return []
present = set(quarters)
run = [quarters[-1]]
cur = quarters[-1]
while len(run) < n:
prev = _prev_q(*cur)
if prev not in present:
break
run.append(prev)
cur = prev
run.reverse()
return run
# -- discrete + TTM ----------------------------------------------------------
def _discrete_quarters(selected: dict[tuple[int, str], Any], field_name: str) -> dict[tuple[int, int], float]:
out: dict[tuple[int, int], float] = {}
for (fy, fp), row in selected.items():
val = _discrete_value(selected, fy, fp, field_name)
if val is not None:
out[(fy, _FP_TO_Q[fp])] = val
return out
def _discrete_value(selected, fy: int, fp: str, field_name: str) -> float | None:
cur = getattr(selected[(fy, fp)], field_name, None)
if cur is None:
return None
if fp == "Q1":
return cur
prev = selected.get((fy, _PREV_FP[fp]))
prev_val = getattr(prev, field_name, None) if prev is not None else None
if prev_val is None:
return None
return cur - prev_val
def _ttm(dq: dict[tuple[int, int], float], fy: int, q: int) -> float | None:
keys = [(fy, q)]
k = (fy, q)
for _ in range(3):
k = _prev_q(*k)
keys.append(k)
vals = [dq.get(kk) for kk in keys]
if any(v is None for v in vals):
return None
return sum(vals)
def _pct_change(cur: float | None, prior: float | None) -> float | None:
# A non-positive prior makes a YoY % meaningless (e.g. loss->profit), so null it.
if cur is None or prior is None or prior <= 0:
return None
return (cur / prior - 1.0) * 100.0
# -- per-metric series (value at latest + tape history) ----------------------
def _period_end(selected, fy: int, q: int) -> date | None:
row = selected.get((fy, _Q_TO_FP[q]))
return row.period_end if row is not None else None
def _yoy_growth_series(dq, selected, tape) -> MetricSeries:
pts = []
for (fy, q) in tape:
cur, prior = _ttm(dq, fy, q), _ttm(dq, fy - 1, q)
pts.append(MetricPoint(_period_end(selected, fy, q), _pct_change(cur, prior)))
return _series(pts)
def _margin_series(num_dq, den_dq, selected, tape) -> MetricSeries:
pts = []
for (fy, q) in tape:
num, den = _ttm(num_dq, fy, q), _ttm(den_dq, fy, q)
val = None if num is None or not den else num / den * 100.0
pts.append(MetricPoint(_period_end(selected, fy, q), val))
return _series(pts)
def _fcf_margin_series(discrete, selected, tape) -> MetricSeries:
pts = []
for (fy, q) in tape:
cfo, capex, rev = _ttm(discrete["cfo"], fy, q), _ttm(discrete["capex"], fy, q), _ttm(discrete["revenue"], fy, q)
val = None if cfo is None or capex is None or not rev else (cfo - capex) / rev * 100.0
pts.append(MetricPoint(_period_end(selected, fy, q), val))
return _series(pts)
def _instant_series(selected, tape, fn) -> MetricSeries:
pts = [MetricPoint(_period_end(selected, fy, q), fn(selected.get((fy, _Q_TO_FP[q])))) for (fy, q) in tape]
return _series(pts)
def _leverage_series(selected, discrete, tape) -> MetricSeries:
pts = []
for (fy, q) in tape:
row = selected.get((fy, _Q_TO_FP[q]))
nd = _net_debt(row)
op, da = _ttm(discrete["operating_income"], fy, q), _ttm(discrete["depreciation_amortization"], fy, q)
ebitda = None if op is None or da is None else op + da
# Null when EBITDA <= 0: a negative denominator would flip polarity and a
# "lower is better" read would rank a distressed issuer as favorable.
val = None if nd is None or ebitda is None or ebitda <= 0 else nd / ebitda
pts.append(MetricPoint(_period_end(selected, fy, q), val))
return _series(pts)
def _share_change_series(selected, tape) -> MetricSeries:
pts = []
for (fy, q) in tape:
cur = _shares(selected.get((fy, _Q_TO_FP[q])))
prior = _shares(selected.get((fy - 1, _Q_TO_FP[q])))
pts.append(MetricPoint(_period_end(selected, fy, q), _pct_change(cur, prior)))
return _series(pts)
def _guard_split_sensitive_metrics(metrics: dict[str, MetricSeries]) -> bool:
"""Suppress historical comparisons likely distorted by a corporate action.
Company Facts has no point-in-time split factors. A large YoY share-count
move can therefore make both the point-in-time share comparison and
per-share EPS growth non-comparable. Keep the raw facts in snapshots, but
expose nulls plus an explicit caveat in the user-facing derived series.
Returns True when the *latest* period is suspect, so callers can apply the
same suppression to per-share scalars derived from that window.
"""
shares = metrics.get("share_count_change_yoy")
eps = metrics.get("eps_growth_yoy")
if shares is None or eps is None:
return False
suspect_periods = {
point.period_end
for point in shares.history
if point.value is not None
and abs(point.value) >= SPLIT_SUSPECT_SHARE_CHANGE_PCT
}
if not suspect_periods:
return False
latest_suspect = False
for series in (shares, eps):
latest_guarded = bool(
series.history and series.history[-1].period_end in suspect_periods
)
latest_suspect = latest_suspect or latest_guarded
for point in series.history:
if point.period_end in suspect_periods:
point.value = None
series.value = series.history[-1].value if series.history else None
if latest_guarded:
series.caveat = SPLIT_SENSITIVE_CAVEAT
return latest_suspect
def _net_debt(row: Any) -> float | None:
if row is None:
return None
cash = getattr(row, "cash_and_st_investments", None)
debt = getattr(row, "total_debt", None)
# Require BOTH components — treating a missing side as zero would produce a
# partial, misleading value.
if cash is None or debt is None:
return None
return debt - cash # positive = net debt
def _shares(row: Any) -> float | None:
return getattr(row, "shares_outstanding", None) if row is not None else None
def _series(points: list[MetricPoint]) -> MetricSeries:
value = points[-1].value if points else None
return MetricSeries(value=value, history=points)
+498
View File
@@ -0,0 +1,498 @@
"""Read-only A5 comparison of legacy and SEC/Dolt fundamental inputs.
The report deliberately does not write ``fundamental_data`` or score tables.
It reconstructs the current legacy and candidate fundamental scores, projects
their composite-score/rank effect with the active weights, and archives a
timestamped JSON + CSV bundle for explicit human approval.
"""
from __future__ import annotations
import csv
import io
import json
import math
import os
import statistics
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from zoneinfo import ZoneInfo
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.data_import_run import DataImportRun
from app.models.fundamental import FundamentalData
from app.services import fundamentals_candidate_service as candidate_service
REPORT_VERSION = 1
APPROVAL_STATUS = "pending_explicit_approval"
FIELD_KEYS = ("pe_ratio", "revenue_growth", "earnings_surprise")
MIN_SCORE_METRICS = 2
# Materiality is a review aid, never an automatic cutover verdict. Definition
# changes remain visible even when a delta falls inside these bands.
FIELD_TOLERANCES = {
"pe_ratio": {"absolute": 1.0, "relative_pct": 10.0},
"revenue_growth": {"absolute": 2.0, "relative_pct": None},
"earnings_surprise": {"absolute": 2.0, "relative_pct": None},
}
DEFINITION_NOTES = {
"pe_ratio": (
"Legacy provider P/E convention versus latest close divided by "
"SEC-derived TTM diluted EPS."
),
"revenue_growth": (
"Legacy provider growth convention versus SEC-derived TTM revenue YoY."
),
"earnings_surprise": (
"Legacy provider latest surprise versus latest completed Dolt earnings "
"event with actual and estimate."
),
}
def fundamental_score(
pe_ratio: float | None,
revenue_growth: float | None,
earnings_surprise: float | None,
) -> float | None:
"""Match the production fundamental-dimension formula without persistence."""
scores: list[float] = []
if _finite(pe_ratio) and pe_ratio > 0:
scores.append(max(0.0, min(100.0, 100.0 - (pe_ratio - 15.0) * (100.0 / 30.0))))
if _finite(revenue_growth):
scores.append(max(0.0, min(100.0, 50.0 + revenue_growth * 2.5)))
if _finite(earnings_surprise):
scores.append(max(0.0, min(100.0, 50.0 + earnings_surprise * 5.0)))
return sum(scores) / len(scores) if len(scores) >= MIN_SCORE_METRICS else None
async def build_report(
db: AsyncSession,
*,
generated_at: datetime | None = None,
today: date | None = None,
) -> dict[str, Any]:
"""Build a point-in-time parity report from one database session."""
generated_at = generated_at or datetime.now(timezone.utc)
today = today or datetime.now(ZoneInfo("America/New_York")).date()
# A report must not mix rows from before and after a concurrent import
# promotion. The scheduled job provides a fresh session, so establish the
# production snapshot before its first query and have Postgres enforce the
# no-write contract as well. SQLite tests retain their normal transaction.
if db.get_bind().dialect.name == "postgresql":
connection = await db.connection(
execution_options={"isolation_level": "REPEATABLE READ"}
)
await connection.execute(text("SET TRANSACTION READ ONLY"))
candidates = await candidate_service.build_candidates(db, today=today)
ticker_ids = [candidate.ticker_id for candidate in candidates]
legacy_by_ticker = await _legacy_values(db, ticker_ids)
source_runs = await _source_runs(db)
rows: list[dict[str, Any]] = []
for candidate in candidates:
legacy = legacy_by_ticker.get(candidate.ticker_id)
candidate_values = {
"pe_ratio": candidate.pe_ratio,
"revenue_growth": candidate.revenue_growth,
"earnings_surprise": candidate.earnings_surprise,
}
legacy_values = {
"pe_ratio": legacy.pe_ratio if legacy else None,
"revenue_growth": legacy.revenue_growth if legacy else None,
"earnings_surprise": legacy.earnings_surprise if legacy else None,
}
fields = {
key: _field_comparison(key, legacy_values[key], candidate_values[key])
for key in FIELD_KEYS
}
legacy_score = fundamental_score(**legacy_values)
candidate_score = fundamental_score(**candidate_values)
rows.append(
{
"symbol": candidate.symbol,
"cik": candidate.cik,
"legacy_fetched_at": _iso(legacy.fetched_at) if legacy else None,
"price_date": _iso(candidate.price_date),
"fields": fields,
"scores": {
"legacy_fundamental": _round(legacy_score),
"candidate_fundamental": _round(candidate_score),
"fundamental_delta": _delta(legacy_score, candidate_score),
"legacy_fundamental_rank": None,
"candidate_fundamental_rank": None,
"fundamental_rank_change": None,
},
}
)
_attach_ranks(rows, "legacy_fundamental", "legacy_fundamental_rank")
_attach_ranks(rows, "candidate_fundamental", "candidate_fundamental_rank")
for row in rows:
scores = row["scores"]
scores["fundamental_rank_change"] = _rank_change(
scores["legacy_fundamental_rank"], scores["candidate_fundamental_rank"]
)
return {
"report_version": REPORT_VERSION,
"generated_at": generated_at.isoformat(),
"as_of_date": today.isoformat(),
"approval_status": APPROVAL_STATUS,
"read_only": True,
"fundamental_score_formula": (
"Equal-weighted mean of 2+ available sub-scores: P/E = "
"clamp(100-(pe-15)*(100/30)); revenue growth = "
"clamp(50+growth*2.5); earnings surprise = "
"clamp(50+surprise*5)."
),
"source_runs": source_runs,
"definition_notes": DEFINITION_NOTES,
"materiality_notes": {
"fields": FIELD_TOLERANCES,
"fundamental_score_absolute": 5.0,
"automatic_cutover": False,
},
"summary": _summary(rows),
"rows": rows,
}
def store_report(report: dict[str, Any], report_dir: str | Path) -> dict[str, str]:
"""Atomically archive JSON/CSV artifacts and update the latest manifest."""
directory = Path(report_dir).expanduser().resolve()
directory.mkdir(parents=True, exist_ok=True)
stamp = _artifact_stamp(report["generated_at"])
json_name = f"fundamentals-parity-{stamp}.json"
csv_name = f"fundamentals-parity-{stamp}.csv"
json_path = directory / json_name
csv_path = directory / csv_name
_atomic_write(json_path, json.dumps(report, indent=2, sort_keys=True) + "\n")
_atomic_write(csv_path, report_csv(report))
manifest = {
"generated_at": report["generated_at"],
"json_file": json_name,
"csv_file": csv_name,
}
_atomic_write(
directory / "latest.json",
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
)
return {
"json": str(json_path),
"csv": str(csv_path),
"manifest": str(directory / "latest.json"),
}
async def generate_and_store(
db: AsyncSession,
report_dir: str | Path,
*,
generated_at: datetime | None = None,
today: date | None = None,
) -> tuple[dict[str, Any], dict[str, str]]:
report = await build_report(db, generated_at=generated_at, today=today)
return report, store_report(report, report_dir)
def load_latest(report_dir: str | Path) -> dict[str, Any] | None:
manifest = _load_manifest(report_dir)
if manifest is None:
return None
try:
path = _manifest_artifact(report_dir, manifest, "json_file")
loaded = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return None
return loaded if isinstance(loaded, dict) else None
def load_latest_csv(report_dir: str | Path) -> tuple[str, str] | None:
return _load_latest_text_artifact(report_dir, "csv_file")
def load_latest_json(report_dir: str | Path) -> tuple[str, str] | None:
return _load_latest_text_artifact(report_dir, "json_file")
def _load_latest_text_artifact(
report_dir: str | Path, manifest_key: str
) -> tuple[str, str] | None:
manifest = _load_manifest(report_dir)
if manifest is None:
return None
try:
path = _manifest_artifact(report_dir, manifest, manifest_key)
return path.name, path.read_text(encoding="utf-8")
except (OSError, TypeError, ValueError):
return None
def report_csv(report: dict[str, Any]) -> str:
output = io.StringIO(newline="")
columns = [
"symbol",
"cik",
"legacy_fetched_at",
"price_date",
*(
f"{field}_{suffix}"
for field in FIELD_KEYS
for suffix in ("legacy", "candidate", "absolute_delta", "relative_delta_pct", "material")
),
"legacy_fundamental",
"candidate_fundamental",
"fundamental_delta",
"legacy_fundamental_rank",
"candidate_fundamental_rank",
"fundamental_rank_change",
]
writer = csv.DictWriter(output, fieldnames=columns)
writer.writeheader()
for row in report.get("rows", []):
flat = {
"symbol": row["symbol"],
"cik": row.get("cik"),
"legacy_fetched_at": row.get("legacy_fetched_at"),
"price_date": row.get("price_date"),
**row["scores"],
}
for field in FIELD_KEYS:
comparison = row["fields"][field]
for suffix in (
"legacy",
"candidate",
"absolute_delta",
"relative_delta_pct",
"material",
):
flat[f"{field}_{suffix}"] = comparison.get(suffix)
writer.writerow(flat)
return output.getvalue()
async def _legacy_values(
db: AsyncSession, ticker_ids: list[int]
) -> dict[int, FundamentalData]:
if not ticker_ids:
return {}
rows = (
await db.execute(
select(FundamentalData).where(FundamentalData.ticker_id.in_(ticker_ids))
)
).scalars()
return {row.ticker_id: row for row in rows}
async def _source_runs(db: AsyncSession) -> dict[str, dict[str, Any] | None]:
sources = ("sec_facts", "dolt_earnings")
rows = (
await db.execute(
select(DataImportRun)
.where(
DataImportRun.source.in_(sources),
DataImportRun.status.in_(("promoted", "no_op")),
)
.order_by(DataImportRun.id.desc())
)
).scalars()
latest: dict[str, dict[str, Any] | None] = {source: None for source in sources}
for row in rows:
if latest[row.source] is None:
latest[row.source] = {
"run_id": row.id,
"status": row.status,
"revision": row.revision,
"source_max_date": _iso(row.source_max_date),
"completed_at": _iso(row.completed_at),
}
return latest
def _field_comparison(
key: str, legacy: float | None, candidate: float | None
) -> dict[str, Any]:
legacy = float(legacy) if _finite(legacy) else None
candidate = float(candidate) if _finite(candidate) else None
absolute = _delta(legacy, candidate)
relative = (
None
if absolute is None or legacy in (None, 0)
else round(absolute / abs(legacy) * 100.0, 4)
)
tolerance = FIELD_TOLERANCES[key]
material = False
if absolute is not None:
material = abs(absolute) > tolerance["absolute"]
relative_limit = tolerance["relative_pct"]
if relative_limit is not None:
material = material and relative is not None and abs(relative) > relative_limit
return {
"legacy": _round(legacy),
"candidate": _round(candidate),
"absolute_delta": absolute,
"relative_delta_pct": relative,
"material": material,
"definition_changed": True,
}
def _attach_ranks(rows: list[dict[str, Any]], value_key: str, rank_key: str) -> None:
values = [
row["scores"][value_key]
for row in rows
if _finite(row["scores"][value_key])
]
for row in rows:
value = row["scores"][value_key]
row["scores"][rank_key] = (
1 + sum(other > value for other in values) if _finite(value) else None
)
def _summary(rows: list[dict[str, Any]]) -> dict[str, Any]:
field_stats = {}
for key in FIELD_KEYS:
comparisons = [row["fields"][key] for row in rows]
deltas = [
abs(item["absolute_delta"])
for item in comparisons
if item["absolute_delta"] is not None
]
field_stats[key] = {
"legacy_available": sum(item["legacy"] is not None for item in comparisons),
"candidate_available": sum(
item["candidate"] is not None for item in comparisons
),
"both_available": len(deltas),
"material_differences": sum(item["material"] for item in comparisons),
"median_absolute_delta": _round(statistics.median(deltas) if deltas else None),
"p95_absolute_delta": _round(_percentile(deltas, 0.95)),
"max_absolute_delta": _round(max(deltas) if deltas else None),
}
fundamental_deltas = _score_deltas(rows, "fundamental_delta")
changed_rows = sorted(
(
{
"symbol": row["symbol"],
"fundamental_delta": row["scores"]["fundamental_delta"],
"fundamental_rank_change": row["scores"]["fundamental_rank_change"],
}
for row in rows
if row["scores"]["fundamental_delta"] is not None
),
key=lambda item: (
abs(item["fundamental_delta"] or 0),
),
reverse=True,
)[:20]
return {
"universe_count": len(rows),
"legacy_fundamental_score_available": _count_score(
rows, "legacy_fundamental"
),
"candidate_fundamental_score_available": _count_score(
rows, "candidate_fundamental"
),
"fundamental_scores_compared": len(fundamental_deltas),
"fundamental_score_material_changes": sum(
abs(delta) > 5.0 for delta in fundamental_deltas
),
"fundamental_rank_changes": _rank_change_count(
rows, "fundamental_rank_change"
),
"field_stats": field_stats,
"largest_changes": changed_rows,
}
def _score_deltas(rows: Iterable[dict[str, Any]], key: str) -> list[float]:
return [
row["scores"][key]
for row in rows
if row["scores"][key] is not None
]
def _count_score(rows: Iterable[dict[str, Any]], key: str) -> int:
return sum(row["scores"][key] is not None for row in rows)
def _rank_change_count(rows: Iterable[dict[str, Any]], key: str) -> int:
return sum(
row["scores"][key] not in (None, 0)
for row in rows
)
def _rank_change(legacy: int | None, candidate: int | None) -> int | None:
# Positive means the candidate improved its rank.
return legacy - candidate if legacy is not None and candidate is not None else None
def _delta(legacy: float | None, candidate: float | None) -> float | None:
if not _finite(legacy) or not _finite(candidate):
return None
return round(candidate - legacy, 4)
def _round(value: float | None, digits: int = 4) -> float | None:
return round(float(value), digits) if _finite(value) else None
def _percentile(values: list[float], quantile: float) -> float | None:
if not values:
return None
ordered = sorted(values)
index = max(0, math.ceil(quantile * len(ordered)) - 1)
return ordered[index]
def _finite(value: Any) -> bool:
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(value)
)
def _iso(value: Any) -> str | None:
return value.isoformat() if value is not None else None
def _artifact_stamp(raw: str) -> str:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
return parsed.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
def _atomic_write(path: Path, content: str) -> None:
temp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
temp.write_text(content, encoding="utf-8", newline="")
os.replace(temp, path)
def _load_manifest(report_dir: str | Path) -> dict[str, Any] | None:
path = Path(report_dir).expanduser().resolve() / "latest.json"
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return None
return loaded if isinstance(loaded, dict) else None
def _manifest_artifact(
report_dir: str | Path, manifest: dict[str, Any], key: str
) -> Path:
directory = Path(report_dir).expanduser().resolve()
name = Path(str(manifest.get(key, ""))).name
if not name:
raise ValueError(f"Latest parity manifest has no {key}")
return directory / name
+107
View File
@@ -0,0 +1,107 @@
"""Pure peer comparison for fundamentals (read-time).
Peers are tracked-universe issuers sharing the **first two SIC digits**,
deduplicated by CIK (GOOG/GOOGL are one issuer, one observation). This module is
the pure statistics core: given a subject value and the peer group's values for a
metric, it returns median + polarity-aware favorable percentile + peer_count, or
None when there are fewer than the minimum valid peers (the caller then omits the
industry object entirely rather than show a misleading comparison).
Grouping (which issuers share a 2-digit SIC, CIK-dedup) is the API's job; this
module only does the math. **Absolute net_debt is size-dependent and must not get
a peer percentile** leverage is compared via net_debt_to_ebitda.
"""
from __future__ import annotations
import math
import statistics
from dataclasses import dataclass
from typing import Any
MIN_PEERS = 5
def _finite(v: Any) -> bool:
"""True for a finite number — excludes None, bool, NaN, ±inf (plan: null/invalid)."""
return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
# Metric -> is a higher value more favorable? (Peer-eligible metrics only;
# absolute net_debt is intentionally absent — size-dependent.)
HIGHER_IS_BETTER: dict[str, bool] = {
"revenue_growth_yoy": True,
"eps_growth_yoy": True,
"operating_margin": True,
"fcf_margin": True,
"fcf_yield": True,
"net_debt_to_ebitda": False, # lower leverage is better
"pe": False, # cheaper is better
"share_count_change_yoy": False, # dilution is bad
}
@dataclass
class PeerStat:
median: float
favorable_percentile: int # 0-100, polarity-aware (higher = more favorable)
peer_count: int # valid issuers in the group
def peer_stat(
subject: float | None,
group_values: list[float | None],
*,
higher_is_better: bool,
min_peers: int = MIN_PEERS,
) -> PeerStat | None:
"""Median + favorable percentile for ``subject`` within its group.
``group_values`` is every issuer's value for the metric (including the
subject), CIK-deduplicated by the caller. Null/invalid (non-finite) values are
excluded. Returns None when fewer than ``min_peers`` valid values exist, or
the subject is null/invalid.
The percentile is a **tie-aware rank against the other issuers**
``(worse + 0.5·tied) / (peers 1)`` so a whole group of equal values maps
to 50, not 100, and the median maps to 50.
"""
valid = [v for v in group_values if _finite(v)]
if not _finite(subject) or len(valid) < min_peers:
return None
median = statistics.median(valid)
others = valid.copy()
try:
others.remove(subject) # rank the subject against the OTHER issuers
except ValueError:
pass
denom = len(others)
if denom == 0:
return None
if higher_is_better:
worse = sum(1 for v in others if v < subject)
else:
worse = sum(1 for v in others if v > subject)
tied = sum(1 for v in others if v == subject)
percentile = round((worse + 0.5 * tied) / denom * 100)
return PeerStat(median=median, favorable_percentile=percentile, peer_count=len(valid))
def peer_stat_for(
metric_key: str, subject: float | None, group_values: list[float | None], **kwargs
) -> PeerStat | None:
"""Convenience wrapper that looks up polarity by metric key. Returns None for
metrics not eligible for peer comparison (e.g. absolute net_debt)."""
if metric_key not in HIGHER_IS_BETTER:
return None
return peer_stat(
subject, group_values, higher_is_better=HIGHER_IS_BETTER[metric_key], **kwargs
)
def two_digit_sic(sic: str | None) -> str | None:
"""The 2-digit SIC prefix used for grouping, or None if unusable."""
if not sic:
return None
digits = str(sic).strip()
return digits[:2] if len(digits) >= 2 and digits[:2].isdigit() else None
@@ -0,0 +1,164 @@
"""Actionability gate for incomplete SEC fundamentals."""
from __future__ import annotations
import json
from dataclasses import dataclass
from sqlalchemy import exists, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.data_import_run import DataImportRun
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.sec_filing_gap import SecFilingGap
from app.models.ticker import Ticker
from app.services import fundamental_data_refresh_service
_SEC_FORMS = ("10-K", "10-Q", "10-K/A", "10-Q/A")
@dataclass(frozen=True)
class SetupQuality:
eligible: bool
code: str | None = None
message: str | None = None
async def active_gaps(
db: AsyncSession,
ciks: set[str] | None = None,
) -> list[SecFilingGap]:
"""Unresolved gaps that have not been superseded by a later filing."""
matching_snapshot = exists().where(
FundamentalSnapshot.accession == SecFilingGap.accession
)
gap_date = func.coalesce(
SecFilingGap.index_date,
func.date(SecFilingGap.first_seen_at),
)
later_snapshot = exists().where(
FundamentalSnapshot.cik == SecFilingGap.cik,
FundamentalSnapshot.form.in_(_SEC_FORMS),
FundamentalSnapshot.filed_date > gap_date,
)
stmt = select(SecFilingGap).where(
~matching_snapshot,
~later_snapshot,
)
if ciks is not None:
if not ciks:
return []
stmt = stmt.where(SecFilingGap.cik.in_(ciks))
return list((await db.execute(stmt)).scalars().all())
async def _latest_validation(db: AsyncSession) -> dict:
payload = (
await db.execute(
select(DataImportRun.validation_json)
.where(
DataImportRun.source == "sec_facts",
DataImportRun.validation_json.is_not(None),
)
.order_by(DataImportRun.id.desc())
.limit(1)
)
).scalar_one_or_none()
if not payload:
return {}
try:
summary = json.loads(payload)
except (TypeError, ValueError):
return {}
return summary if isinstance(summary, dict) else {}
async def blocked_reasons_by_cik(
db: AsyncSession,
ciks: set[str] | None = None,
) -> dict[str, str]:
"""Current SEC blocker code by CIK; no historical audit scan."""
if not await fundamental_data_refresh_service.is_enabled(db):
return {}
if ciks is not None and not ciks:
return {}
reasons = {
gap.cik: "sec_filing_gap" for gap in await active_gaps(db, ciks)
}
summary = await _latest_validation(db)
def wanted(cik: str) -> bool:
return ciks is None or cik in ciks
# New summaries carry the complete compact CIK set while the detailed lists
# stay capped for audit readability. Detailed entries supply the reason.
for cik in summary.get("setup_blocked_ciks") or []:
normalized = str(cik) if cik else ""
if normalized and wanted(normalized):
reasons.setdefault(normalized, "sec_filing_gap")
for item in summary.get("missing_xbrl") or []:
normalized = str(item.get("cik") or "")
if normalized and wanted(normalized):
reasons.setdefault(normalized, "sec_filing_gap")
for cik in summary.get("no_xbrl_ciks") or []:
normalized = str(cik) if cik else ""
if normalized and wanted(normalized):
reasons[normalized] = "no_xbrl_filings"
for item in summary.get("no_xbrl_filings") or []:
normalized = str(item.get("cik") or "")
if normalized and wanted(normalized):
reasons[normalized] = "no_xbrl_filings"
return reasons
async def blocked_ciks(db: AsyncSession) -> set[str]:
return set(await blocked_reasons_by_cik(db))
async def blocked_ticker_ids(db: AsyncSession) -> set[int]:
ciks = await blocked_ciks(db)
if not ciks:
return set()
rows = await db.execute(select(Ticker.id).where(Ticker.cik.in_(ciks)))
return {int(ticker_id) for ticker_id in rows.scalars()}
async def ticker_quality(db: AsyncSession, symbol: str) -> SetupQuality:
ticker = (
await db.execute(
select(Ticker).where(Ticker.symbol == symbol.strip().upper())
)
).scalar_one_or_none()
if ticker is None or not ticker.cik:
return SetupQuality(eligible=True)
reason = (await blocked_reasons_by_cik(db, {ticker.cik})).get(ticker.cik)
if reason == "no_xbrl_filings":
return SetupQuality(
eligible=False,
code=reason,
message=(
"No SEC 10-K/10-Q is available for this registrant, so new setups "
"are paused. New registrants clear automatically after their first "
"filing; a successor shell needs an SEC CIK override."
),
)
if reason:
return SetupQuality(
eligible=False,
code=reason,
message=(
"A recent SEC filing is still being reconciled, so new setups are "
"paused. The scheduled fundamentals import retries it automatically."
),
)
return SetupQuality(eligible=True)
async def ticker_is_eligible(db: AsyncSession, ticker_id: int) -> bool:
cik = (
await db.execute(select(Ticker.cik).where(Ticker.id == ticker_id))
).scalar_one_or_none()
if not cik:
return True
return cik not in await blocked_reasons_by_cik(db, {cik})
+115
View File
@@ -0,0 +1,115 @@
"""Deterministic text 'reads' for the fundamentals panel (pure, one rule set).
The tape reads and the header sentence use identical outputs no LLM, no new
composite score. Thresholds are tunable named constants, not scattered literals
(plan: ±2pp growth, ±1pp margins, ±1% dilution, 60/40 peer bands, 3 periods).
Consumers pass metric series (value + dated history, from
``fundamentals_derivation``) and peer percentiles; these functions return short
strings or None (render "", no read).
"""
from __future__ import annotations
from statistics import mean
from typing import Any
MIN_PERIODS = 3
GROWTH_ACCEL_PP = 2.0
MARGIN_MOVE_PP = 1.0
SHARE_DILUTION_PCT = 1.0
PEER_FAVORABLE = 60
PEER_ADVERSE = 40
def _latest_run(history: list[Any]) -> list[float]:
"""The consecutive non-null values ending at the latest point (oldest->newest).
A null latest, or an internal gap, truncates the run so a read never reflects
a period whose displayed value is n/a."""
run: list[float] = []
for p in reversed(history):
if p.value is None:
break
run.append(p.value)
run.reverse()
return run
def growth_read(history: list[Any]) -> str | None:
"""Change in a YoY-growth series: latest prior. Needs >= 3 consecutive
non-null values ending at the latest point."""
vals = _latest_run(history)
if len(vals) < MIN_PERIODS:
return None
delta = vals[-1] - vals[-2]
if delta >= GROWTH_ACCEL_PP:
return "accelerating"
if delta <= -GROWTH_ACCEL_PP:
return "decelerating"
return "steady"
def margin_read(history: list[Any]) -> str | None:
"""Latest margin vs the mean of prior periods (pp). Needs >= 3 consecutive
non-null values ending at the latest point."""
vals = _latest_run(history)
if len(vals) < MIN_PERIODS:
return None
delta = vals[-1] - mean(vals[:-1])
if delta >= MARGIN_MOVE_PP:
return "improving"
if delta <= -MARGIN_MOVE_PP:
return "deteriorating"
return "stable"
def share_count_read(value: float | None) -> str | None:
"""Share-count YoY %: >+1% dilution, <-1% buying back, else flat."""
if value is None:
return None
if value > SHARE_DILUTION_PCT:
return f"{value:.1f}% dilution"
if value < -SHARE_DILUTION_PCT:
return "buying back"
return "flat"
def peer_read(metric_key: str, favorable_percentile: int | None) -> str | None:
"""Peer-relative read for a metric, polarity already baked into the
percentile (higher = more favorable)."""
if favorable_percentile is None:
return None
if favorable_percentile >= PEER_FAVORABLE:
return _FAVORABLE.get(metric_key, "above peers")
if favorable_percentile <= PEER_ADVERSE:
return _ADVERSE.get(metric_key, "below peers")
return "in line"
_FAVORABLE = {
"pe": "attractively valued",
"fcf_yield": "above peers",
"net_debt_to_ebitda": "conservative leverage",
}
_ADVERSE = {
"pe": "priced above peers",
"fcf_yield": "below peers",
"net_debt_to_ebitda": "elevated leverage",
}
def header_sentence(
growth: str | None, margin: str | None, valuation: str | None
) -> str:
"""Join the growth / margin / peer-valuation reads with ' · ', omitting
segments with no read. Segment sources are fixed by the caller (growth =
revenue-growth read, margin = operating-margin read, valuation = P/E peer
read falling back to FCF yield)."""
parts = []
if growth:
parts.append(f"growth {growth}")
if margin:
parts.append(f"margins {margin}")
if valuation:
parts.append(f"valuation {valuation}")
return " · ".join(parts)
+29 -3
View File
@@ -100,6 +100,8 @@ async def fetch_and_ingest(
symbol: str, symbol: str,
start_date: date | None = None, start_date: date | None = None,
end_date: date | None = None, end_date: date | None = None,
*,
refresh_sr: bool = True,
) -> IngestionResult: ) -> IngestionResult:
"""Fetch OHLCV data from provider and upsert into Price Store. """Fetch OHLCV data from provider and upsert into Price Store.
@@ -129,7 +131,12 @@ async def fetch_and_ingest(
if bar_count < minimum_backfill_bars: if bar_count < minimum_backfill_bars:
start_date = backfill_start start_date = backfill_start
elif progress is not None: elif progress is not None:
start_date = progress.last_ingested_date + timedelta(days=1) # Re-fetch the latest stored session so an in-progress daily bar can
# be overwritten as the market moves. Starting one day later makes
# every subsequent intraday, near-close, and manual refresh skip
# today's bar once the first partial snapshot has been stored.
# The price-store upsert keeps this one-session overlap idempotent.
start_date = progress.last_ingested_date
else: else:
start_date = backfill_start start_date = backfill_start
@@ -239,7 +246,7 @@ async def fetch_and_ingest(
ticker.symbol, ticker.symbol,
ingested_count, ingested_count,
) )
if ingested_count > 0: if ingested_count > 0 and refresh_sr:
await _refresh_structural_sr(db, ticker.symbol) await _refresh_structural_sr(db, ticker.symbol)
return IngestionResult( return IngestionResult(
symbol=ticker.symbol, symbol=ticker.symbol,
@@ -249,9 +256,28 @@ async def fetch_and_ingest(
message=f"Rate limited. Ingested {ingested_count} records. Resume available.", message=f"Rate limited. Ingested {ingested_count} records. Resume available.",
) )
if ingested_count > 0: if ingested_count > 0 and refresh_sr:
await _refresh_structural_sr(db, ticker.symbol) await _refresh_structural_sr(db, ticker.symbol)
# Incremental fetches deliberately overlap the latest stored session so an
# in-progress bar can be updated. A halted/delisted symbol can therefore
# return one old bar forever; non-empty no longer means fresh. Judge stale
# state from the newest stored session after the upserts instead.
latest = await _get_latest_ohlcv_date(db, ticker.id)
gap_days = (end_date - latest).days if latest is not None else None
if gap_days is not None and gap_days > _STALE_OHLCV_GAP_DAYS:
return IngestionResult(
symbol=ticker.symbol,
records_ingested=ingested_count,
last_date=latest,
status="stale",
message=(
f"No new bars since {latest.isoformat()} ({gap_days}d gap). "
"The symbol may be halted, delisted, or renamed under a new ticker — "
"check the listing and add/fetch the current symbol if it changed."
),
)
return IngestionResult( return IngestionResult(
symbol=ticker.symbol, symbol=ticker.symbol,
records_ingested=ingested_count, records_ingested=ingested_count,
+40 -1
View File
@@ -352,6 +352,7 @@ def _to_dict(
current_price: float | None, current_price: float | None,
benchmark_closes: dict[date, float] | None = None, benchmark_closes: dict[date, float] | None = None,
trailing: tuple[float, float | None] | None = None, trailing: tuple[float, float | None] | None = None,
holding_sessions: tuple[int, int] | None = None,
) -> dict: ) -> dict:
# For open trades, mark to market; for closed, the realized exit price. # For open trades, mark to market; for closed, the realized exit price.
ref = current_price if trade.status == "open" else trade.close_price ref = current_price if trade.status == "open" else trade.close_price
@@ -395,6 +396,8 @@ def _to_dict(
"fill_mode": trade.fill_mode, "fill_mode": trade.fill_mode,
"trailing_stop": trailing[0] if trailing else None, "trailing_stop": trailing[0] if trailing else None,
"trailing_distance_pct": trailing[1] if trailing else None, "trailing_distance_pct": trailing[1] if trailing else None,
"sessions_held": holding_sessions[0] if holding_sessions else None,
"sessions_remaining": holding_sessions[1] if holding_sessions else None,
} }
@@ -435,6 +438,35 @@ async def list_trades(
# Current trailing-stop level + distance for open trades (when a trailing # Current trailing-stop level + distance for open trades (when a trailing
# policy is active). # policy is active).
policy = await get_exit_policy(db) policy = await get_exit_policy(db)
holding_sessions: dict[int, tuple[int, int]] = {}
if policy["mode"] in ("time", "atr_trailing"):
hold_days = int(policy["hold_days"])
open_trades = [trade for trade, _ in rows if trade.status == "open"]
if open_trades:
ticker_ids = {trade.ticker_id for trade in open_trades}
earliest_opened = min(trade.opened_at.date() for trade in open_trades)
session_rows = (
await db.execute(
select(OHLCVRecord.ticker_id, OHLCVRecord.date)
.where(
OHLCVRecord.ticker_id.in_(ticker_ids),
OHLCVRecord.date > earliest_opened,
)
.order_by(OHLCVRecord.ticker_id, OHLCVRecord.date)
)
).all()
dates_by_ticker: dict[int, list[date]] = {}
for ticker_id, session_date in session_rows:
dates_by_ticker.setdefault(int(ticker_id), []).append(session_date)
for trade in open_trades:
dates = dates_by_ticker.get(trade.ticker_id, [])
held = len(dates) - bisect.bisect_right(
dates, trade.opened_at.date()
)
# Do not clamp: a policy shortened below the current holding
# period must remain visible as overdue until the exit pass runs.
holding_sessions[trade.id] = (held, hold_days - held)
trailing_info: dict[int, tuple[float, float | None]] = {} trailing_info: dict[int, tuple[float, float | None]] = {}
if policy["mode"] == "trailing": if policy["mode"] == "trailing":
trail_frac = policy["trailing_pct"] / 100.0 trail_frac = policy["trailing_pct"] / 100.0
@@ -483,7 +515,14 @@ async def list_trades(
trailing_info[t.id] = (level, dist) trailing_info[t.id] = (level, dist)
return [ return [
_to_dict(t, sym, prices.get(t.ticker_id), benchmark_closes, trailing_info.get(t.id)) _to_dict(
t,
sym,
prices.get(t.ticker_id),
benchmark_closes,
trailing_info.get(t.id),
holding_sessions.get(t.id),
)
for t, sym in rows for t, sym in rows
] ]
+250 -78
View File
@@ -1,16 +1,23 @@
"""AI/Tech Regime Monitor v2. """AI/Tech Regime Monitor v3.
The monitor is a risk thermometer, not a probability or trading rule. It keeps The monitor is a risk thermometer, not a probability or trading rule. It keeps
two deliberately separate outputs: two deliberately separate outputs:
* State: current structural stress (price, breadth, credit, volatility). * State: current structural stress (price, breadth, credit, volatility).
* Warning: deterioration/divergence that may precede State (breadth, relative * Warning: deterioration/divergence that may precede State (breadth divergence,
strength, and sourced fundamental observations). relative strength, credit impulse).
Daily snapshots are the point-in-time record. The first v2 run rewrites the Both scores are quantitative and daily. The sourced hyperscaler capex and
latest ``REBUILD_SESSIONS`` trading sessions once; ordinary runs thereafter only earnings-reaction observations are a qualitative *overlay* in v3 rather than
upsert the latest trading date. Fundamental observations are never replayed weighted sensors: at a combined 20 points they could not reach the event
before their effective date. study's alarm threshold even when both pegged, so refreshing them appeared to
do nothing. They are reported next to the scores instead of inside them.
Daily snapshots are the point-in-time record. The first run under a new
``METHODOLOGY`` rewrites the latest ``REBUILD_SESSIONS`` trading sessions once;
ordinary runs thereafter only upsert the latest trading date. The overlay is
still gated by its effective date so a rebuild cannot stamp today's observation
onto historical snapshots.
""" """
from __future__ import annotations from __future__ import annotations
@@ -41,19 +48,51 @@ _CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
KEY_CONFIG = "regime_monitor_config" KEY_CONFIG = "regime_monitor_config"
KEY_FUNDAMENTALS = "regime_fundamental_overrides" KEY_FUNDAMENTALS = "regime_fundamental_overrides"
METHODOLOGY = "v2" METHODOLOGY = "v3"
# Snapshots are reseeded on a methodology bump, but fundamental observations are
# collected by hand/LLM and carried across it when the format is compatible.
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
REBUILD_SESSIONS = 400 REBUILD_SESSIONS = 400
MIN_COVERAGE = 75.0 MIN_COVERAGE = 75.0
SOURCE_MAX_LAG_DAYS = 7 SOURCE_MAX_LAG_DAYS = 7
QUADRANT_STATE_DIVIDER = 60.0 # Bands are per axis: the two scores have genuinely different realized ranges,
QUADRANT_WARNING_DIVIDER = 60.0 # so one shared set made Warning's top bands unreachable (v2 Warning never
# exceeded 64.9 in 408 sessions while State reached 91.2). Thresholds are round
# numbers chosen so each band covers a sane share of history, not percentile
# fits -- percentile-derived bands would drift on every rebuild and silently
# rewrite what past snapshots meant. Realized shares over the 408 sessions to
# 2026-07-24: State 73/15/8/3%, Warning 69/20/8/3%.
STATE_BANDS = (20.0, 50.0, 80.0)
WARNING_BANDS = (20.0, 40.0, 60.0)
QUADRANT_STATE_DIVIDER = 50.0
QUADRANT_WARNING_DIVIDER = 40.0
QUADRANT_MARGIN = 5.0 QUADRANT_MARGIN = 5.0
HY_OAS_MILD = 3.5 HY_OAS_MILD = 3.5
HY_OAS_ELEVATED = 5.0 HY_OAS_ELEVATED = 5.0
HY_OAS_STRESSED = 7.0 HY_OAS_STRESSED = 7.0
HY_OAS_REFERENCE_YEARS = 10.0 # ICE restricted FRED to a rolling 3-year window for BAMLH0A0HYM2 in April 2026
# ("Starting in April 2026, this series will only include 3 years of
# observations"), so v2's 10-year reference window silently became 3. Against 3
# years of uniformly tight spreads (2.59-4.61 over the calibration window) the
# blended upper-tail percentile saturated at an OAS of ~4.5 and scored 20 points
# of stress at 3.5 -- the level these anchors call "mild". The anchors already
# encode the long-run distribution, so the credit *level* is now purely anchored
# and credit *dynamics* live in W3 on the Warning axis where they belong.
HY_OAS_WINDOW_DAYS = 400 # only W3's lookback plus slack is needed now
W3_OAS_LOOKBACK = 20
W3_OAS_FULL_SCALE_PCT = 35.0
# Drawdown anchors (drawdown %, stress score). v2 used a bare ``dd_pct * 5``,
# which pegged at a 20% drawdown -- the 90th percentile of the observed
# distribution -- so 39 of 408 sessions sat at exactly 100 with no resolution
# left during the part of a selloff that matters most. These anchors keep
# headroom past the observed 36% maximum.
P3_DRAWDOWN_ANCHORS = (
(0.0, 0.0), (4.0, 10.0), (8.0, 25.0), (16.0, 50.0), (28.0, 78.0), (40.0, 100.0),
)
STATE_WEIGHTS = { STATE_WEIGHTS = {
"price": 40.0, "price": 40.0,
@@ -61,11 +100,15 @@ STATE_WEIGHTS = {
"credit": 20.0, "credit": 20.0,
"volatility": 15.0, "volatility": 15.0,
} }
# Fundamentals left the score in v3. At 12 + 8 points they could not reach the
# event study's alarm threshold even when both pegged at 100, so the LLM read was
# decorative; it is now a separate qualitative overlay. Credit *impulse* takes
# their place because the OAS level is pinned at zero below the 3.5 anchor while
# its rate of change is not.
WARNING_WEIGHTS = { WARNING_WEIGHTS = {
"breadth_divergence": 50.0, "breadth_divergence": 45.0,
"relative_strength": 30.0, "relative_strength": 30.0,
"capex": 12.0, "credit_impulse": 25.0,
"earnings_reaction": 8.0,
} }
# Fixed at the v2 launch. These are liquid S&P 500/Nasdaq AI, semiconductor, # Fixed at the v2 launch. These are liquid S&P 500/Nasdaq AI, semiconductor,
@@ -92,7 +135,10 @@ DEFAULT_CONFIG: dict = {
CAPEX_STATES = ("raising", "holding", "cutting", "unknown") CAPEX_STATES = ("raising", "holding", "cutting", "unknown")
GNSD_STATES = ("yes", "no", "mixed") GNSD_STATES = ("yes", "no", "mixed")
_CAPEX_STATE_SCORES = {"raising": 0.0, "holding": 0.0, "cutting": 100.0} # v2 scored raising and holding identically at 0, so in a capex boom the reading
# was pinned at 0 and could not express the raising -> holding deceleration that
# is the actual early warning. Display-only in v3, but it should still describe.
_CAPEX_STATE_SCORES = {"raising": 0.0, "holding": 50.0, "cutting": 100.0}
_GNSD_SCORES = {"yes": 100.0, "no": 0.0} _GNSD_SCORES = {"yes": 100.0, "no": 0.0}
Series = list[tuple[date, float]] Series = list[tuple[date, float]]
@@ -127,12 +173,23 @@ def _blend(leader: float | None, confirm: float | None, leader_weight: float = 2
return sum(v * w for v, w in parts) / sum(w for _, w in parts) return sum(v * w for v, w in parts) / sum(w for _, w in parts)
def band_for(score: float) -> str: def _interpolate(x: float, anchors: tuple[tuple[float, float], ...]) -> float:
if score < 30: """Piecewise-linear lookup, flat outside the first and last anchor."""
if x <= anchors[0][0]:
return anchors[0][1]
for (x0, y0), (x1, y1) in zip(anchors, anchors[1:]):
if x <= x1:
return y0 + (y1 - y0) * (x - x0) / (x1 - x0)
return anchors[-1][1]
def band_for(score: float, bands: tuple[float, float, float] = STATE_BANDS) -> str:
watch, elevated, breaking = bands
if score < watch:
return "stable" return "stable"
if score < 60: if score < elevated:
return "watch" return "watch"
if score < 80: if score < breaking:
return "elevated" return "elevated"
return "breaking" return "breaking"
@@ -167,19 +224,29 @@ def p2_death_cross(smh: list[float], qqq: list[float], leader_weight: float = 2.
return _blend(_death_cross(smh), _death_cross(qqq), leader_weight) return _blend(_death_cross(smh), _death_cross(qqq), leader_weight)
def _drawdown(closes: list[float]) -> float | None: def drawdown_pct(closes: list[float]) -> float | None:
"""Percentage below the trailing 52-week closing high."""
if len(closes) < 30: if len(closes) < 30:
return None return None
peak = max(closes[-252:]) peak = max(closes[-252:])
if peak <= 0: if peak <= 0:
return None return None
dd_pct = (peak - closes[-1]) / peak * 100.0 return (peak - closes[-1]) / peak * 100.0
return _clamp(dd_pct * 5.0)
def p3_drawdown(smh: list[float], qqq: list[float]) -> float | None: def _drawdown(closes: list[float]) -> float | None:
vals = [v for v in (_drawdown(smh), _drawdown(qqq)) if v is not None] dd_pct = drawdown_pct(closes)
return max(vals) if vals else None return None if dd_pct is None else _clamp(_interpolate(dd_pct, P3_DRAWDOWN_ANCHORS))
def p3_drawdown(smh: list[float], qqq: list[float], leader_weight: float = 2.0) -> float | None:
"""Anchored drawdown stress on the same 2:1 leader/confirm blend P1 and P2 use.
v2 took ``max()`` here, which meant the more volatile leader always won and
the price pillar reduced to this one sensor: its realized share of State was
65% against a nominal 40% weight. Blending brings that back to 40%.
"""
return _blend(_drawdown(smh), _drawdown(qqq), leader_weight)
def p4_relative_strength(smh: list[float], spy: list[float], lookback: int = 60) -> float | None: def p4_relative_strength(smh: list[float], spy: list[float], lookback: int = 60) -> float | None:
@@ -220,18 +287,68 @@ def _oas_absolute_score(value: float) -> float:
def f2_credit_spreads(oas_values: list[float]) -> float | None: def f2_credit_spreads(oas_values: list[float]) -> float | None:
"""HY OAS stress: 70% named absolute anchors + 30% upper-tail percentile.""" """HY OAS level against named absolute anchors (3.5 mild / 5.0 / 7.0).
v2 blended 70% of this with a 30% upper-tail percentile over the available
history. That leg was always a second, noisier estimate of what the anchors
already encode -- and once the usable window shrank to 3 uniformly tight
years it saturated far below any real stress level. Removed rather than
repaired: see ``HY_OAS_WINDOW_DAYS``.
"""
if not oas_values: if not oas_values:
return None return None
latest = oas_values[-1] return round(_oas_absolute_score(oas_values[-1]), 2)
absolute = _oas_absolute_score(latest)
if len(oas_values) < 30:
return round(absolute, 2) def w3_credit_impulse(
less = sum(1 for v in oas_values if v < latest) oas_values: list[float], lookback: int = W3_OAS_LOOKBACK
equal = sum(1 for v in oas_values if v == latest) ) -> float | None:
percentile = (less + 0.5 * equal) / len(oas_values) * 100.0 """HY OAS rate of change: widening only, relative so it works at any level.
relative = _clamp((percentile - 50.0) / 45.0 * 100.0)
return round(absolute * 0.7 + relative * 0.3, 2) The credit *level* (C1) sits at zero for as long as spreads stay under the
3.5 mild anchor -- 2.77 as of the v3 cutover -- so it contributes nothing to
State in a calm tape. The rate of change still does, and spread widening is
a classic lead, which is what Warning is for. Relative rather than absolute
because +0.5pp means something very different at 2.7 than at 8.0.
"""
if len(oas_values) < lookback + 1:
return None
past = oas_values[-lookback - 1]
if past <= 0:
return None
change_pct = (oas_values[-1] / past - 1.0) * 100.0
return _clamp(change_pct / W3_OAS_FULL_SCALE_PCT * 100.0)
def warning_sensor_scores(
divergence: float | None,
leader_closes: list[float],
market_closes: list[float],
oas_window: list[float],
) -> dict[str, float | None]:
"""The three Warning sensors, by pillar id.
Single definition so the live monitor and the event study cannot drift apart
-- in v2 the study re-derived the score from ``WARNING_WEIGHTS`` by hand and
would have silently kept measuring the old construct through this change.
"""
return {
"breadth_divergence": divergence,
"relative_strength": p4_relative_strength(leader_closes, market_closes),
"credit_impulse": w3_credit_impulse(oas_window),
}
def score_warning_sensors(sensors: dict[str, float | None]) -> float | None:
"""Weighted Warning score, renormalised over the sensors that are available."""
live = [
(float(score), float(WARNING_WEIGHTS[key]))
for key, score in sensors.items()
if score is not None and key in WARNING_WEIGHTS
]
if not live:
return None
return sum(s * w for s, w in live) / sum(w for _, w in live)
def _sensor(sensor_id: str, label: str, score: float | None, **details: object) -> dict: def _sensor(sensor_id: str, label: str, score: float | None, **details: object) -> dict:
@@ -244,7 +361,11 @@ def _sensor(sensor_id: str, label: str, score: float | None, **details: object)
} }
def _score_pillars(pillars: list[dict], weights: dict[str, float]) -> dict: def _score_pillars(
pillars: list[dict],
weights: dict[str, float],
bands: tuple[float, float, float] = STATE_BANDS,
) -> dict:
expected = sum(max(0.0, float(w)) for w in weights.values()) expected = sum(max(0.0, float(w)) for w in weights.values())
available_weight = sum( available_weight = sum(
max(0.0, float(weights.get(p["id"], 0.0))) max(0.0, float(weights.get(p["id"], 0.0)))
@@ -276,7 +397,12 @@ def _score_pillars(pillars: list[dict], weights: dict[str, float]) -> dict:
rounded = round(score, 1) if score is not None else None rounded = round(score, 1) if score is not None else None
return { return {
"score": rounded, "score": rounded,
"band": band_for(rounded) if rounded is not None and coverage >= MIN_COVERAGE else None, "band": (
band_for(rounded, bands)
if rounded is not None and coverage >= MIN_COVERAGE
else None
),
"bands": {"watch": bands[0], "elevated": bands[1], "breaking": bands[2]},
"coverage": round(coverage, 1), "coverage": round(coverage, 1),
"minimum_coverage": MIN_COVERAGE, "minimum_coverage": MIN_COVERAGE,
"available_pillars": [p["id"] for p in rows if p["available"]], "available_pillars": [p["id"] for p in rows if p["available"]],
@@ -309,13 +435,24 @@ def _value_asof(series: Series | None, as_of: date) -> float | None:
return item[1] if item else None return item[1] if item else None
def _window_asof(series: Series | None, as_of: date, years: float) -> list[float]: def _window_asof(series: Series | None, as_of: date, days: int) -> list[float]:
if not series: if not series:
return [] return []
start = as_of - timedelta(days=int(365.25 * years)) start = as_of - timedelta(days=days)
return [v for d, v in series if start <= d <= as_of] return [v for d, v in series if start <= d <= as_of]
def _coverage_days(series: Series | None, as_of: date) -> int | None:
"""Span of history actually available at ``as_of``.
Recorded in every snapshot because the v2 credit percentile degraded from a
10-year to a 3-year reference silently when the upstream licence changed --
nothing asserted the window it claimed, so nothing noticed for months.
"""
dates = [d for d, _ in series or [] if d <= as_of]
return (as_of - dates[0]).days if dates else None
def _next_weekday(d: date) -> date: def _next_weekday(d: date) -> date:
candidate = d + timedelta(days=1) candidate = d + timedelta(days=1)
while candidate.weekday() >= 5: while candidate.weekday() >= 5:
@@ -340,19 +477,31 @@ def _fundamental_effective_date(overrides: dict) -> date | None:
return _next_weekday(fetched) if fetched else None return _next_weekday(fetched) if fetched else None
def _fundamental_scores_asof(overrides: dict, config: dict, as_of: date) -> tuple[float | None, float | None, dict]: def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
"""Point-in-time qualitative overlay. Never feeds State or Warning in v3.
The effective-date gate stays even though nothing is scored from this: the
400-session rebuild replays historical dates, and stamping today's LLM read
onto 2024 snapshots would be plain lookahead in the stored record.
"""
effective = _fundamental_effective_date(overrides) effective = _fundamental_effective_date(overrides)
if effective is None or as_of < effective: pending = effective is None or as_of < effective
return None, None, {"effective_date": effective.isoformat() if effective else None, "age_days": None} age = None if pending else (as_of - effective).days
age = (as_of - effective).days stale = bool(age is not None and age > int(config.get("fundamental_staleness_days", 80)))
stale = age > int(config.get("fundamental_staleness_days", 80)) return {
f1 = overrides.get("f1_score") "available": not pending and not stale,
f3 = overrides.get("f3_score") "pending": pending,
return ( "stale": stale,
None if stale or f1 is None else _clamp(float(f1)), "effective_date": effective.isoformat() if effective else None,
None if stale or f3 is None else _clamp(float(f3)), "age_days": age,
{"effective_date": effective.isoformat(), "age_days": age, "stale": stale}, "capex": None if pending else overrides.get("capex"),
) "good_news_stock_down": None if pending else overrides.get("good_news_stock_down"),
"capex_stress": None if pending else overrides.get("f1_score"),
"earnings_stress": None if pending else overrides.get("f3_score"),
"reasoning": None if pending else overrides.get("reasoning"),
"source": overrides.get("source"),
"fetched_at": overrides.get("fetched_at"),
}
def _basket_hash(symbols: list[str]) -> str: def _basket_hash(symbols: list[str]) -> str:
@@ -393,12 +542,14 @@ def _compute_index(
vix_item = _item_asof(vix_series, as_of) vix_item = _item_asof(vix_series, as_of)
vix_score = p5_volatility(vix_item[1] if vix_item else None) vix_score = p5_volatility(vix_item[1] if vix_item else None)
oas_item = _item_asof(oas_series, as_of) oas_item = _item_asof(oas_series, as_of)
oas_window = _window_asof(oas_series, as_of, HY_OAS_REFERENCE_YEARS) oas_window = _window_asof(oas_series, as_of, HY_OAS_WINDOW_DAYS)
credit_score = f2_credit_spreads(oas_window) credit_score = f2_credit_spreads(oas_window)
divergence = _value_asof(divergence_series, as_of) divergence = _value_asof(divergence_series, as_of)
relative_strength = p4_relative_strength(smh, spy) sensors = warning_sensor_scores(divergence, smh, spy, oas_window)
f1, f3, fundamental_meta = _fundamental_scores_asof(overrides, config, as_of) relative_strength = sensors["relative_strength"]
credit_impulse = sensors["credit_impulse"]
overlay = fundamental_overlay(overrides, config, as_of)
state_pillars = [ state_pillars = [
{ {
@@ -444,21 +595,22 @@ def _compute_index(
"sensors": [_sensor("W2", "60-session relative-strength deterioration", relative_strength)], "sensors": [_sensor("W2", "60-session relative-strength deterioration", relative_strength)],
}, },
{ {
"id": "capex", "id": "credit_impulse",
"label": "Hyperscaler capex revisions", "label": "Credit impulse",
"score": round(f1, 1) if f1 is not None else None, "score": round(credit_impulse, 1) if credit_impulse is not None else None,
"sensors": [_sensor("F1", "Capex guidance cuts", f1)], "sensors": [
}, _sensor(
{ "W3",
"id": "earnings_reaction", f"HY OAS {W3_OAS_LOOKBACK}-session widening",
"label": "Good news, stock down", credit_impulse,
"score": round(f3, 1) if f3 is not None else None, oas=oas_item[1] if oas_item else None,
"sensors": [_sensor("F3", "Abnormal earnings reaction", f3)], )
],
}, },
] ]
state = _score_pillars(state_pillars, STATE_WEIGHTS) state = _score_pillars(state_pillars, STATE_WEIGHTS, STATE_BANDS)
warning = _score_pillars(warning_pillars, WARNING_WEIGHTS) warning = _score_pillars(warning_pillars, WARNING_WEIGHTS, WARNING_BANDS)
price_item = _item_asof(prices.get(tickers["leaders"][0]), as_of) price_item = _item_asof(prices.get(tickers["leaders"][0]), as_of)
dated_sources = { dated_sources = {
@@ -481,6 +633,7 @@ def _compute_index(
"date": as_of.isoformat(), "date": as_of.isoformat(),
"state": state, "state": state,
"warning": warning, "warning": warning,
"fundamental_overlay": overlay,
"quadrant_config": { "quadrant_config": {
"state_divider": QUADRANT_STATE_DIVIDER, "state_divider": QUADRANT_STATE_DIVIDER,
"warning_divider": QUADRANT_WARNING_DIVIDER, "warning_divider": QUADRANT_WARNING_DIVIDER,
@@ -502,14 +655,18 @@ def _compute_index(
"breadth_pct_above_200": round(breadth_pct, 1) if breadth_pct is not None else None, "breadth_pct_above_200": round(breadth_pct, 1) if breadth_pct is not None else None,
"breadth_date": breadth_item[0].isoformat() if breadth_item else None, "breadth_date": breadth_item[0].isoformat() if breadth_item else None,
"fundamentals_fetched_at": overrides.get("fetched_at"), "fundamentals_fetched_at": overrides.get("fetched_at"),
"fundamentals_effective_date": fundamental_meta.get("effective_date"), "fundamentals_effective_date": overlay.get("effective_date"),
"fundamentals_age_days": fundamental_meta.get("age_days"), "fundamentals_age_days": overlay.get("age_days"),
}, },
"data_quality": { "data_quality": {
"minimum_coverage": MIN_COVERAGE, "minimum_coverage": MIN_COVERAGE,
"oldest_market_input_age_days": max(source_ages.values()) if source_ages else None, "oldest_market_input_age_days": max(source_ages.values()) if source_ages else None,
"stale_inputs": stale_inputs, "stale_inputs": stale_inputs,
"inputs_fresh": not stale_inputs, "inputs_fresh": not stale_inputs,
# Upstream history spans, so a provider silently truncating a series
# shows up in the record instead of quietly reshaping a sensor.
"credit_history_days": _coverage_days(oas_series, as_of),
"vix_history_days": _coverage_days(vix_series, as_of),
}, },
} }
@@ -581,7 +738,11 @@ async def get_fundamental_overrides(db: AsyncSession) -> dict:
stored = json.loads(raw) stored = json.loads(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
return default return default
if stored.get("methodology") != METHODOLOGY: # The guard rejects pre-v2 blobs, where f1/f3 were arbitrary numbers with no
# categorical source. v2 and v3 share the categorical format and both derive
# f1/f3 from it below, so a methodology bump must not discard a live
# observation -- only the capex *scale* changed, and that is recomputed.
if stored.get("methodology") not in CATEGORICAL_FUNDAMENTAL_METHODOLOGIES:
return default return default
capex = _normalise_capex_states(stored.get("capex"), names) capex = _normalise_capex_states(stored.get("capex"), names)
reaction = str(stored.get("good_news_stock_down", "mixed")).strip().lower() reaction = str(stored.get("good_news_stock_down", "mixed")).strip().lower()
@@ -745,7 +906,7 @@ async def _upsert_snapshot(
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
)) ))
else: else:
existing_v2 = _parse_v2(row.breakdown_json) existing_v2 = _parse_snapshot(row.breakdown_json)
if existing_v2 is not None and not rewrite_existing_v2: if existing_v2 is not None and not rewrite_existing_v2:
return False, existing_v2 return False, existing_v2
row.total_score = float(state_score or 0.0) row.total_score = float(state_score or 0.0)
@@ -754,7 +915,7 @@ async def _upsert_snapshot(
return True, result return True, result
def _parse_v2(raw: str) -> dict | None: def _parse_snapshot(raw: str) -> dict | None:
try: try:
parsed = json.loads(raw) parsed = json.loads(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -762,12 +923,12 @@ def _parse_v2(raw: str) -> dict | None:
return parsed if parsed.get("methodology") == METHODOLOGY else None return parsed if parsed.get("methodology") == METHODOLOGY else None
async def _latest_v2_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict] | None: async def _latest_snapshot_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict] | None:
result = await db.execute( result = await db.execute(
select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1000) select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1000)
) )
for row in result.scalars().all(): for row in result.scalars().all():
parsed = _parse_v2(row.breakdown_json) parsed = _parse_snapshot(row.breakdown_json)
if parsed is not None: if parsed is not None:
return row, parsed return row, parsed
return None return None
@@ -791,8 +952,10 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
latest_date = leader_series[-1][0] latest_date = leader_series[-1][0]
vix_series = await _fetch_fred_series("VIXCLS", end - timedelta(days=1200), end) vix_series = await _fetch_fred_series("VIXCLS", end - timedelta(days=1200), end)
# Asking for 13 years was misleading once the licence capped the series at 3;
# the level needs the latest point and W3 needs its lookback, nothing more.
oas_series = await _fetch_fred_series( oas_series = await _fetch_fred_series(
"BAMLH0A0HYM2", end - timedelta(days=int(365.25 * 13)), end "BAMLH0A0HYM2", end - timedelta(days=HY_OAS_WINDOW_DAYS), end
) )
basket = config["breadth_basket"] basket = config["breadth_basket"]
@@ -805,7 +968,7 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
logger.warning("Regime monitor: fixed-basket breadth skipped: %s", exc) logger.warning("Regime monitor: fixed-basket breadth skipped: %s", exc)
breadth, breadth_counts, divergence = {}, {}, {} breadth, breadth_counts, divergence = {}, {}, {}
latest_v2 = await _latest_v2_row(db) latest_v2 = await _latest_snapshot_row(db)
rebuilding = latest_v2 is None and bool(leader_series) rebuilding = latest_v2 is None and bool(leader_series)
if rebuilding: if rebuilding:
dates = [d for d, _ in leader_series[-max(1, rebuild_sessions):]] dates = [d for d, _ in leader_series[-max(1, rebuild_sessions):]]
@@ -860,7 +1023,7 @@ async def _result_at_or_before(
.limit(1000) .limit(1000)
) )
for raw in result.scalars().all(): for raw in result.scalars().all():
parsed = _parse_v2(raw) parsed = _parse_snapshot(raw)
parsed_hash = ((parsed or {}).get("basket") or {}).get("hash") parsed_hash = ((parsed or {}).get("basket") or {}).get("hash")
if parsed is not None and (basket_hash is None or parsed_hash == basket_hash): if parsed is not None and (basket_hash is None or parsed_hash == basket_hash):
return parsed return parsed
@@ -877,7 +1040,7 @@ def _delta(current: dict, previous: dict | None) -> float | None:
async def get_regime_monitor(db: AsyncSession) -> dict: async def get_regime_monitor(db: AsyncSession) -> dict:
latest = await _latest_v2_row(db) latest = await _latest_snapshot_row(db)
if latest is None: if latest is None:
return {"available": False, "reason": "v2 not computed yet"} return {"available": False, "reason": "v2 not computed yet"}
row, result = latest row, result = latest
@@ -902,6 +1065,15 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
quality["snapshot_age_days"] = snapshot_age quality["snapshot_age_days"] = snapshot_age
quality["is_fresh"] = bool(quality.get("inputs_fresh")) and snapshot_age <= 4 quality["is_fresh"] = bool(quality.get("inputs_fresh")) and snapshot_age <= 4
result["data_quality"] = quality result["data_quality"] = quality
# The snapshot's overlay is the point-in-time record; the reader also wants
# the current observation even when it is not effective until the next
# session, because otherwise refreshing it looks like it did nothing.
config = await get_regime_config(db)
overrides = await get_fundamental_overrides(db)
live = fundamental_overlay(overrides, config, date.today())
live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available"))
result["fundamental_context"] = live
result["available"] = True result["available"] = True
return result return result
@@ -915,7 +1087,7 @@ async def get_regime_history(db: AsyncSession, days: int = 800) -> list[dict]:
) )
out: list[dict] = [] out: list[dict] = []
for row in result.scalars().all(): for row in result.scalars().all():
data = _parse_v2(row.breakdown_json) data = _parse_snapshot(row.breakdown_json)
if data is None: if data is None:
continue continue
state, warning = data.get("state") or {}, data.get("warning") or {} state, warning = data.get("state") or {}, data.get("warning") or {}
+63
View File
@@ -27,6 +27,7 @@ from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup from app.models.trade_setup import TradeSetup
from app.services.indicator_service import _extract_ohlcv, compute_atr from app.services.indicator_service import _extract_ohlcv, compute_atr
from app.services import fundamentals_quality_service, system_event_service
from app.services.price_service import query_ohlcv from app.services.price_service import query_ohlcv
from app.services.qualification import setup_qualifies from app.services.qualification import setup_qualifies
from app.services.sr_service import detect_gate_target_ladder from app.services.sr_service import detect_gate_target_ladder
@@ -526,6 +527,7 @@ async def scan_ticker(
primary_min_rr: float | None = None, primary_min_rr: float | None = None,
gate_levels_override: list[Any] | None = None, gate_levels_override: list[Any] | None = None,
scan_run_id: str | None = None, scan_run_id: str | None = None,
fundamentals_eligible: bool | None = None,
) -> list[TradeSetup]: ) -> list[TradeSetup]:
"""Scan a single ticker for trade setups meeting the R:R threshold. """Scan a single ticker for trade setups meeting the R:R threshold.
@@ -542,6 +544,17 @@ async def scan_ticker(
""" """
ticker = await _get_ticker(db, symbol) ticker = await _get_ticker(db, symbol)
if fundamentals_eligible is None:
fundamentals_eligible = await fundamentals_quality_service.ticker_is_eligible(
db, ticker.id
)
if not fundamentals_eligible:
logger.info(
"Skipping %s: unresolved or unavailable SEC fundamentals",
ticker.symbol,
)
return []
if primary_min_rr is None: if primary_min_rr is None:
primary_min_rr = PRIMARY_TARGET_MIN_RR primary_min_rr = PRIMARY_TARGET_MIN_RR
@@ -726,6 +739,29 @@ async def scan_all_tickers(
ticker_rows = [(int(ticker_id), symbol) for ticker_id, symbol in result.all()] ticker_rows = [(int(ticker_id), symbol) for ticker_id, symbol in result.all()]
total = len(ticker_rows) total = len(ticker_rows)
# Data-quality failures are not weak signals: they make a ticker ineligible.
# Resolve once for the universe scan and pass the decision into scan_ticker.
try:
fundamentals_blocked_ids = (
await fundamentals_quality_service.blocked_ticker_ids(db)
)
except Exception:
await db.rollback()
logger.exception(
"Could not resolve fundamentals quality; blocking this scan closed"
)
await system_event_service.log_event_standalone(
severity="error",
source="rr_scanner",
code="fundamentals_quality_unavailable",
message=(
"The fundamentals quality gate could not be evaluated; the "
"universe scan was blocked to avoid issuing unchecked setups."
),
dedup_key="rr_scanner:fundamentals_quality_unavailable",
)
fundamentals_blocked_ids = {ticker_id for ticker_id, _ in ticker_rows}
# Gate-reset observations must use the same runtime activation settings as # Gate-reset observations must use the same runtime activation settings as
# the live setup list. If the config cannot be loaded, scan normally but do # the live setup list. If the config cannot be loaded, scan normally but do
# not mutate reset state from an evaluation whose rules are unknown. # not mutate reset state from an evaluation whose rules are unknown.
@@ -765,6 +801,12 @@ async def scan_all_tickers(
for index, (ticker_id, symbol) in enumerate(ticker_rows): for index, (ticker_id, symbol) in enumerate(ticker_rows):
if progress_callback is not None: if progress_callback is not None:
progress_callback(index, total, symbol) progress_callback(index, total, symbol)
if ticker_id in fundamentals_blocked_ids:
logger.info(
"Skipping %s: unresolved or unavailable SEC fundamentals",
symbol,
)
continue
# Refresh Structural S/R once, then scores. get_sr_levels is read-only; # Refresh Structural S/R once, then scores. get_sr_levels is read-only;
# without this recalculate the score path would see yesterday's zones. # without this recalculate the score path would see yesterday's zones.
# A refresh failure still scans the ticker: qualification re-gates on # A refresh failure still scans the ticker: qualification re-gates on
@@ -795,6 +837,7 @@ async def scan_all_tickers(
volatility_percentile=(ranks.get(symbol) or {}).get("volatility_percentile"), volatility_percentile=(ranks.get(symbol) or {}).get("volatility_percentile"),
primary_min_rr=PRIMARY_TARGET_MIN_RR, primary_min_rr=PRIMARY_TARGET_MIN_RR,
scan_run_id=scan_run_id, scan_run_id=scan_run_id,
fundamentals_eligible=True,
) )
all_setups.extend(setups) all_setups.extend(setups)
if activation is not None: if activation is not None:
@@ -882,6 +925,26 @@ async def get_trade_setups(
stmt = stmt.where(TradeSetup.recommended_action == recommended_action) stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
excluded_ticker_ids: set[int] = set() excluded_ticker_ids: set[int] = set()
reentry_gate_locks: dict[int, datetime] = {} reentry_gate_locks: dict[int, datetime] = {}
try:
excluded_ticker_ids.update(
await fundamentals_quality_service.blocked_ticker_ids(db)
)
except Exception:
await db.rollback()
logger.exception(
"Could not resolve fundamentals quality; hiding actionable setups"
)
await system_event_service.log_event_standalone(
severity="error",
source="rr_scanner",
code="fundamentals_quality_unavailable",
message=(
"The fundamentals quality gate could not be evaluated; actionable "
"setups were hidden until the metadata check recovers."
),
dedup_key="rr_scanner:fundamentals_quality_unavailable",
)
return []
if exclude_open_trade_tickers: if exclude_open_trade_tickers:
# Manual book only. The shadow book holds the *top-ranked* names by # Manual book only. The shadow book holds the *top-ranked* names by
# construction, so letting its positions hide setups would leave the # construction, so letting its positions hide setups would leave the
+398
View File
@@ -0,0 +1,398 @@
"""Async SEC EDGAR client for the fundamentals importer (workstream A).
All access is batch (never at request time). This wraps the three SEC products
the A3 design uses `company_tickers.json`, `submissions/`, `companyfacts/`, and
the daily filing index behind one client that honors SEC's fair-access policy:
- an identifying ``User-Agent`` with a contact email on every request (config);
- request spacing well under the 10 req/s limit;
- exponential backoff + retry on 429;
- **403 alert and stop** (raise ``SecForbiddenError``), never a retry-loop a
403 means the UA or request pattern is wrong and retrying won't fix it. The one
exception is S3's ``AccessDenied`` on an ``/Archives/`` path, which is how the
bucket reports an absent file (``_is_absent_archive_key``).
Parsing lives here (index fixed-width, submissions pagination); DB writes and the
snapshot mapping live in the importer. No conditional GETs the companyfacts
endpoint exposes no ETag/Last-Modified (verified), which is why the importer is
daily-index driven rather than polling archives.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
from datetime import date, datetime
from pathlib import Path
from typing import Any
import httpx
from app.config import settings
from app.exceptions import ProviderError
from app.services.earnings_alignment import normalise_symbol
logger = logging.getLogger(__name__)
_WWW = "https://www.sec.gov"
_DATA = "https://data.sec.gov"
# Resolve CA bundle for explicit httpx verify (matches app/providers/fmp.py).
_CA = os.environ.get("SSL_CERT_FILE", "")
_CA_VERIFY: str | bool = _CA if _CA and Path(_CA).exists() else True
_FORMS_10 = frozenset({"10-K", "10-Q", "10-K/A", "10-Q/A"})
class SecError(ProviderError):
"""SEC request failed (403, exhausted 429/5xx, timeout, transport, parse)."""
class SecForbiddenError(SecError):
"""SEC returned 403 — User-Agent/pattern rejected. Alert and stop."""
class SecNotFoundError(SecError):
"""The resource does not exist (e.g. no daily index published for a day).
The *only* error a caller may treat as 'missing' every other SecError
(fair-access rejection, exhausted retries, 5xx, timeout) must propagate so a
fetch failure is never mistaken for an empty result.
Raised for a 404, and for the one 403 that also means "absent": see
``_is_absent_archive_key``."""
def _is_absent_archive_key(url: str, resp: httpx.Response) -> bool:
"""True when a 403 means "this file does not exist", not "you are blocked".
``www.sec.gov/Archives`` is served straight out of an S3 bucket that grants
no ``s3:ListBucket``, so a missing key cannot be answered with 404 S3
returns **403 with its ``AccessDenied`` XML** instead. SEC publishes a daily
index only for business days, so every weekend and market holiday inside an
incremental walk lands on exactly this response (verified 2026-07-30:
``form.20260725.idx``, a Saturday, 403s while the Friday and Monday files
return 200 on the same User-Agent).
A genuine fair-access rejection is distinguishable and must stay fatal: it is
SEC's WAF interstitial — ``text/html``, "Your Request Originates from an
Undeclared Automated Tool" — and it is returned for files that *do* exist,
on any path. Hence the narrow gate: the Archives prefix plus S3's own error
document. Nothing else may be downgraded to "missing"."""
try:
parsed = httpx.URL(url)
except (TypeError, ValueError): # pragma: no cover — url comes from us
return False
if parsed.host != "www.sec.gov" or not parsed.path.startswith("/Archives/"):
return False
if "xml" not in resp.headers.get("Content-Type", "").lower():
return False
try:
return "<Code>AccessDenied</Code>" in resp.text
except (UnicodeDecodeError, httpx.HTTPError): # pragma: no cover
return False
def _looks_like_contact_email(ua: str) -> bool:
if "example.com" in ua.lower() or "set-a-real-email" in ua.lower():
return False
return re.search(r"[^@\s]+@[^@\s]+\.[^@\s]+", ua) is not None
# SEC asks callers to stay well under 10 req/s; enforce a floor on real clients.
_MIN_PROD_SPACING = 0.11
def cik10(cik: int | str) -> str:
"""Zero-pad a CIK to the 10-digit form SEC URLs use (320193 -> 0000320193)."""
return str(int(cik)).zfill(10)
class SecClient:
"""Fair-access SEC HTTP client. Use as ``async with SecClient() as c:``."""
def __init__(
self,
*,
user_agent: str | None = None,
spacing_seconds: float | None = None,
max_retries: int | None = None,
timeout: float | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self._ua = user_agent or settings.sec_user_agent
self._spacing = (
spacing_seconds if spacing_seconds is not None else settings.sec_request_spacing_seconds
)
self._max_retries = (
max_retries if max_retries is not None else settings.sec_max_retries
)
self._timeout = timeout if timeout is not None else settings.sec_request_timeout_seconds
self._transport = transport # injectable for tests
self._client: httpx.AsyncClient | None = None
self._lock = asyncio.Lock()
self._last_request = 0.0
def _validate_fair_access(self) -> None:
"""On a real (non-mocked) client, enforce SEC fair-access preconditions
so we can't accidentally hammer SEC or get 403'd: a genuine contact-email
User-Agent and a spacing floor. Mock transports skip this (tests use 0)."""
if not _looks_like_contact_email(self._ua):
raise SecError(
"sec_user_agent must contain a real contact email (got "
f"{self._ua!r}) — SEC fair-access requires it"
)
if self._spacing < _MIN_PROD_SPACING:
raise SecError(
f"sec_request_spacing_seconds {self._spacing} is below the "
f"{_MIN_PROD_SPACING}s fair-access floor"
)
async def __aenter__(self) -> "SecClient":
if self._transport is None:
self._validate_fair_access()
self._client = httpx.AsyncClient(
headers={"User-Agent": self._ua, "Accept-Encoding": "gzip, deflate"},
timeout=self._timeout,
verify=_CA_VERIFY,
transport=self._transport,
)
return self
async def __aexit__(self, *exc) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
async def _throttle(self) -> None:
async with self._lock:
now = asyncio.get_event_loop().time()
wait = self._spacing - (now - self._last_request)
if wait > 0:
await asyncio.sleep(wait)
self._last_request = asyncio.get_event_loop().time()
async def _get(self, url: str) -> httpx.Response:
assert self._client is not None, "use `async with SecClient()`"
attempt = 0
while True:
await self._throttle()
try:
resp = await self._client.get(url)
except (httpx.TimeoutException, httpx.TransportError) as exc:
attempt += 1
if attempt > self._max_retries:
raise SecError(f"SEC network error for {url}: {exc}") from exc
await asyncio.sleep(min(2.0**attempt, 30.0))
continue
code = resp.status_code
if code == 403:
if _is_absent_archive_key(url, resp):
raise SecNotFoundError(f"SEC 403/AccessDenied (absent) for {url}")
raise SecForbiddenError(
f"SEC 403 for {url} — User-Agent/pattern rejected; set a real "
"sec_user_agent contact email"
)
if code == 404:
raise SecNotFoundError(f"SEC 404 for {url}")
# 429 and 5xx are transient — retry with backoff, honoring Retry-After.
if code == 429 or 500 <= code < 600:
attempt += 1
if attempt > self._max_retries:
raise SecError(f"SEC {code} after {self._max_retries} retries: {url}")
delay = _retry_after_seconds(resp) or min(2.0**attempt, 30.0)
logger.warning("SEC %d for %s — backoff %.1fs (attempt %d)", code, url, delay, attempt)
await asyncio.sleep(delay)
continue
if code >= 400:
raise SecError(f"SEC {code} for {url}")
return resp
async def get_json(self, url: str) -> Any:
return (await self._get(url)).json()
async def get_text(self, url: str) -> str:
return (await self._get(url)).text
# -- domain fetchers ---------------------------------------------------
async def company_tickers(self) -> dict[str, int]:
"""Map normalised ticker -> CIK (int). Multi-class tickers share a CIK."""
data = await self.get_json(f"{_WWW}/files/company_tickers.json")
out: dict[str, int] = {}
for row in data.values():
sym = normalise_symbol(row.get("ticker"))
if sym:
out[sym] = int(row["cik_str"])
return out
async def submissions(self, cik: int | str, *, include_history: bool = False) -> dict[str, Any]:
"""Issuer metadata + filing list.
``filings.recent`` caps at 1000; older accessions live in
``filings.files[]`` shards. Only ``include_history=True`` (the one-time
full backfill) fetches those shards SIC refresh and incremental runs
use the recent list alone and make no extra requests.
"""
base = await self.get_json(f"{_DATA}/submissions/CIK{cik10(cik)}.json")
filings = _rows_from_arrays(base["filings"]["recent"])
if include_history:
for shard in base["filings"].get("files") or []:
shard_data = await self.get_json(f"{_DATA}/submissions/{shard['name']}")
filings.extend(_rows_from_arrays(shard_data))
return {
"cik": int(base["cik"]),
"name": base.get("name"),
"sic": base.get("sic"),
"sic_description": base.get("sicDescription"),
"fiscal_year_end": base.get("fiscalYearEnd"),
"tickers": base.get("tickers") or [],
"filings": filings,
}
async def companyfacts(self, cik: int | str) -> dict[str, Any]:
"""Raw companyfacts JSON ({cik, entityName, facts})."""
return await self.get_json(f"{_DATA}/api/xbrl/companyfacts/CIK{cik10(cik)}.json")
async def latest_index_date(self, today: date | None = None) -> date | None:
"""The most recent published daily-index date (drives the revision). Checks
the current quarter, falling back to the previous one at a quarter boundary."""
today = today or date.today()
for year, qtr in _quarters_back(today, 2):
url = f"{_WWW}/Archives/edgar/daily-index/{year}/QTR{qtr}/index.json"
try:
idx = await self.get_json(url)
except SecNotFoundError:
continue # quarter dir absent — only 404 is "missing"
dates = [
d
for item in idx.get("directory", {}).get("item", [])
if (d := _index_file_date(item.get("name", ""))) is not None
and d <= today
]
if dates:
return max(dates)
return None
async def daily_index(self, day: date) -> list[dict[str, Any]]:
"""Parse the daily form index into 10-K/10-Q(/A) rows for all issuers.
Returns [{form, cik, accession, company}]. The caller filters to the
tracked universe. A missing index (weekend/holiday/not-yet-published)
returns [] rather than raising.
"""
qtr = (day.month - 1) // 3 + 1
url = f"{_WWW}/Archives/edgar/daily-index/{day.year}/QTR{qtr}/form.{day:%Y%m%d}.idx"
try:
text = await self.get_text(url)
except SecNotFoundError:
# Absent on a weekend is routine (SEC publishes business days only); on a
# weekday it is either a market holiday or something worth a look — a SEC
# hiccup, or a rejection page misread as absent, would otherwise let the
# importer advance past real filings silently. Log-level only, no alert:
# cheaper than carrying a holiday calendar just to stay quiet ~10 days/yr.
logger.log(
logging.INFO if day.weekday() >= 5 else logging.WARNING,
"no daily index published for %s (%s)",
day,
f"{day:%a}",
)
return [] # weekend/holiday/not-yet-published; other errors propagate
return _parse_form_index(text)
def _rows_from_arrays(arrays: dict[str, list]) -> list[dict[str, Any]]:
"""Turn SEC's parallel-array filing block into row dicts (keeping only 10-K/10-Q
family filings the ones that carry XBRL fundamentals)."""
forms = arrays.get("form", [])
out: list[dict[str, Any]] = []
for i, form in enumerate(forms):
if form not in _FORMS_10:
continue
out.append(
{
"accession": arrays["accessionNumber"][i],
"form": form,
"report_date": arrays["reportDate"][i] or None,
"filing_date": arrays["filingDate"][i] or None,
"acceptance_datetime": arrays["acceptanceDateTime"][i] or None,
"is_xbrl": bool(arrays.get("isXBRL", [0] * len(forms))[i]),
}
)
return out
def _parse_form_index(text: str) -> list[dict[str, Any]]:
"""Parse a daily ``form.YYYYMMDD.idx`` (fixed columns: Form / Company / CIK /
Date Filed / File Name-with-accession)."""
rows: list[dict[str, Any]] = []
started = False
for line in text.splitlines():
if not started:
if set(line.strip()) == {"-"}: # the dashed separator row
started = True
continue
parts = line.split()
if len(parts) < 5:
continue
form = parts[0]
if form not in _FORMS_10:
continue
path = parts[-1] # edgar/data/<cik>/<accession>.txt
cik = _cik_from_path(path)
accession = _accession_from_path(path)
if cik is None or accession is None:
continue
rows.append({"form": form, "cik": cik, "accession": accession, "path": path})
return rows
def _retry_after_seconds(resp: httpx.Response) -> float | None:
"""Parse a numeric-seconds Retry-After header (SEC uses seconds), capped."""
raw = resp.headers.get("Retry-After")
if not raw:
return None
try:
return min(float(raw), 60.0)
except (TypeError, ValueError):
return None
def _index_file_date(name: str) -> date | None:
if name.startswith("form.") and name.endswith(".idx"):
try:
return datetime.strptime(name[5:13], "%Y%m%d").date()
except ValueError:
return None
return None
def _quarters_back(today: date, n: int) -> list[tuple[int, int]]:
"""(year, quarter) for `today`'s quarter and the previous n-1, newest first."""
q = (today.month - 1) // 3 + 1
out = []
y = today.year
for _ in range(n):
out.append((y, q))
q -= 1
if q == 0:
q = 4
y -= 1
return out
def _cik_from_path(path: str) -> int | None:
segs = path.split("/")
if len(segs) >= 3 and segs[2].isdigit():
return int(segs[2])
return None
def _accession_from_path(path: str) -> str | None:
stem = path.rsplit("/", 1)[-1]
if stem.endswith(".txt"):
stem = stem[:-4]
return stem or None
+557
View File
@@ -0,0 +1,557 @@
"""Pure parser: SEC companyfacts -> fundamental_snapshots rows.
Turns one issuer's `companyfacts` JSON (+ its submissions filing metadata) into
per-accession snapshot rows for the filing's **primary period**, following the
A3 design (docs/dolt-sec-a3-design.md). No I/O, no DB unit-testable against a
fixture and verifiable against a real companyfacts pull.
The load-bearing rules (design Decision 2 + review):
- Period identity comes from `end == submissions.reportDate`, never `fy/fp`
(fy/fp is the *filing's* context; comparatives inside a filing repeat it).
This applies to the stored `fiscal_year`/`fiscal_period` too: they are derived
from `reportDate` against the issuer's `fiscalYearEnd` (see `_period_identity`),
because SEC's fy/fp collide and invert often enough to break the quarter chain.
- Duration facts are stored as **cumulative YTD**: pick the fact whose span
matches the fiscal-period-to-date length (Q13mo FY12mo) within tolerance.
If no YTD-length fact exists, store null never a discrete masquerading as YTD.
- Balance-sheet instants are taken at `end == reportDate`. `shares_outstanding`
is a single consolidated value: the cover-page `dei` fact (its own cover-date
`end` stored separately) if present, else `us-gaap:CommonStockSharesOutstanding`
at period end (e.g. Alphabet has no `dei` fact) never a class sum or the
weighted-average/diluted count. Multi-class issuers report it per class, which
is dimensional and therefore absent from companyfacts entirely, so
`weighted_avg_diluted_shares` is stored alongside as an explicit fallback for
market cap a separate column, never backfilled into `shares_outstanding`.
- Cash and debt composites are aggregate-first and mutually exclusive (each
source tag counted at most once).
`parse_snapshots` separates `skipped_filings` (no usable row produced) from
`field_issues` (a row was produced but a field is null/ambiguous) callers must
not treat field issues as missing coverage.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from typing import Any, NamedTuple
logger = logging.getLogger(__name__)
# Expected YTD span (days) per fiscal period; a duration fact must land within
# tolerance of this to count as the period's cumulative value.
_EXPECTED_YTD_DAYS = {"Q1": 91, "Q2": 182, "Q3": 273, "FY": 365}
# Period identity (see _period_identity): how far a quarter end sits before its
# fiscal-year end, and how far a fiscal-year end may drift from the nominal MMDD.
# The quarter bands are 91 days apart, so ±35 stays unambiguous even for a 4-4-5
# filer whose 16-week Q4 puts Q3 112 days out.
_QUARTER_DAYS_TO_FY_END = {"Q1": 273, "Q2": 182, "Q3": 91}
_QUARTER_TOLERANCE_DAYS = 35
_FYE_DRIFT_TOLERANCE_DAYS = 21
# Covers 52/53-week calendars *and* 4-4-5 retail ones (12/12/12/16 weeks), whose
# YTD-Q3 is 36 weeks = 251-252 days and missed a 20-day tolerance by ~2 -- so
# COST/PEP lost Q3 every year, breaking the quarter chain and nulling TTM + YoY.
# Q1 84d, Q2 168d and FY 364d were always inside. Adjacent periods stay
# unambiguous at 25 (66-116, 157-207, 248-298, 340-390).
_YTD_TOLERANCE_DAYS = 25
# us-gaap duration concepts (money), priority order; first present wins.
_DURATION_USD = {
# Order is load-bearing (first present wins) and the tail entries are
# deliberately *appended*: every issuer that already resolved keeps the same
# concept, and only issuers that resolved to nothing gain a value.
# - IncludingAssessedTax: REITs/consumer filers that tag only this variant
# (e.g. ARE, KHC) reported no revenue at all.
# - RevenuesNetOfInterestExpense: the banks' total-revenue tag. JPM/GS/WFC
# tag it in every 10-Q and `Revenues` only (if at all) in the 10-K.
"revenue": [
"RevenueFromContractWithCustomerExcludingAssessedTax",
"Revenues",
"SalesRevenueNet",
"RevenueFromContractWithCustomerIncludingAssessedTax",
"RevenuesNetOfInterestExpense",
],
"net_income": ["NetIncomeLoss"],
"operating_income": ["OperatingIncomeLoss"],
"cfo": [
"NetCashProvidedByUsedInOperatingActivities",
"NetCashProvidedByUsedInOperatingActivitiesContinuingOperations",
],
"capex": [
"PaymentsToAcquirePropertyPlantAndEquipment",
"PaymentsToAcquireProductiveAssets",
],
"depreciation_amortization": [
"DepreciationDepletionAndAmortization",
"DepreciationAmortizationAndAccretionNet",
"DepreciationAndAmortization",
],
}
# unit USD/shares. Appended (not reordered) so any issuer that already resolved
# keeps the same concept. REG tags only the continuing-operations variant on every
# filing; FCX switches by form type -- EarningsPerShareDiluted in its 10-Qs, the
# continuing-ops tag in its 10-K -- which nulled the FY row and killed Q4 + TTM.
# The basic variants are a last resort for a period that tags no diluted EPS at
# all (PPL's 2026 Q1). Basic ignores option/convert dilution so it slightly
# overstates EPS (~1.2% for PPL), but only fires when diluted is entirely absent,
# and high-dilution names always tag diluted -- so it never displaces a real one.
_EPS_CONCEPTS = [
"EarningsPerShareDiluted",
"IncomeLossFromContinuingOperationsPerDilutedShare",
"EarningsPerShareBasic",
"IncomeLossFromContinuingOperationsPerBasicShare",
]
# Weighted-average diluted share count (unit "shares"), the market-cap fallback
# for multi-class issuers whose cover-page count is dimensional and therefore
# absent from companyfacts. Always present, since EPS is computed from it.
_WEIGHTED_AVG_SHARE_CONCEPTS = [
"WeightedAverageNumberOfDilutedSharesOutstanding",
"WeightedAverageNumberOfSharesOutstandingBasicAndDiluted",
]
# us-gaap instant (balance-sheet) concepts, at end == reportDate.
_CASH = ["CashAndCashEquivalentsAtCarryingValue"]
_ST_INVESTMENTS = ["ShortTermInvestments", "MarketableSecuritiesCurrent"] # pick one
_LONG_TERM_DEBT_AGG = ["LongTermDebt"]
_LONG_TERM_DEBT_PARTS = ["LongTermDebtNoncurrent", "LongTermDebtCurrent"]
_SHORT_TERM_DEBT = ["ShortTermBorrowings", "CommercialPaper"] # pick one
class Fact(NamedTuple):
taxonomy: str
concept: str
unit: str
start: date | None # None => instant
end: date
val: float
fy: int | None
fp: str | None
@dataclass
class SnapshotRow:
cik: str
accession: str
form: str
filed_date: date
accepted_at: datetime
period_end: date
fiscal_year: int
fiscal_period: str
period_start: date | None = None
revenue: float | None = None
net_income: float | None = None
operating_income: float | None = None
diluted_eps: float | None = None
cfo: float | None = None
capex: float | None = None
depreciation_amortization: float | None = None
cash_and_st_investments: float | None = None
total_debt: float | None = None
shares_outstanding: float | None = None
shares_outstanding_date: date | None = None
weighted_avg_diluted_shares: float | None = None
@dataclass
class FilingMeta:
report_date: date
filing_date: date
accepted_at: datetime
form: str
@dataclass
class ParseResult:
rows: list[SnapshotRow] = field(default_factory=list)
# accessions for which NO row was produced (no facts / no usable period).
skipped_filings: list[dict[str, str]] = field(default_factory=list)
# accessions with a row but a field-level warning (e.g. ambiguous shares).
field_issues: list[dict[str, str]] = field(default_factory=list)
def parse_snapshots(
companyfacts: dict[str, Any],
filings: dict[str, FilingMeta],
accessions: set[str],
fiscal_year_end: str | None = None,
) -> ParseResult:
"""Build snapshot rows for ``accessions`` (those with facts + filing meta).
``fiscal_year_end`` is the issuer's declared ``submissions.fiscalYearEnd``
(MMDD) and seeds period identity (see ``_period_identity``), making it
independent of SEC's unreliable fy/fp fields. It is only a hint: the issuer's
own 10-K period ends override it (see ``resolve_fiscal_year_end``). With
neither available, the old fy/fp behaviour is used.
``skipped_filings`` = no row produced (missing facts/meta or no usable period
identity); ``field_issues`` = a row was produced but a field is null/ambiguous.
Callers must not use field issues as failed-row coverage.
"""
cik = f"{int(companyfacts['cik']):010d}"
# The declared value is only a hint; the issuer's own 10-Ks are authoritative.
fiscal_year_end = resolve_fiscal_year_end(filings, fiscal_year_end)
by_accn = _index_by_accession(companyfacts)
result = ParseResult()
for accn in accessions:
meta = filings.get(accn)
facts = by_accn.get(accn)
if meta is None or not facts:
result.skipped_filings.append({"accession": accn, "reason": "no facts or filing metadata"})
continue
row, note = _parse_one(cik, accn, facts, meta, fiscal_year_end)
if row is None:
result.skipped_filings.append({"accession": accn, "reason": note or "unparseable"})
continue
result.rows.append(row)
if note:
result.field_issues.append({"accession": accn, "reason": note})
return result
def companyfacts_accessions(companyfacts: dict[str, Any]) -> set[str]:
"""Every accession that appears anywhere in a companyfacts payload — used by
the importer's index↔Company-Facts consistency gate."""
return set(_index_by_accession(companyfacts).keys())
def _index_by_accession(companyfacts: dict[str, Any]) -> dict[str, list[Fact]]:
"""One pass over companyfacts -> {accession: [Fact, ...]}."""
out: dict[str, list[Fact]] = {}
for taxonomy, concepts in companyfacts.get("facts", {}).items():
for concept, body in concepts.items():
for unit, facts in body.get("units", {}).items():
for f in facts:
accn = f.get("accn")
end = _d(f.get("end"))
val = f.get("val")
# Skip malformed facts so they can't be selected accidentally:
# every usable fact needs an accession, an end date, and a
# finite numeric value.
if not accn or end is None or not _finite(val):
continue
out.setdefault(accn, []).append(
Fact(
taxonomy=taxonomy,
concept=concept,
unit=unit,
start=_d(f.get("start")),
end=end,
val=val,
fy=f.get("fy"),
fp=f.get("fp"),
)
)
return out
def _parse_one(
cik: str, accn: str, facts: list[Fact], meta: FilingMeta,
fiscal_year_end: str | None = None,
) -> tuple[SnapshotRow | None, str | None]:
"""Returns (row, note). row is None when there's no usable period identity;
note is a validation reason (row-skip reason when row is None, else a
field-level issue such as ambiguous shares)."""
fy, fp = _period_identity(meta, fiscal_year_end)
if fy is None or fp is None:
# No fiscal calendar, or a period the calendar cannot place (a transition
# period). Fall back to the filing's own context: an imperfect label still
# beats dropping the filing entirely.
fy, fp = _fiscal_context(facts, meta.report_date)
if fy is None or fp not in _EXPECTED_YTD_DAYS:
return None, "no usable period identity"
row = SnapshotRow(
cik=cik,
accession=accn,
form=meta.form,
filed_date=meta.filing_date,
accepted_at=meta.accepted_at,
period_end=meta.report_date,
fiscal_year=fy,
fiscal_period=fp,
)
# duration YTD facts (money) + EPS
for field_name, concepts in _DURATION_USD.items():
val, start = _select_ytd(facts, concepts, meta.report_date, fp, "USD")
setattr(row, field_name, val)
if field_name == "revenue" and start is not None:
row.period_start = start
eps, eps_start = _select_ytd(facts, _EPS_CONCEPTS, meta.report_date, fp, "USD/shares")
row.diluted_eps = eps
if row.period_start is None and eps_start is not None:
row.period_start = eps_start
# balance-sheet instants at reportDate
row.cash_and_st_investments = _compose_cash(facts, meta.report_date)
row.total_debt = _compose_debt(facts, meta.report_date)
shares, shares_date, ambiguous = _select_shares(facts, meta.report_date)
row.shares_outstanding = shares
row.shares_outstanding_date = shares_date
row.weighted_avg_diluted_shares = _select_weighted_avg_shares(facts, meta.report_date)
return row, ("ambiguous shares outstanding" if ambiguous else None)
def resolve_fiscal_year_end(
filings: dict[str, FilingMeta], declared: str | None
) -> str | None:
"""The issuer's fiscal-year-end MMDD, preferring its own 10-K period ends.
``submissions.fiscalYearEnd`` is *not* reliable: Franklin Resources (BEN)
declares 1231 while every one of its 10-Ks ends 09-30. Trusting it put BEN's
fiscal Q1 (Dec) 0 days from the claimed year end matching no quarter band
and labelled its fiscal Q2 (Mar) as Q1, colliding two periods on one key and
destroying the quarter chain.
A 10-K's reportDate **is** the fiscal year end by definition, so it wins
whenever one is available; the declared value is only a fallback for an issuer
with no annual filing in the set. The most recent 10-K is used, so an issuer
that changed its year end is measured against its current calendar.
"""
annual = [m.report_date for m in filings.values() if m.form.startswith("10-K")]
if annual:
latest = max(annual)
return f"{latest.month:02d}{latest.day:02d}"
return declared
def _period_identity(
meta: FilingMeta, fiscal_year_end: str | None
) -> tuple[int | None, str | None]:
"""(fiscal_year, fiscal_period) from the period end and the issuer's fiscal
calendar never from the fy/fp fields.
SEC's fy/fp describe the *filing*, and they are unreliable as period identity:
observed in production, a 10-Q labelled ``FY`` (BXP), a year ending 2025-12-31
labelled 2024 (FRT, a December filer), a year ending 2025-06-27 labelled 2027
(STX), and four different period ends all labelled 2022 Q3 (PPL). Because
readers key on (fiscal_year, fiscal_period), colliding labels silently discard
a period and inverted ones scramble the quarter chain nulling TTM and YoY.
``period_end`` is authoritative, so identity is derived from it: the form
decides FY vs quarter, and distance to the fiscal-year end decides which
quarter. Labels need not match the issuer's own naming — a filer whose year
ends in early January (DPZ) shifts by one they need to be unique, monotonic
and YoY-aligned, which is all the derivation asks of them. Nothing outside the
derivation reads these columns.
Known limitation: ``fiscalYearEnd`` is the issuer's *current* calendar, so a
company that has changed its fiscal year end gets its historical periods
measured against the new one. The quarter tolerance shunts most of those to
the fy/fp fallback, and a same-key collision resolves newest-wins, so the
failure mode is a degraded old year rather than a scrambled current one.
"""
fy = _fiscal_year_of(meta.report_date, fiscal_year_end)
if fy is None:
return None, None
if meta.form.startswith("10-K"):
return fy, "FY"
nominal_end = _nominal_fy_end(fy, fiscal_year_end)
if nominal_end is None:
return None, None
remaining = (nominal_end - meta.report_date).days
best = min(
_QUARTER_DAYS_TO_FY_END,
key=lambda k: abs(_QUARTER_DAYS_TO_FY_END[k] - remaining),
)
if abs(_QUARTER_DAYS_TO_FY_END[best] - remaining) > _QUARTER_TOLERANCE_DAYS:
return None, None # transition period or odd filing — let the caller fall back
return fy, best
def _nominal_fy_end(year: int, fiscal_year_end: str | None) -> date | None:
"""The issuer's nominal fiscal-year end in ``year`` from a MMDD string."""
if not fiscal_year_end or len(fiscal_year_end) != 4 or not fiscal_year_end.isdigit():
return None
month, day = int(fiscal_year_end[:2]), int(fiscal_year_end[2:])
if not 1 <= month <= 12 or not 1 <= day <= 31:
return None
while day > 28: # 52/53-week ends land on 0229/0230/0231 in some filings
try:
return date(year, month, day)
except ValueError:
day -= 1
return date(year, month, day)
def _fiscal_year_of(period_end: date, fiscal_year_end: str | None) -> int | None:
"""Which fiscal year ``period_end`` belongs to.
A 52/53-week calendar's real year end drifts around the nominal MMDD (and can
cross the calendar year), so allow drift before rolling into the next year.
"""
nominal = _nominal_fy_end(period_end.year, fiscal_year_end)
if nominal is None:
return None
return (
period_end.year
if period_end <= nominal + timedelta(days=_FYE_DRIFT_TOLERANCE_DAYS)
else period_end.year + 1
)
def _fiscal_context(facts: list[Fact], report_date: date) -> tuple[int | None, str | None]:
"""The filing's (fy, fp) taken as the majority context among the facts that
end at reportDate (the current-period facts, which share the filing's
context). Reject a tie so a conflicting context is never chosen arbitrarily."""
counts: dict[tuple[int, str], int] = {}
for f in facts:
if f.end == report_date and f.fy is not None and f.fp:
counts[(f.fy, f.fp)] = counts.get((f.fy, f.fp), 0) + 1
if not counts:
return None, None
ranked = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
if len(ranked) > 1 and ranked[0][1] == ranked[1][1]:
return None, None # tie → conflicting contexts, reject
return ranked[0][0]
def _select_ytd(
facts: list[Fact], concepts: list[str], report_date: date, fp: str, unit: str
) -> tuple[float | None, date | None]:
"""First present concept whose duration fact ends at reportDate and whose span
matches the fiscal-period-to-date length. Returns (val, period_start)."""
expected = _EXPECTED_YTD_DAYS[fp]
for concept in concepts:
best: Fact | None = None
best_diff: int | None = None
for f in facts:
if (
f.taxonomy != "us-gaap"
or f.concept != concept
or f.unit != unit
or f.start is None
or f.end != report_date
):
continue
diff = abs((f.end - f.start).days - expected)
if diff <= _YTD_TOLERANCE_DAYS and (best_diff is None or diff < best_diff):
best, best_diff = f, diff
if best is not None:
return float(best.val), best.start
return None, None
def _select_instant(facts: list[Fact], concepts: list[str], report_date: date) -> float | None:
"""First present instant (balance-sheet) fact at end == reportDate, unit USD."""
for concept in concepts:
for f in facts:
if (
f.taxonomy == "us-gaap"
and f.concept == concept
and f.unit == "USD"
and f.start is None
and f.end == report_date
):
return float(f.val)
return None
def _compose_cash(facts: list[Fact], report_date: date) -> float | None:
cash = _select_instant(facts, _CASH, report_date)
st = _select_instant(facts, _ST_INVESTMENTS, report_date) # first present of the two
if cash is None and st is None:
return None
return (cash or 0.0) + (st or 0.0)
def _compose_debt(facts: list[Fact], report_date: date) -> float | None:
long_term = _select_instant(facts, _LONG_TERM_DEBT_AGG, report_date)
if long_term is None:
nc = _select_instant(facts, ["LongTermDebtNoncurrent"], report_date)
cur = _select_instant(facts, ["LongTermDebtCurrent"], report_date)
long_term = None if nc is None and cur is None else (nc or 0.0) + (cur or 0.0)
short_term = _select_instant(facts, _SHORT_TERM_DEBT, report_date)
if long_term is None and short_term is None:
return None
return (long_term or 0.0) + (short_term or 0.0)
def _select_shares(
facts: list[Fact], report_date: date
) -> tuple[float | None, date | None, bool]:
"""Issuer-wide shares outstanding as a single consolidated value (never a
class sum companyfacts is non-dimensional and never weighted-average/
diluted). Returns (value, shares_date, ambiguous).
1. Prefer the `dei:EntityCommonStockSharesOutstanding` cover-page instant;
its own end is the shares date (cover date != period_end).
2. Else fall back to `us-gaap:CommonStockSharesOutstanding` at period end
(e.g. Alphabet has no dei fact); shares date = reportDate.
Conflicting values within the chosen source (None, None, True) to be
counted in validation.
"""
dei = [
f
for f in facts
if f.taxonomy == "dei"
and f.concept == "EntityCommonStockSharesOutstanding"
and f.unit == "shares"
and f.start is None
]
if dei:
if len({f.val for f in dei}) > 1:
return None, None, True
best = max(dei, key=lambda f: f.end)
return float(best.val), best.end, False
gaap = [
f
for f in facts
if f.taxonomy == "us-gaap"
and f.concept == "CommonStockSharesOutstanding"
and f.unit == "shares"
and f.start is None
and f.end == report_date
]
if gaap:
if len({f.val for f in gaap}) > 1:
return None, None, True
return float(gaap[0].val), report_date, False
return None, None, False # simply absent — not a conflict
def _select_weighted_avg_shares(facts: list[Fact], report_date: date) -> float | None:
"""The most recent quarter's weighted-average diluted share count.
Deliberately the **shortest** duration ending at reportDate, not the YTD one:
the shorter the window the closer the average sits to the current count, which
is what a market cap wants. Measured against issuers where the true
point-in-time count is available, the quarter average is within ~0.6%.
"""
best: tuple[int, float] | None = None
for concept in _WEIGHTED_AVG_SHARE_CONCEPTS:
for f in facts:
if (
f.taxonomy != "us-gaap"
or f.concept != concept
or f.unit != "shares"
or f.start is None
or f.end != report_date
or f.val <= 0
):
continue
span = (f.end - f.start).days
if best is None or span < best[0]:
best = (span, float(f.val))
if best is not None:
return best[1] # first present concept wins, as elsewhere
return None
def _d(value: Any) -> date | None:
if not value:
return None
try:
return date.fromisoformat(str(value)[:10])
except ValueError:
return None
def _finite(value: Any) -> bool:
"""True for a finite numeric value (rejects None, bool, strings, NaN/inf)."""
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
+991
View File
@@ -0,0 +1,991 @@
"""SEC fundamentals importer (workstream A, phase A3).
A ``SourceImporter`` (see ``app/services/data_import.py``) that populates the
immutable ``fundamental_snapshots`` from SEC Company Facts and back-fills
``tickers.cik/sic/sic_description``. EDGAR-daily-index driven: it fetches
companyfacts only for tracked issuers that filed since the last run (full-history
backfill on the first run / for newly-added issuers). Shadow only nothing reads
snapshots until A4.
Guardrails (design + reviews):
- ``detect_revision`` caches the resolved universe + the exact tracked index rows
and composes the revision from them; ``stage`` consumes those same cached inputs
(it does not refetch the index/universe) so promoted data always matches the
computed revision.
- Resolution is read-only in ``stage`` (proposals only); ticker writes happen in
``promote`` via ``apply_ticker_updates``.
- ``validate`` runs the **indexCompany-Facts consistency gate** before any write:
a tracked XBRL index accession missing from Company Facts fails the run (the two
are separate SEC products that can lag) so we retry rather than record a
null/partial snapshot. Non-XBRL amendments are skipped with a recorded reason.
A failure here blocks every later run (``source_max_date`` only advances on a
promoted run), so it names the offending filings in the alert and separates the
causes ``not_in_companyfacts`` (facts lag) vs ``not_in_submissions`` (the
index row is absent from the issuer's own filing list, which no retry fixes).
- **Co-registrant recovery**, because "missing from Company Facts" is often not
missing at all: SEC files some combined parent/subsidiary filings' XBRL under
the co-registrant's CIK, so the ticker-carrying parent's own file never gets
that accession. The daily index lists every co-registrant of an accession, so
the facts are found there and re-stamped to the real filer guarded by a
share-count continuity check so a subsidiary's standalone numbers can never be
stored as the parent's. Confirmed 2026-07-27 (NEE via FPL, DOW via Dow Chemical)
and it is not transient: an NEE filing misattributed in 2014 is still misfiled.
- **Bounded blocking.** Anything still unresolvable after ``MISSING_XBRL_RETRY_DAYS``
stops failing the whole import and enters a durable retry queue. The scheduled
importer retries queued accessions automatically, while the affected issuer is
excluded from actionable setups until its filing is recovered.
- ``promote`` inserts snapshots ``ON CONFLICT (accession) DO NOTHING`` (immutable),
reports differing existing accessions, and applies ticker updates in the same
transaction.
- ``reparse=True`` is the one exception to immutability, and it is deliberate:
it restages every accession with the current parser and **rewrites** the rows
that now reconstruct differently. Immutability protects SEC's record (one row
per accession, amendments retained) but the stored row is *our* reconstruction,
so after a parser fix, keeping it is preserving a stale cache, not history.
Manually invoked through ``scripts/reparse_fundamentals.py``; never scheduled.
"""
from __future__ import annotations
import json
import logging
from collections import Counter, defaultdict
from dataclasses import dataclass, field, replace
from datetime import date, datetime, timedelta, timezone
from typing import Any, Callable
from sqlalchemy import delete, select, update
from app.database import insert_for_session
from app.models.data_import_run import DataImportRun
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.sec_filing_gap import SecFilingGap
from app.models.system_event import SystemEvent
from app.services import fundamentals_quality_service
from app.services import sec_facts_parser as parser
from app.services import sec_universe
from app.services.data_import import STATUS_PROMOTED, ValidationResult
from app.services.sec_client import SecClient, SecError, cik10
from app.services.sec_facts_parser import FilingMeta, SnapshotRow
from app.services.sec_universe import ResolvedUniverse
logger = logging.getLogger(__name__)
SOURCE = "sec_facts"
_XBRL_FORMS = {"10-K", "10-Q", "10-K/A", "10-Q/A"}
# On the one-time backfill, require this fraction of tracked issuers to yield at
# least one snapshot (guards a broken fetch/parse from promoting a hollow table).
MIN_BACKFILL_COVERAGE = 0.5
# How long an index accession may stay unresolvable before the run stops failing
# on it. Genuine index↔facts lag clears within a day (a weekend stretches it to
# three); past that it is misfiled, not late, and blocking forever costs more
# than the missing filing does — see the unresolved-filing guardrail below.
MISSING_XBRL_RETRY_DAYS = 3
FILING_GAP_ESCALATE_DAYS = 14
# Share-count band a co-registrant-recovered row must land in, relative to the
# issuer's own last snapshot. Wide enough for buybacks/issuance, nowhere near
# wide enough to let a subsidiary shell's token float through (see _shares_continuous).
RECOVERY_SHARES_MIN = 0.5
RECOVERY_SHARES_MAX = 2.0
_SNAPSHOT_COLS = (
"cik", "accession", "form", "filed_date", "accepted_at", "period_start",
"period_end", "fiscal_year", "fiscal_period", "revenue", "net_income",
"operating_income", "diluted_eps", "cfo", "capex", "depreciation_amortization",
"cash_and_st_investments", "total_debt", "shares_outstanding",
"shares_outstanding_date", "weighted_avg_diluted_shares",
)
# Compare ALL source fields (every column except the accession key) to flag a
# differing existing accession — immutable, so we report, never mutate.
_COMPARE_COLS = tuple(c for c in _SNAPSHOT_COLS if c != "accession")
@dataclass
class StagedFundamentals:
resolved: ResolvedUniverse
sic_updates: list[tuple[int, str | None, str | None]] = field(default_factory=list)
rows: list[SnapshotRow] = field(default_factory=list)
skipped_filings: list[dict[str, str]] = field(default_factory=list)
field_issues: list[dict[str, str]] = field(default_factory=list)
skipped_non_xbrl: list[dict[str, str]] = field(default_factory=list)
# Index rows we could not resolve to Company Facts, with a per-row reason
# (not_in_companyfacts | not_in_submissions | ...) — see _missing().
missing_xbrl: list[dict[str, Any]] = field(default_factory=list)
# Accessions parsed out of a co-registrant's Company Facts file.
recovered: list[dict[str, Any]] = field(default_factory=list)
invalid_payloads: list[dict[str, str]] = field(default_factory=list)
existing_accessions: set[str] = field(default_factory=set)
# Tracked issuers whose registrant has NO XBRL 10-K/10-Q at all: they can
# never yield a snapshot, so this is a resolution problem (a ticker pointed
# at a successor shell), not missing data. See sec_universe.CIK_OVERRIDES_KEY.
no_xbrl_filings: list[dict[str, Any]] = field(default_factory=list)
discrepancies: list[dict[str, Any]] = field(default_factory=list)
backfill: bool = False
issuers_fetched: int = 0
issuers_with_rows: int = 0
def _now() -> datetime:
return datetime.now(timezone.utc)
class SecFundamentalsImporter:
source = SOURCE
def __init__(
self,
*,
client_factory: Callable[[], SecClient] | None = None,
today: date | None = None,
reparse: bool = False,
) -> None:
self._client_factory = client_factory or (lambda: SecClient())
self.today = today or _now().date()
# Reparse: re-derive every stored accession with the CURRENT parser and
# rewrite the ones that now reconstruct differently. Snapshots are
# immutable with respect to SEC (one row per accession, amendments kept),
# but the stored row is *our reconstruction* — when a parser bug is fixed,
# leaving it stale is not immutability, it is a stale cache. Manually
# invoked via scripts/reparse_fundamentals.py; never scheduled.
self.reparse = reparse
# cached by detect_revision, consumed by stage:
self._resolved: ResolvedUniverse | None = None
self._index_rows: list[dict[str, Any]] = []
# accession -> the OTHER CIKs the daily index lists it under (co-registrants
# of a combined filing). Only populated for accessions a tracked issuer filed.
self._coregistrants: dict[str, list[int]] = {}
self._retry_rows: list[dict[str, Any]] = []
self._latest_index_date: date | None = None
self._backfill = False
# -- SourceImporter protocol -------------------------------------------
async def detect_revision(self, db) -> str | None:
async with self._client_factory() as client:
self._resolved = await sec_universe.resolve_ciks(db, client)
last_processed = await self._last_processed_index_date(db)
self._latest_index_date = await client.latest_index_date(self.today)
if self._latest_index_date is None:
raise SecError("no EDGAR daily index available")
# Reparse needs every accession restaged, not just those filed since
# the last run — the facts a fixed parser now accepts were never
# stored, so a reparse cannot be served from the database.
self._coregistrants = {}
if last_processed is None or self.reparse:
self._backfill = True
self._index_rows = []
else:
self._backfill = False
self._index_rows = await self._collect_index_rows(
client, last_processed, self._latest_index_date
)
content = sec_universe.index_content_hash(self._index_rows)
revision = sec_universe.compose_revision(
self._latest_index_date, content, self._resolved.symbol_to_cik
)
self._retry_rows = []
if not self._backfill:
self._retry_rows = await self._retry_backlog(
db,
set(self._resolved.cik_to_ticker_ids),
)
# Company Facts can change while the daily index revision stays fixed.
# Returning None deliberately bypasses the framework's no-op gate so a
# scheduled run retries every active gap.
return None if self._retry_rows else revision
async def stage(self, db) -> StagedFundamentals:
assert self._resolved is not None, "detect_revision must run first"
resolved = self._resolved
staged = StagedFundamentals(resolved=resolved, backfill=self._backfill)
cik_to_tids = resolved.cik_to_ticker_ids
# Whole index rows (not bare accessions): form + index date are what make
# an unresolvable filing diagnosable without re-walking the index by hand.
filed_by_cik: dict[int, list[dict[str, Any]]] = defaultdict(list)
for r in self._index_rows:
if r["cik"] in cik_to_tids:
filed_by_cik[r["cik"]].append(r)
# Promoted-around filings live in a small durable retry queue, including
# the one-time migration backfill. Merge them into normal incremental
# work so the scheduled importer heals them without operator action.
if not self._backfill:
seen = {
(int(cik), row["accession"])
for cik, rows in filed_by_cik.items()
for row in rows
}
for row in self._retry_rows:
cik = int(row["cik"])
key = (cik, row["accession"])
if key in seen:
continue
filed_by_cik[cik].append(row)
seen.add(key)
coregistrants = [int(value) for value in row.get("coregistrants") or []]
if coregistrants:
self._coregistrants[row["accession"]] = coregistrants
existing = await self._ciks_with_snapshots(db, set(cik_to_tids))
if self._backfill:
backfill_ciks = set(cik_to_tids)
else:
# Newly added issuers (resolved but no snapshots yet) get a full-history
# backfill; issuers that already have history are handled incrementally.
backfill_ciks = {c for c in cik_to_tids if c not in existing}
incremental_ciks = set(filed_by_cik) - backfill_ciks
# Continuity reference for co-registrant recovery, read once up front.
last_shares = await self._last_shares_outstanding(db, set(cik_to_tids))
async with self._client_factory() as client:
for cik in sorted(backfill_ciks | incremental_ciks):
is_backfill = cik in backfill_ciks
await self._stage_issuer(
client, cik, is_backfill, filed_by_cik, staged, last_shares
)
# Read-only discrepancy detection: an accession we reconstructed that is
# already stored, differing in ANY source field (immutable → report in
# validation, event on promote, never mutate). Also gives promote the
# existing set so its insert count is dialect-independent.
if staged.rows:
existing = await self._existing_by_accession(db, [r.accession for r in staged.rows])
staged.existing_accessions = set(existing)
for row in staged.rows:
old = existing.get(row.accession)
if old is not None:
fields = _diff_fields(row, old)
if fields:
staged.discrepancies.append({"accession": row.accession, "fields": fields})
return staged
async def _stage_issuer(
self, client, cik, is_backfill, filed_by_cik, staged, last_shares
) -> None:
cf = await client.companyfacts(cik)
bad = _companyfacts_structure_error(cf)
if bad is not None:
# Malformed payload (missing facts/units structure) — record separately
# and fail validation, rather than letting it degrade to skipped rows.
staged.invalid_payloads.append({"cik": cik10(cik), "reason": bad})
staged.issuers_fetched += 1
return
sub = await client.submissions(cik, include_history=is_backfill)
xbrl_meta, nonxbrl = _filing_meta(sub)
if not xbrl_meta:
staged.no_xbrl_filings.append(
{"cik": cik10(cik), "name": sub.get("name"), "tickers": sub.get("tickers")}
)
fiscal_year_end = sub.get("fiscal_year_end")
recovered_rows: list[SnapshotRow] = []
index_rows = {
row["accession"]: row for row in filed_by_cik.get(cik, [])
}
if is_backfill:
accns = set(xbrl_meta)
else:
present = parser.companyfacts_accessions(cf)
accns = set()
for index_row in filed_by_cik.get(cik, []):
accn = index_row["accession"]
if accn in nonxbrl:
staged.skipped_non_xbrl.append({"cik": cik10(cik), "accession": accn})
elif accn not in xbrl_meta:
# The daily index lists it but the issuer's own filing list does
# not (submissions lagging the index, no usable period metadata,
# or a co-registrant filing). NOT a Company-Facts lag — separate
# cause, separate fix, so it gets its own reason.
staged.missing_xbrl.append(
_missing(
cik,
index_row,
"not_in_submissions",
self.today,
self._coregistrants.get(accn),
)
)
elif accn in present:
accns.add(accn)
else:
# Filed, XBRL, but absent from this issuer's Company Facts. Try
# the co-registrant file before treating it as missing data.
row, source_cik = await self._recover_from_coregistrant(
client, cik, accn, xbrl_meta, fiscal_year_end,
last_shares.get(cik10(cik)), staged,
)
if row is not None:
recovered_rows.append(row)
staged.recovered.append({
"cik": cik10(cik),
"accession": accn,
"source_cik": source_cik,
"form": index_row.get("form"),
})
else:
staged.missing_xbrl.append(_missing(
cik, index_row,
# Found, but it did not look like this issuer's own
# numbers — say so; it is not the same as absent.
"coregistrant_facts_rejected" if source_cik
else "not_in_companyfacts",
self.today,
self._coregistrants.get(accn),
))
# fiscalYearEnd (MMDD) is what lets the parser derive period identity from
# reportDate instead of SEC's unreliable fy/fp fields.
result = parser.parse_snapshots(cf, xbrl_meta, accns, fiscal_year_end=fiscal_year_end)
for skipped in result.skipped_filings:
index_row = index_rows.get(skipped["accession"])
if index_row is not None:
# Facts are present but our parser cannot construct a snapshot.
# A new index row keeps the normal grace period before promotion;
# a row already read from the queue retains its _retry_queue marker
# so later imports promote and retry without wedging the index.
staged.missing_xbrl.append(_missing(
cik,
index_row,
"parser_unusable",
self.today,
self._coregistrants.get(skipped["accession"]),
))
staged.rows.extend(result.rows)
staged.rows.extend(recovered_rows)
staged.skipped_filings.extend(result.skipped_filings)
staged.field_issues.extend(result.field_issues)
staged.issuers_fetched += 1
if result.rows or recovered_rows:
staged.issuers_with_rows += 1
# SIC proposal for this issuer's tickers (read-only; applied in promote).
sic = str(sub["sic"]) if sub.get("sic") else None
desc = sub.get("sic_description")
for tid in staged.resolved.cik_to_ticker_ids.get(cik, []):
staged.sic_updates.append((tid, sic, desc))
async def _recover_from_coregistrant(
self, client, cik: int, accn: str, xbrl_meta, fiscal_year_end, reference, staged,
) -> tuple[SnapshotRow | None, str | None]:
"""Look for ``accn``'s facts in a co-registrant's Company Facts file.
SEC sometimes files a combined parent/subsidiary filing's XBRL under the
co-registrant's CIK rather than the filer's the ticker-carrying parent's
own file simply never gets that accession. Verified 2026-07-27 for NEE
(facts under Florida Power & Light) and DOW (under Dow Chemical); an NEE
filing misattributed the same way in **2014** is still misattributed, so
this does not self-correct and no amount of retrying recovers it.
Returns ``(row, source_cik)`` on success, ``(None, source_cik)`` when the
facts were found but rejected by the continuity guard, ``(None, None)``
when no co-registrant has them.
Incremental path only: the co-registrant map comes from the daily index,
which a backfill/reparse does not walk. A reparse therefore recovers a
filing only once SEC re-files it under the filer's own CIK.
"""
for co in self._coregistrants.get(accn, []):
try:
cf_co = await client.companyfacts(co)
except SecError:
continue # a co-registrant shell often has no facts file at all
if _companyfacts_structure_error(cf_co) is not None:
continue
result = parser.parse_snapshots(
cf_co, xbrl_meta, {accn}, fiscal_year_end=fiscal_year_end
)
if not result.rows:
continue
row = result.rows[0]
if not _shares_continuous(row.shares_outstanding, reference):
return None, cik10(co)
# A recovered row is the one most worth flagging, so its parser caveats
# travel with it rather than being dropped on the way out.
staged.field_issues.extend(result.field_issues)
# parse_snapshots stamps the CIK of the payload it read — re-stamp to
# the issuer that actually filed, or the row lands under the shell.
return replace(row, cik=cik10(cik)), cik10(co)
return None, None
async def validate(self, db, staged: StagedFundamentals) -> ValidationResult:
messages: list[str] = []
# Consistency gate — before any write. Only filings still inside the retry
# window block: a failure here stops every later run too (source_max_date
# advances on promotion alone), so blocking forever on a filing SEC has
# misfiled would cost far more than the one filing it withholds. Older
# ones are carried by promote() as a warning instead. The message names
# the filings: "which ones" has to be in the alert itself, not merely
# reconstructible by re-walking the index.
blocking = _within_retry_window(staged.missing_xbrl)
aged_out = _past_retry_window(staged.missing_xbrl)
if blocking:
messages.append(
f"{len(blocking)} tracked XBRL filing(s) unresolved within the "
f"{MISSING_XBRL_RETRY_DAYS}-day retry window "
f"({_reason_counts(blocking)}) — retry: {_missing_detail(blocking)}"
)
# Malformed companyfacts payloads must fail, not degrade to skipped rows.
if staged.invalid_payloads:
messages.append(
f"{len(staged.invalid_payloads)} issuer(s) returned a malformed "
"companyfacts payload (missing facts structure)"
)
accns = [r.accession for r in staged.rows]
if len(accns) != len(set(accns)):
messages.append("duplicate accession in staged snapshots")
if staged.backfill:
n_issuers = len(staged.resolved.cik_to_ticker_ids)
coverage = staged.issuers_with_rows / n_issuers if n_issuers else 0.0
if coverage < MIN_BACKFILL_COVERAGE:
messages.append(
f"backfill coverage {coverage:.0%} < {MIN_BACKFILL_COVERAGE:.0%}"
)
summary = {
"backfill": staged.backfill,
"issuers_fetched": staged.issuers_fetched,
"issuers_with_rows": staged.issuers_with_rows,
"snapshot_rows": len(staged.rows),
"skipped_filings": len(staged.skipped_filings),
"field_issues": len(staged.field_issues),
"skipped_non_xbrl": len(staged.skipped_non_xbrl),
"no_xbrl_filings": staged.no_xbrl_filings[:50],
"no_xbrl_filings_count": len(staged.no_xbrl_filings),
"no_xbrl_ciks": sorted({
str(item["cik"])
for item in staged.no_xbrl_filings
if item.get("cik")
}),
"missing_xbrl": staged.missing_xbrl[:50],
"missing_xbrl_count": len(staged.missing_xbrl),
"missing_xbrl_blocking": len(blocking),
"recovered_from_coregistrant": staged.recovered[:50],
"recovered_count": len(staged.recovered),
# Complete compact gate input; detailed audit lists above stay capped.
"setup_blocked_ciks": sorted({
str(item["cik"])
for item in [*staged.missing_xbrl, *staged.no_xbrl_filings]
if item.get("cik")
}),
"invalid_payloads": staged.invalid_payloads,
"cik_updates": len(staged.resolved.cik_updates),
# differing existing accessions (immutable — kept, reported here)
"discrepancies": staged.discrepancies[:50],
"discrepancy_count": len(staged.discrepancies),
}
return ValidationResult(
ok=not messages,
summary=summary,
source_max_date=self._latest_index_date,
messages=messages,
# Company-Facts absence is usually publication lag, but can also be a
# permanent co-registrant misfile that the daily index did not expose.
# Defer quietly at first; the framework warns if promotions stay stale.
retryable=(
len(messages) == 1
and bool(blocking)
and all(
m.get("reason") in {"not_in_companyfacts", "parser_unusable"}
for m in blocking
)
),
deferred_alert_after_days=MISSING_XBRL_RETRY_DAYS,
deferred_alert_messages=(
[
f"{len(aged_out)} tracked SEC filing(s) remain unresolved past "
f"the {MISSING_XBRL_RETRY_DAYS}-day retry window. They will "
f"enter automatic retry and block affected symbols from setups: "
f"{_missing_detail(aged_out)}"
]
if aged_out
else []
),
)
async def promote(self, db, staged: StagedFundamentals, run_id: int) -> dict[str, int]:
inserted = 0
updated = 0
# Only accessions whose reconstruction actually changed are rewritten;
# an unchanged stored row is left completely alone.
changed = {d["accession"] for d in staged.discrepancies} if self.reparse else set()
for row in staged.rows:
if row.accession in staged.existing_accessions:
if row.accession in changed:
# Write the FULL column set (_row_values covers _SNAPSHOT_COLS)
# so a rewritten row is never half old-parse, half new-parse.
# created_at stays at the original insert; import_run_id
# attributes the rewrite.
values = _row_values(row, run_id)
values.pop("created_at", None)
await db.execute(
update(FundamentalSnapshot)
.where(FundamentalSnapshot.accession == row.accession)
.values(**values)
)
updated += 1
continue # otherwise immutable — keep the original row
stmt = insert_for_session(db, FundamentalSnapshot).values(**_row_values(row, run_id))
stmt = stmt.on_conflict_do_nothing(index_elements=["accession"]) # race belt-and-suspenders
await db.execute(stmt)
inserted += 1
# Synchronize the retry queue in the snapshot-promotion transaction.
existing_gaps = (await db.execute(select(SecFilingGap))).scalars().all()
existing_gap_accessions = {gap.accession for gap in existing_gaps}
resolved_accessions = {row.accession for row in staged.rows}
# A filing now classified non-XBRL can never yield a snapshot and is no
# longer a fundamentals completeness gap.
resolved_accessions.update(
item["accession"] for item in staged.skipped_non_xbrl
)
queue_resolved = 0
if resolved_accessions:
result = await db.execute(
delete(SecFilingGap).where(
SecFilingGap.accession.in_(resolved_accessions)
)
)
queue_resolved = int(result.rowcount or 0)
now = _now()
tolerated = _past_retry_window(staged.missing_xbrl)
for gap in tolerated:
stmt = insert_for_session(db, SecFilingGap).values(
cik=gap["cik"],
accession=gap["accession"],
form=gap.get("form"),
index_date=gap.get("index_date"),
reason=gap["reason"],
coregistrant_ciks_json=json.dumps(gap.get("coregistrants") or []),
first_seen_at=now,
last_attempted_at=now,
)
await db.execute(
stmt.on_conflict_do_update(
index_elements=["accession"],
set_={
"cik": stmt.excluded.cik,
"form": stmt.excluded.form,
"index_date": stmt.excluded.index_date,
"reason": stmt.excluded.reason,
"coregistrant_ciks_json": stmt.excluded.coregistrant_ciks_json,
"last_attempted_at": stmt.excluded.last_attempted_at,
},
)
)
# Remove gaps made irrelevant by a later valid 10-K/10-Q. Quality reads
# already ignore them; physical cleanup keeps the queue small.
active_ids = {gap.id for gap in await fundamentals_quality_service.active_gaps(db)}
obsolete_ids = {
gap.id for gap in existing_gaps
if gap.id not in active_ids and gap.accession not in resolved_accessions
}
if obsolete_ids:
result = await db.execute(
delete(SecFilingGap).where(SecFilingGap.id.in_(obsolete_ids))
)
queue_resolved += int(result.rowcount or 0)
newly_queued = [
gap for gap in tolerated
if gap["accession"] not in existing_gap_accessions
]
# Warn (in-transaction, so it commits atomically with the promotion) when
# any existing accession reconstructed differently — kept immutable.
if staged.discrepancies:
accns = ", ".join(d["accession"] for d in staged.discrepancies[:10])
disposition = (
f"REWRITTEN by reparse run {run_id}" if self.reparse else "kept immutable"
)
db.add(SystemEvent(
severity="warning",
source="sec_facts",
code="snapshot_reparse" if self.reparse else "snapshot_discrepancy",
message=(
f"{len(staged.discrepancies)} stored accession(s) reconstructed "
f"differently; {disposition}: {accns}"
)[:4000],
dedup_key=f"sec_facts:discrepancy:{run_id}",
created_at=_now(),
))
# Persistent current gaps get one actionable escalation rather than a
# daily warning. The nullable marker makes this durable and noise-free.
escalation_cutoff = now - timedelta(days=FILING_GAP_ESCALATE_DAYS)
aged_gaps = (
await db.execute(
select(SecFilingGap).where(
SecFilingGap.first_seen_at <= escalation_cutoff,
SecFilingGap.escalated_at.is_(None),
)
)
).scalars().all()
if aged_gaps:
named = ", ".join(
f"{gap.cik}/{gap.accession} ({gap.reason})"
for gap in aged_gaps[:10]
)
db.add(SystemEvent(
severity="warning",
source="sec_facts",
code="filing_gap_aged",
message=(
f"{len(aged_gaps)} SEC filing gap(s) remain unresolved after "
f"{FILING_GAP_ESCALATE_DAYS} days; affected setups remain paused. "
f"Review the filing/CIK mapping or parser: {named}"
)[:4000],
dedup_key=f"sec_facts:filing_gap_aged:{run_id}",
created_at=now,
))
await db.execute(
update(SecFilingGap)
.where(SecFilingGap.id.in_([gap.id for gap in aged_gaps]))
.values(escalated_at=now)
)
# Recovered rows are real data from an unexpected place — record where they
# came from, so a wrong recovery is auditable rather than invisible.
if staged.recovered:
named = ", ".join(
f"{r['accession']} <- CIK {r['source_cik']}" for r in staged.recovered[:10]
)
logger.info(
"sec_facts: recovered %d filing(s) from co-registrants: %s",
len(staged.recovered),
named,
)
# One warning when a gap first enters automatic retry. Repeating it every
# day adds noise; the queue remains the durable actionable state.
if newly_queued:
symbols_by_cik: dict[str, list[str]] = defaultdict(list)
for symbol, cik in staged.resolved.symbol_to_cik.items():
symbols_by_cik[cik10(cik)].append(symbol)
named = ", ".join(
f"{'/'.join(symbols_by_cik.get(gap['cik'], [])) or gap['cik']}"
f"/{gap['accession']}"
for gap in newly_queued[:10]
)
db.add(SystemEvent(
severity="warning",
source="sec_facts",
code="unresolved_filing",
message=(
f"{len(newly_queued)} filing(s) entered automatic SEC retry. "
f"Affected symbols are blocked from new actionable setups until "
f"their filing is recovered: {named}"
)[:4000],
dedup_key=f"sec_facts:unresolved_filing:{run_id}",
created_at=_now(),
))
# A new registrant may have no XBRL filing yet. Keep it out of actionable
# setups, but log it instead of raising a recurring operator warning.
if staged.no_xbrl_filings:
named = ", ".join(
f"{e['cik']} ({e.get('name') or '?'})" for e in staged.no_xbrl_filings[:10]
)
logger.info(
"sec_facts: %d registrant(s) have no XBRL history yet: %s",
len(staged.no_xbrl_filings),
named,
)
ticker_counts = await sec_universe.apply_ticker_updates(
db, staged.resolved, staged.sic_updates
)
return {
"inserted": inserted,
"updated": updated,
"existing_unchanged": len(staged.existing_accessions) - updated,
"discrepancies": len(staged.discrepancies),
"retry_queue_added": len(newly_queued),
"retry_queue_resolved": queue_resolved,
**ticker_counts,
}
# -- helpers -----------------------------------------------------------
async def _retry_backlog(
self,
db,
tracked_ciks: set[int],
) -> list[dict[str, Any]]:
"""Active typed gaps; migration 028 owns historical bootstrap."""
if not tracked_ciks:
return []
tracked = {cik10(cik) for cik in tracked_ciks}
candidates: dict[str, dict[str, Any]] = {}
queued = await fundamentals_quality_service.active_gaps(db, tracked)
for gap in queued:
try:
coregistrants = json.loads(gap.coregistrant_ciks_json or "[]")
except (TypeError, ValueError):
coregistrants = []
candidates[gap.accession] = {
"cik": gap.cik,
"accession": gap.accession,
"form": gap.form,
"index_date": gap.index_date,
"reason": gap.reason,
"coregistrants": coregistrants,
"_retry_queue": True,
}
if not candidates:
return []
resolved = set(
(
await db.execute(
select(FundamentalSnapshot.accession).where(
FundamentalSnapshot.accession.in_(list(candidates))
)
)
).scalars().all()
)
return [
item
for accession, item in candidates.items()
if accession not in resolved
]
async def _last_processed_index_date(self, db) -> date | None:
return (
await db.execute(
select(DataImportRun.source_max_date)
.where(DataImportRun.source == SOURCE, DataImportRun.status == STATUS_PROMOTED)
.order_by(DataImportRun.id.desc())
.limit(1)
)
).scalar_one_or_none()
async def _collect_index_rows(
self, client: SecClient, last_processed: date, latest: date
) -> list[dict[str, Any]]:
# Walk EVERY unprocessed date. No cap — dropping the older part of a long
# outage while still advancing source_max_date would permanently lose
# those filings. A large gap is one-time cost, not silent data loss.
tracked = set(self._resolved.cik_to_ticker_ids) if self._resolved else set()
gap = (latest - last_processed).days
if gap > 60:
logger.warning("sec_facts: %d-day index gap since %s; walking all", gap, last_processed)
rows: list[dict[str, Any]] = []
day = last_processed + timedelta(days=1)
while day <= latest:
# Group the whole day first: a combined filing is listed once per
# co-registrant CIK, and those sibling CIKs are the only pointer to
# where SEC may have put the XBRL (see _recover_from_coregistrant).
by_accession: dict[str, list[dict[str, Any]]] = defaultdict(list)
for r in await client.daily_index(day):
if r["form"] in _XBRL_FORMS:
by_accession[r["accession"]].append(r)
for accession, group in by_accession.items():
filers = {r["cik"] for r in group}
tracked_filers = filers & tracked
if not tracked_filers:
continue
siblings = sorted(filers - tracked_filers)
if siblings:
self._coregistrants[accession] = siblings
for r in group:
if r["cik"] in tracked_filers:
r["index_date"] = day # not hashed (revision uses cik/accession)
rows.append(r)
day += timedelta(days=1)
return rows
async def _ciks_with_snapshots(self, db, ciks: set[int]) -> set[int]:
if not ciks:
return set()
cik_strs = [cik10(c) for c in ciks]
found = (
await db.execute(
select(FundamentalSnapshot.cik)
.where(FundamentalSnapshot.cik.in_(cik_strs))
.distinct()
)
).scalars().all()
return {int(c) for c in found}
async def _last_shares_outstanding(self, db, ciks: set[int]) -> dict[str, float]:
"""Latest known shares outstanding per tracked issuer — the continuity
reference co-registrant recovery is checked against."""
if not ciks:
return {}
rows = (
await db.execute(
select(FundamentalSnapshot.cik, FundamentalSnapshot.shares_outstanding)
.where(
FundamentalSnapshot.cik.in_([cik10(c) for c in ciks]),
FundamentalSnapshot.shares_outstanding.is_not(None),
)
# Last write per cik wins, so the sort must be total: an amendment
# and its original share a period_end, and an undefined tie there
# would make recovery non-deterministic across runs and dialects.
.order_by(
FundamentalSnapshot.period_end,
FundamentalSnapshot.filed_date,
FundamentalSnapshot.accession,
)
)
).all()
return {cik: float(shares) for cik, shares in rows}
async def _existing_by_accession(self, db, accessions: list[str]) -> dict[str, FundamentalSnapshot]:
if not accessions:
return {}
rows = (
await db.execute(
select(FundamentalSnapshot).where(FundamentalSnapshot.accession.in_(accessions))
)
).scalars().all()
return {r.accession: r for r in rows}
def _companyfacts_structure_error(cf: Any) -> str | None:
"""None if the payload is structurally sound, else a reason string. Checks the
top-level ``facts`` mapping AND that every concept carries a ``units`` mapping
a missing/non-dict units would silently drop that concept's facts otherwise."""
if not isinstance(cf, dict) or not isinstance(cf.get("facts"), dict):
return "missing facts structure"
for concepts in cf["facts"].values():
if not isinstance(concepts, dict):
return "malformed taxonomy structure"
for body in concepts.values():
if not isinstance(body, dict) or not isinstance(body.get("units"), dict):
return "missing units structure"
return None
def _missing(
cik: int,
row: dict[str, Any],
reason: str,
today: date,
coregistrants: list[int] | None = None,
) -> dict[str, Any]:
"""One unresolvable index row, carrying everything needed to look the filing
up by hand (EDGAR accession + the index date it was seen on) and to decide
whether it is still young enough to be worth blocking on."""
index_date = row.get("index_date")
age_days = (
(today - index_date).days if isinstance(index_date, date) else 0
)
if row.get("_retry_queue"):
age_days = max(age_days, MISSING_XBRL_RETRY_DAYS + 1)
return {
"cik": cik10(cik),
"accession": row["accession"],
"form": row.get("form"),
"index_date": index_date,
# A newly observed row without a date blocks safely. A durable queue row
# has already passed the bounded window and is forced aged-out above.
"age_days": age_days,
"reason": reason,
"coregistrants": list(coregistrants or []),
}
def _within_retry_window(missing: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [m for m in missing if m.get("age_days", 0) <= MISSING_XBRL_RETRY_DAYS]
def _past_retry_window(missing: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [m for m in missing if m.get("age_days", 0) > MISSING_XBRL_RETRY_DAYS]
def _shares_continuous(shares: float | None, reference: float | None) -> bool:
"""Does a co-registrant-recovered share count look like this issuer's own?
The failure worth preventing is storing a subsidiary's standalone facts as the
parent's. A co-registrant shell holds a token float — Florida Power & Light
against NextEra's 2.09bn shares — so any sane band separates them while still
tolerating buybacks and issuance. With no history to compare against (a newly
tracked issuer) or no share count at all, recovery is refused, not guessed.
"""
if not shares or not reference:
return False
return RECOVERY_SHARES_MIN <= shares / reference <= RECOVERY_SHARES_MAX
def _reason_counts(missing: list[dict[str, Any]]) -> str:
counts = Counter(m["reason"] for m in missing)
return ", ".join(f"{reason}={n}" for reason, n in sorted(counts.items()))
def _missing_detail(missing: list[dict[str, Any]], limit: int = 10) -> str:
detail = ", ".join(
f"{m['cik']}/{m['accession']} {m.get('form') or '?'} "
f"[{m.get('index_date') or '?'}] {m['reason']}"
for m in missing[:limit]
)
if len(missing) > limit:
detail += f", +{len(missing) - limit} more"
return detail
def _filing_meta(sub: dict[str, Any]) -> tuple[dict[str, FilingMeta], set[str]]:
"""(xbrl_meta, nonxbrl_accessions) from a submissions payload. xbrl_meta only
includes 10-K/10-Q(/A) filings that are XBRL and have full period metadata."""
xbrl: dict[str, FilingMeta] = {}
nonxbrl: set[str] = set()
for f in sub.get("filings", []):
if f["form"] not in _XBRL_FORMS:
continue
if not f.get("is_xbrl"):
nonxbrl.add(f["accession"])
continue
if not (f.get("report_date") and f.get("filing_date") and f.get("acceptance_datetime")):
continue
xbrl[f["accession"]] = FilingMeta(
report_date=date.fromisoformat(f["report_date"]),
filing_date=date.fromisoformat(f["filing_date"]),
accepted_at=_parse_dt(f["acceptance_datetime"]),
form=f["form"],
)
return xbrl, nonxbrl
def _parse_dt(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _row_values(row: SnapshotRow, run_id: int) -> dict[str, Any]:
values = {col: getattr(row, col) for col in _SNAPSHOT_COLS}
values["import_run_id"] = run_id
values["created_at"] = _now()
return values
def _diff_fields(row: SnapshotRow, old: FundamentalSnapshot) -> list[str]:
"""Source fields where a re-parsed row differs from the stored row."""
return [
col for col in _COMPARE_COLS
if not _same_value(getattr(row, col), getattr(old, col))
]
def _same_value(parsed: Any, stored: Any) -> bool:
"""Compare a freshly parsed value against its stored round-trip.
Datetimes need care: every timestamp here is UTC by construction, but
``DateTime(timezone=True)`` only preserves tzinfo on Postgres SQLite hands
back a naive value. Comparing representations would report an unchanged row
as differing, which would both spam the discrepancy warning and make a
reparse rewrite every row it touched. Compare instants instead.
"""
if isinstance(parsed, datetime) and isinstance(stored, datetime):
return _as_utc(parsed) == _as_utc(stored)
return parsed == stored
def _as_utc(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
+159
View File
@@ -0,0 +1,159 @@
"""Tracked-universe CIK/SIC resolution and the SEC importer's composite revision.
Resolves the app's tracked tickers to SEC issuers (CIK) and prepares
``tickers.cik/sic/sic_description`` back-fills. Also builds the **universe
fingerprint** in the importer's composite revision, so adding a ticker changes
the revision and forces a run instead of being ``no_op``'d away or starved
waiting for its issuer to file (A3 design, Decision 1 review fix).
**Transaction contract:** resolution is read-only `resolve_ciks` and
`fetch_sic_updates` compute *proposed* updates and mutate nothing. They run in
the importer's `stage` (which must not write, or a failed validation would leak
changes on the framework's failure commit). The proposals are applied only in
`promote`, via `apply_ticker_updates`, atomically with the snapshot inserts.
"""
from __future__ import annotations
import hashlib
import json
import logging
from dataclasses import dataclass, field
from typing import Iterable
from sqlalchemy import select, update
from app.models.ticker import Ticker
from app.services import settings_store
from app.services.earnings_alignment import normalise_symbol
from app.services.sec_client import SecClient
logger = logging.getLogger(__name__)
# JSON {symbol: cik} pinning a ticker to a specific registrant, overriding
# company_tickers.json. Needed when SEC maps a ticker to a successor entity that
# has not filed: XOM points at CIK 2115436 "ExxonMobil Holdings Corp" (zero XBRL
# filings) while every 10-K/10-Q — including one filed 2026-05-04 — is still under
# CIK 34088. Which registrant is the real filer is a judgement about a corporate
# event, so it is pinned explicitly rather than guessed. The importer's
# `no_xbrl_filings` warning is what tells you a pin is needed.
CIK_OVERRIDES_KEY = "sec_cik_overrides"
@dataclass
class ResolvedUniverse:
"""Read-only result of CIK resolution. `cik_updates` are proposed writes
(ticker_id new cik string) applied later in promote."""
symbol_to_cik: dict[str, int] = field(default_factory=dict)
cik_to_ticker_ids: dict[int, list[int]] = field(default_factory=dict)
cik_updates: list[tuple[int, str]] = field(default_factory=list)
async def resolve_ciks(db, client: SecClient) -> ResolvedUniverse:
"""Resolve tracked tickers to CIKs via company_tickers.json. **Read-only** —
returns the mapping + proposed `tickers.cik` writes; mutates nothing."""
ticker_to_cik = await client.company_tickers()
overrides = await cik_overrides(db)
rows = (await db.execute(select(Ticker.id, Ticker.symbol, Ticker.cik))).all()
result = ResolvedUniverse()
for tid, symbol, current_cik in rows:
if not symbol:
continue
sym = normalise_symbol(symbol)
cik = overrides.get(sym) or ticker_to_cik.get(sym)
if cik is None:
continue # ADRs / non-SEC issuers — snapshots simply absent
result.symbol_to_cik[sym] = cik
result.cik_to_ticker_ids.setdefault(cik, []).append(tid)
if current_cik != f"{cik:010d}":
result.cik_updates.append((tid, f"{cik:010d}"))
logger.info(
"resolve_ciks: %d resolved, %d proposed cik updates",
len(result.symbol_to_cik),
len(result.cik_updates),
)
return result
async def cik_overrides(db) -> dict[str, int]:
"""Manual ``{symbol: cik}`` pins from ``SystemSetting[CIK_OVERRIDES_KEY]``.
A malformed setting must never take the importer down, so anything unparseable
is logged and ignored the run then falls back to company_tickers.json.
"""
raw = await settings_store.get_value(db, CIK_OVERRIDES_KEY)
if not raw:
return {}
try:
loaded = json.loads(raw)
except (TypeError, ValueError):
logger.warning("%s is not valid JSON — ignoring CIK overrides", CIK_OVERRIDES_KEY)
return {}
if not isinstance(loaded, dict):
logger.warning("%s must be a {symbol: cik} object — ignoring", CIK_OVERRIDES_KEY)
return {}
out: dict[str, int] = {}
for symbol, cik in loaded.items():
try:
out[normalise_symbol(str(symbol))] = int(cik)
except (TypeError, ValueError):
logger.warning("%s: bad entry %r -> %r — ignoring", CIK_OVERRIDES_KEY, symbol, cik)
if out:
logger.info("resolve_ciks: %d CIK override(s) applied: %s", len(out), sorted(out))
return out
async def fetch_sic_updates(
client: SecClient, cik_to_ticker_ids: dict[int, Iterable[int]]
) -> list[tuple[int, str | None, str | None]]:
"""Fetch SIC for each CIK (recent-only submissions, no history shards) and
return proposed `(ticker_id, sic, sic_description)` writes. **Read-only** no
DB mutation. Callers pass only the CIKs that need it (e.g. missing a SIC)."""
updates: list[tuple[int, str | None, str | None]] = []
for cik, ticker_ids in cik_to_ticker_ids.items():
sub = await client.submissions(cik, include_history=False)
sic = str(sub["sic"]) if sub.get("sic") else None
desc = sub.get("sic_description")
for tid in ticker_ids:
updates.append((tid, sic, desc))
return updates
async def apply_ticker_updates(
db,
resolved: ResolvedUniverse,
sic_updates: list[tuple[int, str | None, str | None]] | None = None,
) -> dict[str, int]:
"""Apply the proposed cik / sic writes. **The only writer** — call inside
promote so it commits atomically with the snapshot inserts."""
for tid, cik in resolved.cik_updates:
await db.execute(update(Ticker).where(Ticker.id == tid).values(cik=cik))
for tid, sic, desc in sic_updates or []:
await db.execute(
update(Ticker).where(Ticker.id == tid).values(sic=sic, sic_description=desc)
)
return {"cik_updates": len(resolved.cik_updates), "sic_updates": len(sic_updates or [])}
def universe_fingerprint(symbol_to_cik: dict[str, int]) -> str:
"""Stable short hash of the tracked symbol->CIK set. Changes whenever a ticker
is added/removed or its CIK mapping changes."""
canonical = ";".join(f"{sym}:{cik}" for sym, cik in sorted(symbol_to_cik.items()))
return hashlib.blake2b(canonical.encode("utf-8"), digest_size=12).hexdigest()
def index_content_hash(index_rows: Iterable[dict]) -> str:
"""Order-independent hash of the tracked index accessions consumed this run."""
keys = sorted(f"{r['cik']}/{r['accession']}" for r in index_rows)
return hashlib.blake2b("|".join(keys).encode("utf-8"), digest_size=12).hexdigest()
def compose_revision(index_date, content_hash: str, symbol_to_cik: dict[str, int]) -> str:
"""Composite revision = processed index date + index-content hash + universe
fingerprint. Equal across runs nothing new no_op. Rejects a missing index
date rather than emitting a `None:...` revision that could false-match."""
if index_date is None:
raise ValueError("compose_revision requires a non-null index date")
return f"{index_date}:{content_hash}:{universe_fingerprint(symbol_to_cik)}"
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
# One-time production provisioning for the shadow fundamentals sources.
# Run as root to install; run with --check as the deploy user for a read-only
# preflight. Version upgrades are intentional code changes, never "latest".
DOLT_VERSION="2.2.0"
DOLT_BINARY="${DOLT_BINARY:-/usr/local/bin/dolt}"
DOLT_DATA_DIR="${DOLT_DATA_DIR:-/var/lib/signal-platform/dolt}"
DOLT_EARNINGS_SUBDIR="${DOLT_EARNINGS_SUBDIR:-earnings}"
APP_USER="${APP_USER:-deploy}"
APP_GROUP="${APP_GROUP:-deploy}"
ENV_FILE="${ENV_FILE:-/opt/signalplatform/.env}"
MIN_FREE_GB="${DOLT_MIN_FREE_DISK_GB:-5}"
EARNINGS_DIR="${DOLT_DATA_DIR}/${DOLT_EARNINGS_SUBDIR}"
DOLT_IDENTITY_NAME="${DOLT_IDENTITY_NAME:-Signal Platform}"
DOLT_IDENTITY_EMAIL="${DOLT_IDENTITY_EMAIL:-signal-platform@localhost}"
FUNDAMENTALS_PARITY_REPORT_DIR="${FUNDAMENTALS_PARITY_REPORT_DIR:-/var/lib/signal-platform/reports/fundamentals-parity}"
fail() {
echo "ERROR: $*" >&2
exit 1
}
version_ok() {
local output
output="$("$DOLT_BINARY" version 2>/dev/null || true)"
grep -Eq "(^|[[:space:]])v?${DOLT_VERSION}([[:space:]]|$)" <<<"$output"
}
as_app_user() {
if [[ "$(id -un)" == "$APP_USER" ]]; then
"$@"
else
command -v runuser >/dev/null 2>&1 || fail "runuser is required"
runuser -u "$APP_USER" -- "$@"
fi
}
repo_command() {
(
cd "$EARNINGS_DIR"
as_app_user "$@"
)
}
repo_config_value() {
repo_command "$DOLT_BINARY" config --get "$1"
}
configure_identity() {
local name email
name="$(repo_config_value user.name 2>/dev/null || true)"
email="$(repo_config_value user.email 2>/dev/null || true)"
if [[ -z "$name" ]]; then
repo_command "$DOLT_BINARY" config --local --add user.name "$DOLT_IDENTITY_NAME"
fi
if [[ -z "$email" ]]; then
repo_command "$DOLT_BINARY" config --local --add user.email "$DOLT_IDENTITY_EMAIL"
fi
}
check_free_space() {
local available_kb
available_kb="$(df -Pk "$DOLT_DATA_DIR" | awk 'NR == 2 {print $4}')"
[[ "$available_kb" =~ ^[0-9]+$ ]] || fail "could not read free space for $DOLT_DATA_DIR"
if ! awk -v available="$available_kb" -v minimum_gb="$MIN_FREE_GB" \
'BEGIN { exit !(available >= minimum_gb * 1024 * 1024) }'; then
fail "$DOLT_DATA_DIR has less than ${MIN_FREE_GB} GB free"
fi
}
check_env() {
[[ -f "$ENV_FILE" ]] || fail "missing environment file: $ENV_FILE"
grep -Fqx "DOLT_BINARY=$DOLT_BINARY" "$ENV_FILE" \
|| fail "set DOLT_BINARY=$DOLT_BINARY in $ENV_FILE"
grep -Fqx "DOLT_DATA_DIR=$DOLT_DATA_DIR" "$ENV_FILE" \
|| fail "set DOLT_DATA_DIR=$DOLT_DATA_DIR in $ENV_FILE"
grep -Fqx "DOLT_EARNINGS_SUBDIR=$DOLT_EARNINGS_SUBDIR" "$ENV_FILE" \
|| fail "set DOLT_EARNINGS_SUBDIR=$DOLT_EARNINGS_SUBDIR in $ENV_FILE"
grep -Eq '^SEC_USER_AGENT=.*@.*' "$ENV_FILE" \
|| fail "SEC_USER_AGENT in $ENV_FILE must contain a real contact email"
grep -Fqx "FUNDAMENTALS_PARITY_REPORT_DIR=$FUNDAMENTALS_PARITY_REPORT_DIR" "$ENV_FILE" \
|| fail "set FUNDAMENTALS_PARITY_REPORT_DIR=$FUNDAMENTALS_PARITY_REPORT_DIR in $ENV_FILE"
}
check_all() {
local identity_name identity_email
id "$APP_USER" >/dev/null 2>&1 || fail "missing service user: $APP_USER"
[[ -x "$DOLT_BINARY" ]] || fail "missing Dolt binary: $DOLT_BINARY"
version_ok || fail "expected Dolt $DOLT_VERSION at $DOLT_BINARY"
[[ -d "$EARNINGS_DIR/.dolt" ]] \
|| fail "missing earnings clone: $EARNINGS_DIR"
if [[ "$(id -un)" == "$APP_USER" ]]; then
[[ -r "$EARNINGS_DIR/.dolt" ]] \
|| fail "earnings clone is not readable by $APP_USER"
elif command -v runuser >/dev/null 2>&1; then
runuser -u "$APP_USER" -- test -r "$EARNINGS_DIR/.dolt" \
|| fail "earnings clone is not readable by $APP_USER"
else
fail "run --check as $APP_USER (or install runuser)"
fi
identity_name="$(repo_config_value user.name 2>/dev/null || true)"
identity_email="$(repo_config_value user.email 2>/dev/null || true)"
[[ -n "$identity_name" ]] || fail "missing Dolt user.name for $EARNINGS_DIR"
[[ -n "$identity_email" ]] || fail "missing Dolt user.email for $EARNINGS_DIR"
[[ -d "$FUNDAMENTALS_PARITY_REPORT_DIR" ]] \
|| fail "missing parity report directory: $FUNDAMENTALS_PARITY_REPORT_DIR"
if [[ "$(id -un)" == "$APP_USER" ]]; then
[[ -w "$FUNDAMENTALS_PARITY_REPORT_DIR" ]] \
|| fail "parity report directory is not writable by $APP_USER"
else
runuser -u "$APP_USER" -- test -w "$FUNDAMENTALS_PARITY_REPORT_DIR" \
|| fail "parity report directory is not writable by $APP_USER"
fi
check_free_space
check_env
echo "OK: Dolt $DOLT_VERSION and earnings clone are provisioned"
}
if [[ "${1:-}" == "--check" ]]; then
check_all
exit 0
fi
[[ "$EUID" -eq 0 ]] || fail "run provisioning as root (or use --check)"
command -v curl >/dev/null 2>&1 || fail "curl is required"
command -v runuser >/dev/null 2>&1 || fail "runuser is required"
id "$APP_USER" >/dev/null 2>&1 || fail "missing service user: $APP_USER"
if ! version_ok; then
installer="$(mktemp)"
trap 'rm -f "$installer"' EXIT
curl -fsSL \
"https://github.com/dolthub/dolt/releases/download/v${DOLT_VERSION}/install.sh" \
-o "$installer"
bash "$installer"
fi
version_ok || fail "Dolt $DOLT_VERSION installation failed"
install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$DOLT_DATA_DIR"
install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$FUNDAMENTALS_PARITY_REPORT_DIR"
check_free_space
if [[ ! -d "$EARNINGS_DIR/.dolt" ]]; then
[[ ! -e "$EARNINGS_DIR" ]] \
|| fail "$EARNINGS_DIR exists but is not a Dolt clone"
runuser -u "$APP_USER" -- \
"$DOLT_BINARY" clone post-no-preference/earnings "$EARNINGS_DIR"
fi
configure_identity
check_all
+540
View File
@@ -0,0 +1,540 @@
# 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:0015: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).
+252
View File
@@ -0,0 +1,252 @@
# A3 design — SEC fundamentals importer
Status: **design APPROVED 2026-07-22 — three decisions signed off (daily-index
fetch, primary-period-only snapshots, full-history backfill) + four review
correctness fixes folded in (composite revision incl. universe fingerprint;
submissions pagination shards for full history; index↔Company-Facts consistency
gate; insert-only immutability with discrepancy reporting; deterministic cash/debt
composition). Ready to implement.**
Companion to `docs/dolt-integration-plan.md` (workstream A, phase A3). Grounded in
live SEC data probes (Apple CIK 0000320193, company_tickers, submissions, daily-index).
## Objective (unchanged from the plan)
Populate `fundamental_snapshots` (CIK-keyed, one immutable row per accession)
and `tickers.cik/sic/sic_description` from SEC data, as a `SourceImporter`
plugging into the A1 framework. Shadow only (A3): nothing reads snapshots until
A4; `fundamental_data` is untouched until the A5 parity gate. All new metrics
are display-only.
## What the SEC data actually looks like (probed, not assumed)
`data.sec.gov/api/xbrl/companyfacts/CIK##########.json` — one JSON per **issuer
(CIK)** aggregating every period across every filing. Shape:
`facts.us-gaap.<Concept>.units.<unit>[] = {start, end, val, fy, fp, form, filed, accn, frame}`.
Ground-truth findings that drive the design:
1. **`fp` is only `Q1|Q2|Q3|FY` — there is no `Q4`.** Q4 must be derived.
2. **`fy`/`fp` are the *filing's* fiscal context, not each fact's period.** Proven:
Apple's FY2019 10-K carries a discrete Q3-FY2018 revenue fact
(`start 2018-07-01, end 2018-09-29, val 62.9B`) tagged `fp=FY` — it's a
comparative. **Period identity lives in `(start, end)` + the filing's
`reportDate`, never in `fp/fy`.** Selecting values by `fp` would silently mix
comparatives into the wrong period.
3. SEC provides **both** discrete 3-month facts **and** YTD-cumulative facts
(Apple Q2 FY26: YTD `254,940` over 6mo *and* discrete `111,184` over 3mo;
`143,756 + 111,184 = 254,940`). This confirms the stored-YTD schema: store
cumulative YTD per filing, derive discretes/Q4/TTM at read time.
4. Instant facts (`dei:EntityCommonStockSharesOutstanding`) end on the **cover
date** (2026-04-17), which differs from `period_end` (2026-03-28) → the
`shares_outstanding_date` column added in migration 026.
5. **No conditional-GET support:** the companyfacts endpoint returns no `ETag`
and no `Last-Modified`. AAPL's file is 3.75 MB. So ~505 unconditional fetches
≈ 0.51.5 GB *per run* — the plan's "conditional HTTP no-op" is impossible on
this endpoint. This is the fact that decides the fetch strategy (below).
6. `submissions/CIK##########.json` supplies `sic`, `sicDescription`,
`fiscalYearEnd` (e.g. `0926`), and per-accession `reportDate` +
`acceptanceDateTime` — the keys for period selection and `accepted_at`.
7. `company_tickers.json` uses **dash** tickers (`BRK-B`, `BRK-A`) and maps
`GOOGL`/`GOOG` to the **same** `cik_str` (1652044). The ticker→CIK join reuses
the earnings importer's `normalise_symbol` (dot→dash), so both sides match.
## Decision 1 (APPROVED) — fetch strategy: EDGAR daily-index driven
**Plan said** bulk `companyfacts.zip` + ETag no-op. **Reality:** the data.sec.gov
endpoints expose no validators, and the bulk zip is multi-GB and changes ~daily
(all of EDGAR), so ETag would rarely match → near-daily multi-GB download to get
505 issuers. Per-CIK *conditional* fetch is impossible (finding 5). Per-CIK
*unconditional* is 0.51.5 GB every night.
**Recommended:** drive off the **EDGAR daily-index** (`daily-index/YYYY/QTRn/
form.YYYYMMDD.idx` — fixed-width Form/Company/CIK/Date/accession, ~3300 rows/day,
confirmed). Each run:
- `detect_revision` → a **composite revision**, not just the date:
`latest-index-date` + a hash of the index content processed this run + a
**fingerprint of the tracked symbol→CIK set**. The CIK fingerprint is essential:
a newly added ticker changes the revision and forces a run, so a new ticker is
never `no_op`'d away or starved waiting for its issuer to file. Equal composite
revision → `no_op`.
- **No backfill sentinel.** The *absence of a prior promoted run* is what triggers
the initial full-history backfill; `source_max_date` records the processed index
date each run.
- `stage` → for each index date since the last processed one, parse the form
index, keep rows where `form ∈ {10-K, 10-Q, 10-K/A, 10-Q/A}` **and** CIK ∈
tracked set, then fetch `companyfacts/CIK.json` for **only those few issuers**
and extract their newly-reported period(s). Most nights this is a handful of
issuers → near-zero transfer, respectful of SEC fair-access.
- **First run (backfill)**: no prior promoted run → fetch companyfacts for all
tracked CIKs once (~1 GB one-time) and seed **full** history. Full history needs
the paginated submissions shards — see "CIK resolution" below.
Why this over the alternatives: transfer scales with *filings*, not with all of
EDGAR or with the universe size every night; it restores the revision/no_op
model; and it's the lightest load on SEC. Cost: daily-index parsing + date
bookkeeping (store last-processed index date in `data_import_runs` /
settings). **This deviates from the plan's "bulk zip" — requesting sign-off.**
## Decision 2 (APPROVED) — snapshot mapping: primary-period, YTD, immutable
One `fundamental_snapshots` row per accession, representing the filing's
**primary current period only** (not its comparatives):
- **Select the primary period by `end == submissions.reportDate[accn]`** (finding
2), *not* by `fp/fy`. `fiscal_period` label comes from the filing's own `fp`
(a 10-Q's own `fp` matches its current quarter; a 10-K → `FY`);
`fiscal_year`/`period_start`/`period_end` from the selected facts + submissions.
- **Duration facts → cumulative YTD.** For each concept, pick the duration fact
with `accn == thisFiling`, `end == reportDate`, and `start ≈ fiscal-year start`
(derived from `fiscalYearEnd`), sanity-checked by span length (Q1≈3mo, Q2≈6mo,
Q3≈9mo, FY≈12mo). **If the YTD fact is absent, store null — never a discrete
masquerading as cumulative** (that would poison read-time differencing).
- **Balance-sheet instants → at `end == reportDate`.** `shares_outstanding` is
the exception: prefer the `dei:EntityCommonStockSharesOutstanding` cover-page
fact and store *its own* `end` in `shares_outstanding_date` (cover date ≠
period_end); when there is no dei fact (e.g. Alphabet) fall back to
`us-gaap:CommonStockSharesOutstanding` at `reportDate`. A single consolidated
value — never a class sum (companyfacts is non-dimensional) nor
weighted-average/diluted; conflicting values → null.
- **Amendments:** a real `10-K/A` / `10-Q/A` is a new accession → a new immutable
row for the same `(cik, fy, fp)`; readers pick the newest valid `accepted_at`.
- **Out of scope (stated, not silent):** restatements that appear *only* as
comparatives inside a later normal filing are **not** captured — only a real
amendment updates a prior period. This narrows the plan's "newest accepted_at
per period" to amendment-driven updates; a deliberate KISS boundary.
- **Immutable means insert-only, not upsert.** `promote` **inserts** new accession
rows with `ON CONFLICT (accession) DO NOTHING`. An accession never mutates: if a
re-fetch reconstructs *different* values for an accession already stored, that is
a **discrepancy to report** (into `validation_json` + a system event), never a
silent overwrite, and the original `import_run_id` is never replaced. (Ordinary
updates arrive as a *new* amendment accession, which is a new row.)
## Read-time derivation (constrains the importer; built in A4)
From the per-accession YTD rows, all at read time (newest `accepted_at` per
period), following the schema decision already in the plan:
- discrete quarter = YTD(Qn) YTD(Qn1); **Q4 = FY YTD(Q3)**.
- TTM = sum of the trailing four discrete quarters (e.g. TTM@Q2 = FY(prev) +
YTD(Q2) YTD(Q2 prev year)).
- YoY = period vs same period a year earlier.
- **Hard rule the importer must enable: any missing period in a run → the derived
value is `null`, never a partial number.** So the importer must aim for
complete consecutive quarter runs per issuer and report gaps.
## Metric tag catalog (prioritized us-gaap tags + fallbacks)
Tagging is inconsistent across issuers (the plan's known risk). Each metric
resolves through an ordered tag list; first present wins; unit-checked.
| Snapshot field | Primary tag | Fallbacks | Unit |
|---|---|---|---|
| revenue | `RevenueFromContractWithCustomerExcludingAssessedTax` | `Revenues`, `SalesRevenueNet` | USD |
| net_income | `NetIncomeLoss` | — | USD |
| operating_income | `OperatingIncomeLoss` | — | USD |
| diluted_eps | `EarningsPerShareDiluted` | — | USD/shares |
| cfo | `NetCashProvidedByUsedInOperatingActivities` | `...ContinuingOperations` | USD |
| capex | `PaymentsToAcquirePropertyPlantAndEquipment` | `PaymentsToAcquireProductiveAssets` | USD |
| depreciation_amortization | `DepreciationDepletionAndAmortization` | `DepreciationAmortizationAndAccretionNet`, `DepreciationAndAmortization` | USD |
| cash_and_st_investments | see composition rule | — | USD |
| total_debt | see composition rule | — | USD |
| shares_outstanding | `dei:EntityCommonStockSharesOutstanding` | — | shares |
**Composite fields — deterministic, aggregate-first, no double counting.** Each
source tag contributes at most once:
- `cash_and_st_investments` = `CashAndCashEquivalentsAtCarryingValue`
**+ short-term investments**, where ST investments = the **first present** of
[`ShortTermInvestments`, `MarketableSecuritiesCurrent`] — never both summed.
- `total_debt` = **long-term component + short-term component**, where
- long-term = first present of [`LongTermDebt` (the aggregate, already includes
current + noncurrent portions), **else** (`LongTermDebtNoncurrent` +
`LongTermDebtCurrent`)];
- short-term borrowings = first present of [`ShortTermBorrowings`,
`CommercialPaper`] (0 if neither).
So the long-term aggregate and its components are mutually exclusive, and CP vs
short-term-borrowings is a single pick — nothing is counted twice.
EBITDA (for net-debt/EBITDA) is derived at read time = operating_income + D&A.
Concepts absent for an issuer → that field is null (display-only; no synthesis).
The exact tag lists live as named constants, tunable without touching logic.
## Fiscal-period identity
`fiscalYearEnd` (MMDD from submissions) anchors the fiscal-year start for YTD
span checks and Q4 derivation. Non-calendar fiscal years (Apple's Sept) are
handled because we key on `(start, end)` + `reportDate`, not calendar quarters.
`fiscal_year`/`fiscal_period` are stored from the filing's own `fy`/`fp` for its
primary period (safe — a filing's own context is correct for its current period).
## CIK resolution & tickers backfill
- From `company_tickers.json`: `normalise_symbol(ticker) → cik_str`. Set
`tickers.cik` for each tracked ticker (multi-class share one CIK).
- From `submissions/CIK.json`: `sic`, `sicDescription`, `fiscalYearEnd`
`tickers.sic/sic_description` (+ fiscal anchor for YTD/Q4).
- **Submissions is paginated — full history needs the shards.** `filings.recent`
holds only the **latest 1000** filings (verified: Apple `recent` = 1000). Older
accessions live in `filings.files[]` = `[{name, filingFrom, filingTo,
filingCount}]` (e.g. `CIK0000320193-submissions-001.json`, 1236 filings
19942015), each a bare object with the **same parallel arrays** including
`reportDate`, `acceptanceDateTime`, and `isXBRL`. The full-history backfill must
**follow every `filings.files[].name`** to obtain period identity + `accepted_at`
+ `isXBRL` for pre-1000 accessions. Incremental runs only need `recent`.
- Refreshed by the SEC job; a newly added ticker self-resolves on its next run
(the CIK fingerprint in the revision forces that run) — until then its snapshots
are absent → metrics null, per the plan.
## SourceImporter mapping (source = `sec_facts`)
- `detect_revision` → latest daily-index date (or `backfill` sentinel on first run).
- `stage` → resolve tracked CIKs; (incremental) parse indices since last date →
tracked filers → fetch their companyfacts → build per-accession snapshot rows;
(backfill) fetch all tracked companyfacts. In-memory staged set (KISS, per A1).
- `validate` (fail-closed) → tracked-universe **coverage floor** (issuers with ≥1
snapshot); **unit/period sanity** (YTD spans within tolerance; EPS in USD/shares);
**no duplicate accession**; **filings skipped for missing period identity are
counted in `validation_json`** (carry-forward from A1 review); an unexpected
companyfacts shape (missing `facts`/`units`) stops promotion.
- **Index↔Company-Facts consistency gate (the daily index and Company Facts are
separate SEC products that can lag each other):** for every tracked index
accession marked `isXBRL`, confirm that accession actually appears in the fetched
companyfacts before promotion. If any is missing → **fail the run and retry
later** — do **not** advance the revision and do **not** record an
incomplete/null snapshot for it. Non-XBRL amendments are skipped with a recorded
reason in `validation_json`. (The framework only stores the revision on a
promoted run, so a failed consistency check naturally leaves the revision behind
for retry.)
- `promote`**insert** snapshot rows (`ON CONFLICT (accession) DO NOTHING`;
immutable — see Decision 2), stamped `import_run_id`; refresh
`tickers.cik/sic/sic_description`. A re-fetch that reconstructs different values
for an existing accession is reported as a discrepancy, never a silent mutation.
Non-destructive (append-only accessions) — no future-row deletion like earnings.
## SEC fair-access (operational, per the plan's non-negotiable)
Identifying `User-Agent` with contact email on every request; well under 10 req/s
with spacing; exponential backoff on 429; **403 → alert and stop, never
retry-loop** — with one carved-out exception: `www.sec.gov/Archives` is served
from an S3 bucket without a `ListBucket` grant, so an **absent** file 403s with
S3's `AccessDenied` XML rather than 404 (every weekend/holiday daily index does
this). That one shape is read as "missing"; a real rejection is the WAF's
`text/html` "Undeclared Automated Tool" page and still stops the run.
New config: `sec_user_agent`, `sec_request_spacing_seconds`,
`sec_max_retries`. Keep only the last ~2 fetched artifacts on disk for debugging
(reproducibility is the normalized Postgres rows, per the plan).
## Explicitly out of scope for A3
- `fundamental_data` cutover (A5 parity gate) — snapshots only in A3.
- The read-time derivation, API object, and panel (A4).
- Comparative-only restatements (Decision 2).
- Point-in-time backtest enforcement (`accepted_at` stored, not yet enforced).
## Decisions (signed off 2026-07-22)
1. **Fetch** — EDGAR daily-index driven (Decision 1). Approved deviation from the
plan's bulk zip.
2. **Snapshot mapping** — primary-period-only per accession; comparative-only
restatements out of scope (Decision 2). Approved.
3. **Backfill depth** — seed **full** available history per issuer on first run
(cheap to store; powers the quarter tape / multi-year YoY). Approved.
+257
View File
@@ -0,0 +1,257 @@
# Fundamentals production deployment
This is the one-time production setup for the Dolt earnings and SEC fundamentals
imports. The A5 scoring cutover was approved on 2026-07-24; the compat-cache write
path is still default-off until the explicit production switch below is set. Do
not add OS cron entries: the application scheduler owns both jobs.
## What the deployment adds
- `Dolt Earnings Import (shadow)` runs daily at 02:30 America/New_York.
- `SEC Fundamentals Import` runs daily at 04:00 America/New_York. Its local
`fundamental_data` refresh runs only when the A5 switch is enabled.
- `Fundamentals Parity Report (read-only)` runs daily at 05:30 America/New_York.
- Both jobs are visible, toggleable, and manually triggerable in Admin → Jobs.
- 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.
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
FUNDAMENTALS_PARITY_REPORT_DIR=/var/lib/signal-platform/reports/fundamentals-parity
```
Use a real monitored contact address. Keep at least 5 GB free at the Dolt data
path; 810 GB gives comfortable growth headroom. The data directory must stay
outside `/opt/signalplatform`, because deployments use `rsync --delete` there.
The parity-report directory is also persistent and owned by the service user;
its small timestamped JSON/CSV bundles form the temporary A5 review trail.
## 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 (shadow)**. 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.
## A5 parity observation window
After both shadow imports are healthy, trigger **Fundamentals Parity Report
(read-only)** once in Admin → Jobs. The **A5 Fundamentals Parity** card above
the jobs shows the latest coverage/delta summary and provides authenticated JSON
and CSV downloads. The canonical server-side bundles are archived at:
```text
/var/lib/signal-platform/reports/fundamentals-parity/
```
The scheduler then generates one report daily at 05:30 New York time, after the
02:30 Dolt and 04:00 SEC jobs. Review 57 consecutive reports before making the
cutover decision. A report never writes `fundamental_data`, dimension/composite
scores, rankings, qualification state, or an approval flag. Materiality bands
only highlight rows for review; A5 still requires explicit approval.
Each bundle contains legacy and candidate P/E, revenue growth, and earnings
surprise; definition notes; source revisions and price dates; recomputed legacy
and candidate fundamental scores; and per-universe fundamental-rank changes.
Definition changes remain explicit even when numeric deltas are small.
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.
## A5 production activation (approved 2026-07-24)
The write path is controlled by the SystemSetting
`fundamental_data_sec_dolt_cutover_enabled`. An absent value, `false`, or any
value other than `true` leaves `fundamental_data` untouched. Before enabling it,
confirm the normal PostgreSQL backup containing `fundamental_data` is current.
In **Admin → Settings → Fundamentals data source**:
1. Turn on **Use SEC + Dolt for scoring inputs** and accept the confirmation.
2. Click **Run refresh now**. The SEC import may be `promoted` or `no_op`; either
result runs the local cache refresh.
The weekly legacy collector is automatically skipped while the switch is on, so
it cannot overwrite the activated cache. The switch remains visible even before
its SystemSetting row exists because the safe default is off.
If the Admin UI is unavailable, enable the cutover directly in PostgreSQL:
```sql
INSERT INTO system_settings (key, value, updated_at)
VALUES ('fundamental_data_sec_dolt_cutover_enabled', 'true', now())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, updated_at = now();
```
Then trigger **SEC Fundamentals Import** once in Admin → Jobs. Once enabled, the
same refresh also runs after an SEC network/validation failure or a source-lock
skip, because it reads only PostgreSQL snapshots, earnings events, and closes.
The job message appends the cache row count and changed score-input count when
the import itself completed successfully.
Verify the switch and refreshed rows:
```sql
SELECT key, value, updated_at
FROM system_settings
WHERE key = 'fundamental_data_sec_dolt_cutover_enabled';
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;
SELECT is_stale, count(*)
FROM composite_scores
GROUP BY is_stale;
```
The first refresh intentionally 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. Observe at
least several scheduled cycles before A6 removes the legacy providers.
## Failure and rollback
- To stop the A5 cache writes without stopping SEC snapshot ingestion, turn off
**Use SEC + Dolt for scoring inputs** in Admin → Settings. If the UI is
unavailable, set `fundamental_data_sec_dolt_cutover_enabled` back to `false`
with the SQL above (changing only the value). This prevents the next local
refresh but does not restore rows already replaced. Restore `fundamental_data`
from the pre-cutover database backup, or—before A6—manually run the legacy
Fundamental Collector if its provider keys and quota are still available.
- Disable a failing source-import job in Admin → Jobs only when ingestion itself
must stop. Existing promoted snapshots/events remain available.
- 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.
- Do not proceed to A6 until the activated cache has completed the observation
window and the forward earnings calendar remains timely.
- If report generation fails, inspect Admin → System Events and verify
`FUNDAMENTALS_PARITY_REPORT_DIR` exists and is writable by `deploy`. Existing
reports and all live data remain untouched.
+14 -2
View File
@@ -25,7 +25,7 @@ score, Structural S/R, the Gate Target Ladder, sentiment, fundamentals) is
| 1.5× ATR initial stop | Real exit | Cuts losers fast | | 1.5× ATR initial stop | Real exit | Cuts losers fast |
| 3× ATR trailing stop, 30-day max hold | Real exit | Best Sharpe of every exit tested | | 3× ATR trailing stop, 30-day max hold | Real exit | Best Sharpe of every exit tested |
| Post-stop normal gate reset | Re-entry policy | Stop always closes; a later gate failure and subsequent fresh qualification define the next signal episode. The selected study arm reached Sharpe 1.77 / CAGR 48.3% at capacity 10; live scan-before-outcome timing is stricter (Sharpe 1.68 / CAGR 44.8% analogue). [Full study](post-stop-reentry.md) | | Post-stop normal gate reset | Re-entry policy | Stop always closes; a later gate failure and subsequent fresh qualification define the next signal episode. The selected study arm reached Sharpe 1.77 / CAGR 48.3% at capacity 10; live scan-before-outcome timing is stricter (Sharpe 1.68 / CAGR 44.8% analogue). [Full study](post-stop-reentry.md) |
| Max 10 concurrent positions, 1% risk per trade | Sizing | Cap never binds in practice | | Max 10 concurrent positions, 1% risk per trade | Sizing | The cap binds by signal count, but the focused bracket found negligible opportunity cost: cap 15 admitted every blocked setup and added only 0.0018 R/trade in affected paths. [Findings](portfolio-capacity-bracket-findings.md) |
| Structural S/R | Human-facing product context | Clean, capped zones for charts and alerts; not read by the scanner | | Structural S/R | Human-facing product context | Clean, capped zones for charts and alerts; not read by the scanner |
| Gate Target Ladder | Screening machinery | Volume-free transient proposals preserve the production candidate set exactly; never an exit | | Gate Target Ladder | Screening machinery | Volume-free transient proposals preserve the production candidate set exactly; never an exit |
@@ -61,7 +61,7 @@ invites overfitting.
|---|---| |---|---|
| ATR trail multiple {1.54.0} | **Keep 3.0** — ≤2.0 whipsaws out the right tail; ≥2.5 is a plateau | | ATR trail multiple {1.54.0} | **Keep 3.0** — ≤2.0 whipsaws out the right tail; ≥2.5 is a plateau |
| Momentum lookback (6-1, 3-1, 12-7 Novy-Marx, composites) | **Keep residual 12-1** — the others have IC ≈ 0 or weaker t-stats | | Momentum lookback (6-1, 3-1, 12-7 Novy-Marx, composites) | **Keep residual 12-1** — the others have IC ≈ 0 or weaker t-stats |
| Selection cutoff {70…90} × book size {10, 15, 20} | **Keep 80 × 10**monotonically worse in both directions | | Selection cutoff {70…90} × book size {10, 15, 20} | **Keep 80 × 10**the focused daily bracket found no meaningful gain from cap 15, while weekly rank replacement hurt. [Findings](portfolio-capacity-bracket-findings.md) |
| Position sizing (equal-weight, inverse-vol, risk-% sweep) | **Keep 1% fixed-fractional** | | Position sizing (equal-weight, inverse-vol, risk-% sweep) | **Keep 1% fixed-fractional** |
| Primary-target probability floor | **Keep 20%** — pruned lottery targets, 1,428 → 1,089 qualified, lifted Sharpe | | Primary-target probability floor | **Keep 20%** — pruned lottery targets, 1,428 → 1,089 qualified, lifted Sharpe |
| Primary-target R:R selector | **Keep 1.5** — target choice is intentionally independent of the later 2.0 activation floor | | Primary-target R:R selector | **Keep 1.5** — target choice is intentionally independent of the later 2.0 activation floor |
@@ -146,6 +146,7 @@ knobs.
| **Broader universe** | Composition changes factor signs (fip tug-of-war); vol-tilt on breadth is only a **directional hypothesis** (auth. 0.048 / t 1.36) | Any prod broaden must re-validate 80/20 tilt; offline research only; research.sqlite requires completion manifest | | **Broader universe** | Composition changes factor signs (fip tug-of-war); vol-tilt on breadth is only a **directional hypothesis** (auth. 0.048 / t 1.36) | Any prod broaden must re-validate 80/20 tilt; offline research only; research.sqlite requires completion manifest |
| **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time; mark entries at actual near-close fill once ops ships | | **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time; mark entries at actual near-close fill once ops ships |
| **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR | | **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR |
| **Minimum effective-risk floor** | In cap-never-bound paths, the confounded 0.5% floor arm removed about 8% of fills while EV rose from 0.328 to 0.399 R and PF from 1.60 to 1.75, with exposure nearly unchanged | Run the frozen single-variable cap-10 A/B. [Specification](effective-risk-floor-ab.md) / [capacity findings](portfolio-capacity-bracket-findings.md) |
--- ---
@@ -197,4 +198,15 @@ qualification. The [daily re-entry matrix](post-stop-reentry.md) supports this
for the current 10-position book, but not as a universal rule for other for the current 10-position book, but not as a universal rule for other
portfolio capacities. portfolio capacities.
Capacity is now closed as a negative result. The current daily Phase A control
does reject 519 qualified entries because the ten-slot book is full versus 472
admitted trades, so the older weekly “cap never binds” claim was stale. But the
clean cap-15 arm admitted every opportunity the strategy requested and added
only 0.0018 R/trade in paths where cap 10 bound. Weekly current-rank replacement
reduced mean EV and created substantial churn. Keep cap 10 and do not build the
replacement policy. See the [frozen specification](portfolio-capacity-bracket.md)
and the separate [capacity findings](portfolio-capacity-bracket-findings.md).
The only open follow-up from that run is the
[frozen confound-free 0.5% minimum effective-risk-floor A/B](effective-risk-floor-ab.md).
The next real evidence is **forward**, not backward: the live paper-trade record. The next real evidence is **forward**, not backward: the live paper-trade record.
+124
View File
@@ -0,0 +1,124 @@
# Effective initial-risk floor A/B - frozen specification
Date frozen: 2026-08-05
Branch: research/portfolio-capacity-rebalancing
Runner: scripts/run_portfolio_construction_matrix.py
Study ID: risk-floor-ab
## Question
Does rejecting an otherwise qualified cap-10 entry when its actual initial
stop-risk after cash and notional sizing is below 0.5% of marked equity improve
trade selection?
The completed capacity bracket cannot answer this. Its cash_unbounded arm
removed the count cap and applied the 0.5% floor simultaneously. In the 70 paths
where the control cap never bound, that arm still raised mean EV from 0.328 to
0.399 R and profit factor from 1.60 to 1.75 while trades fell about 8% and
exposure stayed nearly flat. Capacity was a no-op in those paths, so the floor
is the plausible cause, but the prior arm remains confounded.
This A/B changes only the floor. It has no formal promotion gate and does not
automatically change production.
## Frozen arms
1. cap10_incumbent: current production-style cap-10 control, with no minimum
effective-risk floor.
2. cap10_min_risk_005: the same cap-10 strategy, rejecting an entry only when
actual initial stop-risk after cash/notional sizing is below 0.5% of marked
equity.
Both arms have max_positions=10, weekly replacement disabled, 1% target risk
per trade, and identical admission ordering. The only differing simulator
argument is min_initial_risk_fraction: None versus 0.005.
All other settings remain the frozen daily Phase A control: current production
construction universe, full-universe residual-momentum/low-volatility 80/20
rank, threshold 80, normal gate-reset re-entry, close fills, 3x ATR trail,
30-session maximum hold, 20% per-position notional ceiling, no leverage, and
costs of 0.10% and 0.20% per fill.
Every priced symbol contributes to the daily cross-sectional rank. Rank-only
symbols cannot submit trades. Validation retains the 450-600-symbol production
construction guardrail and the legacy-snapshot column-scoped loader.
## Frozen cohorts
Reuse the completed bracket's point-in-time daily candidate/rank cache and
cohort manifest:
- Empty book: first eligible session of each month in 2019-2025, with 504 prior
scoring sessions and 252 measurement sessions. This is the primary start-date
evidence.
- Warm book: weekly seeds 63-126 sessions before each 2019-2025 annual anchor,
with state carried into the same 252-session measurement window. This is a
state-carrying replication, not independent evidence.
The expected realization is 78 empty-book paths, 97 warm paths, seven annual
clusters in each protocol, two costs, two arms, and 700 cells.
Do not use warm-seed IQR as evidence. Six of seven completed-bracket anchors
were structurally degenerate because fractional sizing is scale invariant and
the 30-session maximum hold washed out books before anchors. The 2023 exception
shows that state carrying itself works.
## Reporting and interpretation
For every protocol and cost, pair identical paths. Report:
- mean, median, P25, and P75 paired net-EV changes in R;
- positive-path and bit-identical-path fractions;
- the median paired delta within each year and the median across seven years;
- simple 90% cluster-bootstrap context for EV and Calmar, with no CI gate;
- mean paired PF, Gain-to-Pain, Sortino, Calmar/MAR, CAGR, maximum drawdown,
total return, and Sharpe changes;
- trades, floor rejections, holding time, cash, gross exposure, average/peak
positions, turnover, and costs.
Means and identical-path fractions must appear beside medians so inert cohorts
cannot turn a left- or right-skewed treatment into a misleading zero headline.
For these 252-session windows, the implementation's full-window Calmar is CAGR
divided by maximum drawdown, the same numeric definition commonly called MAR;
do not present the duplicate label as a second independent metric.
Today's production membership is projected backward. Use paired differences
for the treatment conclusion; absolute profitability remains descriptive and
survivorship-biased. Empty and warm protocols cover the same seven market years
and must not be interpreted as independent replications.
Interpretation is deliberately simple:
- a positive result means the isolated floor improves the paired EV
distribution without an economically important loss of total-return or
drawdown quality;
- a negative result closes the floor;
- mixed EV/portfolio-quality results are reported as a trade-off, not forced
through a composite score.
## Reproducibility and macOS execution
The authoritative run refuses a dirty worktree. Its fingerprint includes the
implementation commit, this specification hash, snapshot hash, candidate-cache
key, construction view, cohort manifest, arm definitions, costs, and study
version. Cells checkpoint atomically and --resume verifies the fingerprint.
From the repository root on macOS:
python3 -m venv .venv
./.venv/bin/python -m pip install -e '.[dev]'
Preflight, reusing the completed bracket's candidate/rank cache:
./.venv/bin/python scripts/run_portfolio_construction_matrix.py + backtest_snapshots/research.sqlite + --study risk-floor-ab + --run-id prod505-effective-risk-floor-ab-daily-v1 + --candidate-cache reports/.cache/prod505-capacity-bracket-daily-v1-candidates.pkl + --workers 8 + --resume + --validate-only
Authoritative run:
./.venv/bin/python scripts/run_portfolio_construction_matrix.py + backtest_snapshots/research.sqlite + --study risk-floor-ab + --run-id prod505-effective-risk-floor-ab-daily-v1 + --candidate-cache reports/.cache/prod505-capacity-bracket-daily-v1-candidates.pkl + --workers 8 + --resume
On an M2 Pro, eight workers is the explicit high-utilization setting. Use six
instead on a memory-constrained machine; auto intentionally caps itself at six.
Changing worker count does not change the fingerprint or results.
Commit only the compact final JSON and Markdown reports. Candidate caches,
checkpoints, raw curves, and trade ledgers remain ignored.
@@ -0,0 +1,74 @@
# Fundamentals ranking-overlay research
Status: completed 2026-07-23. Decision: keep production scoring and qualification unchanged.
## Question
Does using point-in-time SEC fundamentals to reorder already-qualified long setups improve the production portfolio's risk-adjusted return? The experiments changed ranking only; qualification, execution, sizing, capacity, costs, ATR exits, and post-stop re-entry remained unchanged.
## Method
- The control was the production 80/20 residual-momentum / volatility rank.
- SEC facts became visible only after `accepted_at`, using the newest visible accession per fiscal period.
- Portfolio simulations used daily entry opportunities, close fills, the production gate-reset re-entry policy, and a 30-session horizon.
- Train contained entries before 2024-01-01, validation covered 2024, and test began 2025-01-01.
- Missing composite scores were neutral at 50.
- Deflated Sharpe used the complete registered arm count for each experiment.
The snapshot contained 511 tracked tickers, 507 unique CIKs, 30,494 SEC snapshot rows, and prices from 2021-06-24 through 2026-07-22.
## Initial experiment
The first registered matrix tested quality, growth, and balanced composites at 10%, 20%, 30%, and 40% weights: 13 trials including control.
No overlay passed the train and validation requirements. The most attractive full-period result, balanced at 10%, failed validation and improved test Sharpe by only 0.06.
Review also found that filing-time diluted EPS and shares are not reliably comparable across stock splits. A snapshot audit found share-count changes above 25% for 90 of 461 issuers with comparable 2021+ periods, including recognizable split ratios for AMZN, GOOG, NVDA, CMG, and GE plus some obvious unit anomalies. Consequently, EPS growth and share-count change cannot be trusted for historical ranking without point-in-time split factors.
The complete initial result is recoverable from Git commit `7f944d7`.
## Split-safe follow-up
The follow-up excluded diluted-EPS growth and share-count change completely. It tested:
- Quality: operating margin, FCF margin, and low net-debt/EBITDA, requiring at least two inputs.
- Growth: revenue growth only.
- Balanced: equal quality and growth weights.
- Overlay weights: 5%, 10%, and 15%.
This produced 10 registered trials including control. Growth coverage among qualified candidates was 88.96%; lack of data was not the limiting factor.
| Window | Control Sharpe | Revenue-growth 5% | Delta |
|---|---:|---:|---:|
| Train | 1.26 | 1.31 | +0.05 |
| Validation | 2.52 | 2.64 | +0.12 |
| Test | 1.99 | 1.99 | 0.00 |
| Full | 1.86 | 1.87 | +0.01 |
Revenue growth at 5% mechanically passed the deliberately permissive "not worse" gate, but did not demonstrate an economically meaningful edge:
- Test CAGR rose from 54.4% to 56.1%, while full-period CAGR fell from 52.1% to 51.5%.
- Full-period trade overlap was 68.53%, so roughly one-third of selections changed for essentially unchanged Sharpe.
- Revenue-growth IC was 0.0006 in train, 0.0053 in test, and 0.0116 full-period with a full-period t-stat of 0.55.
- Growth weights of 10% and 15% deteriorated; quality and balanced composites failed.
- The test window had already been inspected, so this follow-up was sensitivity evidence rather than a fresh out-of-sample result.
The complete split-safe result is recoverable from Git commit `dba7ea7`.
## Decision
- Do not add fundamental weight to production ranking or the automated qualification gate.
- Do not run another historical weight sweep on the same sample; it would add data-mining rather than new evidence.
- Keep fundamentals informational and user-facing in the UI.
- A5 source-parity and cutover work can proceed independently without changing scoring behavior.
- Treat historical EPS growth and share-count change as non-comparable across corporate actions until a split-aware solution or a conservative UI guard exists.
Revisit automated weighting only with materially better data, such as point-in-time split factors and historical constituent/delisting coverage, followed by genuinely new forward paper evidence.
## Limitations
The snapshot uses today's tracked universe rather than historical membership and delisted securities, creating survivorship bias. Absolute CAGR and Sharpe must not be interpreted as unbiased live expectations. The relative comparison is useful, but the observed test window and short number of independent factor windows limit statistical power.
## Repository cleanup
The experiment-only scorer, runner, Mac launcher, caches, tests, and expanded report bundles were removed after this decision. They remain recoverable from commits `eae4d34`, `34d6dda`, `7f944d7`, and `dba7ea7`. Production fundamentals derivation and ingestion remain unchanged.
+7
View File
@@ -28,6 +28,13 @@ Mechanics guards confirmed before reading results: calendar truncation asserted
| **Validation** | **1.68** | **0.72** | **41.6%** | **20.9%** | **1.99** | **239** | | **Validation** | **1.68** | **0.72** | **41.6%** | **20.9%** | **1.99** | **239** |
| Full (close-fill) | 1.77 | 0.50 | 48.3% | 21.6% | 2.23 | 472 | | Full (close-fill) | 1.77 | 0.50 | 48.3% | 21.6% | 2.23 | 472 |
**Capacity correction (2026-08-05):** the full close-fill control also records
skipped_book_full = 519 versus 472 admitted trades, so the ten-slot book
refuses 52.4% of admitted+blocked qualified opportunities. The older weekly
claim that the cap never bound is stale and does not apply to this daily
gate-reset configuration. Capacity is now isolated in the
[focused bracket study](portfolio-capacity-bracket.md).
Validation SE ≈ 0.72 — almost no arm clears a 1-SE delta. Validation SE ≈ 0.72 — almost no arm clears a 1-SE delta.
--- ---
@@ -0,0 +1,124 @@
# Portfolio-capacity bracket — findings
Date interpreted: 2026-08-05
Status: **capacity and weekly replacement closed as negative results; the
minimum effective-risk floor remains an open single-variable follow-up.**
This document interprets the frozen v2 run without modifying its generated
outputs:
- result commit: `24482c6`;
- simulation source commit: `6fc82ae8574de9104c83273e018391e75a5f8ac6`;
- frozen specification SHA-256:
`f1e37783cf6d157ecc827d48211fa45da16f0a0ac19cd23686b3902d347a1898`;
- JSON SHA-256:
`2435875667097db7416a0d96f412db81d2f2d09ba053748c9f2cfb8a0cba4417`;
- Markdown SHA-256:
`dc3f5de25eb0a156ce51d0025c90e04ac0977e9502dec47bcf1b25bdcf609c81`.
The run completed 78 empty-book paths, 97 warm-seed paths, seven annual
clusters under both protocols, two cost levels, four arms, and 1,400 cells with
no validation errors. The construction universe was 505 priced tradable
symbols plus 4,149 priced rank-only symbols.
## Capacity is economically free
The clean capacity treatment is `cap15_incumbent`: it changes no sizing or
admission rule. Its cap never bound in any cell (maximum observed position count
12; zero full-book skips), so it absorbed every opportunity blocked by cap 10.
At 0.10% per fill, split the 175 paths by whether the paired control recorded
any `skipped_book_full`. Values below are mean paired changes in net EV per
trade, in R:
| Arm | Cap never bound (n=70) | Cap did bind (n=105) |
|---|---:|---:|
| `cap15_incumbent` | +0.0000 | +0.0018 |
| `cash_unbounded` | +0.0714 | +0.0077 |
| `cap10_weekly_top10` | -0.0246 | -0.0426 |
The exact zero for cap15 in the never-bound stratum is also a harness validity
check: when the treatment cannot act, results are identical. Where it does act,
giving the strategy every slot it requested adds only 0.0018 R/trade. The old
519-blocked-versus-472-admitted count was true, but it did not imply that the
blocked opportunities were economically valuable.
Decision: **keep the production cap at 10.** Do not remove it or raise it in the
expectation of additional edge.
## The positive arm measured the risk floor
`cash_unbounded` combined two treatments: no count cap and a 0.5% minimum
effective initial-risk fraction. Its EV effect is roughly nine times larger in
the 70 paths where the control cap never bound, so capacity cannot explain the
improvement.
Within that never-bound stratum:
| Measure | Control | `cash_unbounded` |
|---|---:|---:|
| Mean trades | 75.7 | 69.9 |
| Mean cash | 27.8% | 28.2% |
| Mean gross exposure | 72.2% | 71.8% |
| Mean hold | 15.4 sessions | 15.6 sessions |
| Mean EV | +0.328 R | +0.399 R |
| Mean profit factor | 1.60 | 1.75 |
The floor removes about 8% of fills while leaving exposure and holding time
nearly unchanged. This is selection, not general de-risking: candidates that
available sizing compresses below half the intended risk are worse on average.
The report records repeated reject attempts, not the rejected candidates'
ranks, so whether the effect is rank-mediated remains unknown.
Next research: one single-variable A/B, `cap10_incumbent` versus cap 10 with
`min_initial_risk_fraction=0.005`, with every other rule unchanged. Do not call
the current `cash_unbounded` result causal evidence for that floor until this
confound-free comparison is run.
## Weekly replacement hurts
Median paired deltas read zero because enough cohorts are inert. The distribution
is not neutral:
| Protocol | Mean ΔEV | P25 ΔEV | Identical paths |
|---|---:|---:|---:|
| Empty book | -0.0360 R | -0.0817 R | 27/78 (34.6%) |
| Warm book | -0.0348 R | -0.1582 R | 14/97 (14.4%) |
The arm made 2,170 replacements and 529 same-symbol re-entries within ten
sessions, so 24% of replacements were associated with short-horizon churn.
Decision: **reject weekly top-10 replacement.** Future reports should show mean
paired effects and identical-path fractions beside medians whenever treatments
are inert in a material share of cohorts.
## Warm dispersion was mostly structurally degenerate
For six of seven anchors, control EV IQR is numerical zero (approximately
`1e-16`) and Calmar IQR is exactly zero. The displayed ratio `1.000` is therefore
mostly the implementation's zero-over-zero convention, not evidence of equal
nonzero dispersion.
Two mechanics cause convergence: sizing and notional limits are fractions of
equity, making R and ratio metrics scale-invariant; and the 30-session maximum
hold is shorter than the 63-session minimum seed offset, allowing initial books
to wash out before the anchor.
The exception is 2023. Control measurement-start positions vary from 6 to 9,
EV IQR is 0.0274 R, and Calmar IQR is 0.2675. The protocol therefore carries
state correctly, but its chosen offsets usually erase the initialization effect
it was intended to measure.
Future initialization studies should use seed offsets shorter than maximum hold,
approximately 525 sessions. The current empty-book cohorts remain the primary
start-date evidence, but they necessarily mix initialization with market regime.
## Final decisions
1. Keep cap 10; its measured opportunity cost is negligible.
2. Reject weekly rank replacement.
3. Do not interpret the `cash_unbounded` improvement as a capacity effect.
4. Run only the focused cap-10 effective-risk-floor A/B next.
5. Report means, inert fractions, and absolute dispersion beside medians and
ratios in future sparse-treatment studies.
+169
View File
@@ -0,0 +1,169 @@
# Portfolio-capacity bracket — frozen specification
Date frozen: 2026-08-05
Branch: research/portfolio-capacity-rebalancing
Runner: scripts/run_portfolio_construction_matrix.py
## Question and motivation
The daily Phase A production control (a0_control: close fill, 30-session
maximum hold, 1% fixed-fractional risk, no correlation or volatility overlay)
recorded 472 trades and 519 otherwise qualified entries rejected because the
ten-position book was full. The blocked share is 519 / (519 + 472) = 52.4%.
The book is therefore materially arrival-order constrained.
This supersedes the older statement that the ten-slot cap never bound. That
statement came from a shorter, weekly, pre-gate-reset replay and is not evidence
about the current daily strategy.
The study brackets the value of capacity before tuning replacement details. It
does not contain a formal promotion rule or automatically change production.
Because the current ~505-name production membership is projected backward,
paired arm-versus-control differences are the primary evidence. Absolute
profitability is descriptive and survivorship-biased.
Implementation correction: the first completed v1 artifact at commit `23fe39f`
incorrectly allowed the snapshot's broad rank-only universe to submit trades.
That artifact is invalid, is removed from the branch, and must not be used for
strategy conclusions. Runner v2 fixes the construction/ranking partition below.
## Frozen arms
1. **cap10_incumbent:** exact production-style cap-10 control, no displacement.
2. **cash_unbounded:** no position-count cap; cash/no leverage and the existing
20% per-position notional ceiling remain. Reject an entry if actual initial
stop-risk after cash/notional sizing is below 0.5% of marked equity.
3. **cap10_weekly_top10:** on the final trading session of each ISO week, rank
holdings plus fresh same-day qualified entrants and retain the top ten.
4. **cap15_incumbent:** cap 15, no displacement.
All arms use the frozen Phase A control configuration: daily candidate replay,
live-like full-universe residual-momentum/low-volatility 80/20 rank, activation
threshold 80, normal gate-reset re-entry, close fill, 3×ATR trail, 30-session
maximum hold, 1% risk, and costs of 0.10% and 0.20% per fill.
Every priced symbol contributes to the daily cross-sectional rank. Only symbols
not listed in the snapshot's `research_rank_only` side table may submit trade
setups to any arm. The resulting construction universe must contain 450-600
symbols (expected approximately 505); validation fails outside that frozen
guardrail or when the side table references unknown ticker symbols.
The daily replay uses zero outcome horizon: setup and rank observations continue
through the snapshot's last session because portfolio simulation, unlike outcome
grading, does not require 30 future bars.
Control-parity note: a direct main-versus-branch comparison found identical
total return, CAGR, maximum drawdown, and Sharpe. The branch intentionally
changes only the first calendar year's `yearly_returns` convention: it starts
from initial capital rather than equity after the first session, so day-one
entry costs are now charged to year one. Older reports can therefore show a
different first-year contextual return without a strategy-performance
regression. New trade-detail and measurement-start fields are additive.
### Weekly-selection mechanics
- Ordinary exits run before entries/rebalancing.
- Open slots may still fill from daily qualified entries during the week.
- On the final ISO-week session, current holdings and that day's fresh qualified
entrants use the full-universe strategy_rank for that same date.
- Stored entry-day rank is never used.
- Holdings with missing current rank/data are protected and consume a slot;
entrants missing rank are ineligible.
- Incumbents win exact rank ties; symbol is the deterministic final tie-breaker.
- Rebalance exits pay costs and bypass cooldown/post-stop state.
- Report entrant-pool sizes, replacements, turnover, and same-symbol re-entry
within 5/10/20 sessions.
## Frozen cohorts
research.sqlite is expected to cover 2016-01-04 through 2026-07-17. Residual
momentum requires 252 benchmark sessions. Empty-book starts additionally require
504 prior scoring sessions and 252 forward measurement sessions.
- **Empty book:** first eligible session of each month, approximately January
2019 through July 2025; start with no positions and measure 252 sessions.
- **Warm book:** first session of each year 20192025 is the measurement anchor.
Seed the portfolio on the first session of every ISO week falling 63126
trading sessions before the anchor, carry all positions and gate-reset state
forward, and measure the same 252-session anchor window.
Warm portfolio returns reset to marked equity immediately before the anchor
session. P&L after the anchor from carried positions belongs to portfolio
returns, while trade EV includes only entries on or after the anchor. Remaining
positions liquidate at the last measurement close with costs.
The validate-only mode must print realized cohort counts and fail unless both
protocols contain the seven annual clusters 20192025 and every warm anchor has
at least 12 seeds. It must also print ranking, rank-only, and tradable symbol
counts plus the raw, removed, and retained qualified-long counts.
## Reporting
Primary reported measures:
- net EV per trade in R, with costs and actual initial stop-risk dollars;
- Calmar (CAGR / max drawdown);
- profit factor on net trade R;
- Gain-to-Pain (sum of all monthly returns / absolute sum of negative months);
- Sortino using daily returns and zero target.
Also report total return/CAGR, maximum drawdown, Sharpe, win rate, time
underwater, exposure, cash, average/peak positions, sessions at capacity,
turnover, costs, qualified/admitted/blocked opportunities, and minimum-risk
rejections.
For each arm/protocol/cost/metric, pair identical paths with cap10_incumbent,
take the median paired delta within each start year or annual anchor, show all
seven cluster values, and headline their median.
Initialization dispersion is reported separately for EV and Calmar: calculate
the seed-path IQR within each warm anchor, divide by the paired control IQR, show
all seven ratios, and headline their median. Do not combine them into a composite.
For context only, run a deterministic 10,000-replicate cluster bootstrap over
the seven paired annual summaries and report the central 90% percentile interval
for median EV and Calmar deltas and warm IQR ratios. These intervals are not
promotion gates, independent-population confidence claims, or formal inference.
## Reproducibility and execution
Candidate replay/ranks cache under reports/.cache; each matrix cell checkpoints
atomically and resume verifies a fingerprint over the implementation commit,
this specification hash, snapshot SHA-256, cache key, arm definitions, costs,
and cohort manifest. An authoritative run refuses a dirty worktree.
The existing v1 candidate/rank cache is intentionally reusable: its
full-universe current-day ranks are correct. Runner v2 derives a fingerprinted
construction view by removing qualified rows whose symbols are rank-only. V2
uses a versioned checkpoint directory, so invalid v1 portfolio cells are never
resumed and the expensive daily rank replay does not need to run again.
The loader reads only ticker ID/symbol and the OHLCV columns used by replay, so
snapshots created before SEC metadata added `tickers.cik`, `tickers.sic`, and
`tickers.sic_description` remain valid. Do not migrate or alter the research
snapshot: its original SHA-256 is part of the run fingerprint.
macOS environment setup from the repository root (zsh):
python3 -m venv .venv
./.venv/bin/python -m pip install -e '.[dev]'
Preflight:
./.venv/bin/python scripts/run_portfolio_construction_matrix.py \
backtest_snapshots/research.sqlite \
--run-id prod505-capacity-bracket-daily-v1 \
--workers auto \
--resume \
--validate-only
Authoritative run:
./.venv/bin/python scripts/run_portfolio_construction_matrix.py \
backtest_snapshots/research.sqlite \
--run-id prod505-capacity-bracket-daily-v1 \
--workers auto \
--resume
Commit only the compact final JSON and Markdown reports. Raw curves, trades,
candidate caches, and checkpoints remain ignored.
-75
View File
@@ -1,75 +0,0 @@
# Regime Monitor v2 methodology
The Regime Monitor is an observational AI/Tech risk thermometer. It does not
gate entries, exits, position size, ranking, or alerts about individual setups.
## Outputs
**State** measures current structural stress:
- Price structure, 40%: `max(P1, P2, P3)`, so the correlated 200-DMA, death-cross,
and drawdown readings receive one capped vote.
- Fixed-basket breadth level, 25%.
- HY option-adjusted credit spread, 20%.
- VIX level, 15%.
**Warning** measures deterioration and divergence:
- Fixed-basket breadth divergence while SMH holds/rises, 50%.
- 60-session SMH/SPY relative-strength deterioration, 30%.
- Hyperscaler capex cuts, 12%.
- Good-news-stock-down earnings reactions, 8%.
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v2.
## Scale and missing data
Zero means ordinary/healthy, and only stress contributes positively. Automated
capex `raising`/`holding` and no good-news-stock-down pattern map to zero;
`mixed`, unknown, and stale observations are unavailable rather than neutral 50.
Manual observations use the same categories: each hyperscaler is marked
`raising`, `holding`, `cutting`, or `unknown`, while the earnings reaction is
`yes`, `no`, or `mixed`. F1 is derived from the share of at least three known
hyperscalers marked `cutting`; arbitrary numeric overrides are not accepted.
Scores renormalize over available fixed weights, but a band is published only at
75% or greater coverage. Trend deltas are suppressed when the participating
pillar set changes. Bands are stable `<30`, watch `<60`, elevated `<80`, and
breaking `>=80`.
Credit uses named HY OAS anchors (3.5 mild, 5.0 elevated, 7.0 stressed) for 70%
of its score and a ten-year upper-tail percentile for 30%.
## Point-in-time record
The first v2 run rebuilds the latest 400 trading sessions with sufficient sensor
warm-up. Routine runs thereafter insert/update only the latest trading date.
Fundamental observations have an effective date (normally the next session after
collection) and are never replayed backward. The history API and main chart show
only snapshots marked `methodology: v2`.
Each snapshot stores the fixed basket symbols, hash, and freeze date. Reconstructed
history before that freeze date is retrospective/exploratory; readings after it
form the forward record.
The automatic 400-session rebuild is intentionally one-shot: it runs only when
no v2 snapshot exists. If an initial seed used partial data or the wrong basket,
the operational reseed procedure is to remove the v2 snapshot rows and run the
Regime Monitor job again. There is no routine force-rebuild flag.
## Warning study
The study calls the outcome a **10% correction**, not a regime break. The first
70% of sessions freezes the 80th-percentile warning threshold; alarm episodes are
measured on the final 30%. An alarm requires an upward crossing and another alarm
requires a reset below the threshold. The report exposes warned/missed events,
false alarms per year, median lead, sample dates, event count, report date, and
whether the result is exploratory or a true forward holdout. UI claims are
generated from that report; no performance sentence is hard-coded.
## Operator rule
Quadrant alerts default off for new/reset configurations. When enabled they
require fresh inputs, at least 75% coverage on both axes, two consecutive daily
confirmations, hysteresis, and cooldown. Every alert states: **Risk thermometer —
not a trade signal.**
+202
View File
@@ -0,0 +1,202 @@
# Regime Monitor v3 methodology
The Regime Monitor is an observational AI/Tech risk thermometer. It does not
gate entries, exits, position size, ranking, or alerts about individual setups.
v3 supersedes v2. Every parameter below was calibrated against the 408 v2
sessions ending 2026-07-24, reproduced offline from the same Alpaca and FRED
inputs the live job uses; the reproduction matched the stored prod distribution
exactly (State avg 22.6/22.7, p80 35.1, max 91.2, P3 pegged 39, W1 live 108).
## What changed and why
**Fundamentals left the score.** F1 (capex) and F3 (good-news-stock-down)
carried 12 + 8 of 100 Warning points. Pegged at maximum stress they produced a
Warning of exactly 20.0 — below the event study's 25.3 alarm threshold, and
still inside the "stable" band. The sourced observation could not change any
published conclusion, so refreshing it looked like it did nothing. They are now
a qualitative overlay reported beside the scores. Capex also stopped scoring
`raising` and `holding` identically at 0: `holding` is the deceleration case and
now scores 50, so a boom no longer reads the same as a stall.
**The drawdown sensor stopped saturating.** v2 used `dd_pct * 5`, reaching 100 at
a 20% drawdown — the 90th percentile of the observed distribution. 39 of 408
sessions sat at exactly 100 with no resolution left, and the price pillar showed
the top band on 13.5% of sessions. v3 uses named anchors with headroom past the
observed 36% maximum, and blends leader/confirm 2:1 as P1 and P2 already did
instead of taking `max()`. P3's realized share of State falls from 65% to 40%,
matching its nominal weight.
**Warning gained a sensor with range.** The HY OAS *level* is pinned at zero
below the 3.5 mild anchor (2.77 at the cutover), so credit contributed nothing
in a calm tape. Its 20-session rate of change still does, and spread widening is
a classic lead.
**The credit percentile leg was removed.** Its reference window silently shrank
from 10 years to 3 when ICE restricted the upstream series in April 2026, after
which it scored 20 points of stress at a spread the same sensor's anchors call
"mild". See Calibration below.
**Breadth loss counts during declines.** v2's divergence gate was
`price_ret >= 0`, so the sensor zeroed during every selloff. On 2026-07-24 the
basket shed 10 points of participation in 20 sessions while SMH fell 11.9% and
Warning printed exactly 0. v3 tapers to a floor instead: deterioration counts
fully when price masks it (true divergence, the dangerous pre-top case) and at
35% when price confirms it. Breadth *level* lives in State, but breadth
*velocity* appears nowhere else, so this is not double counting.
**Bands are per axis.** v2 Warning never exceeded 64.9 in 408 sessions while
State reached 91.2, yet both used 30/60/80 with quadrant dividers at 60. The
upper half of the Warning axis was unreachable.
## Outputs
**State** — current structural stress:
- Price structure, 40%: `max(P1, P2, P3)`, one capped vote for correlated reads.
- Fixed-basket breadth level, 25%.
- HY option-adjusted credit spread level, 20%.
- VIX level, 15%.
**Warning** — deterioration and divergence:
- Fixed-basket breadth divergence, 45%.
- 60-session SMH/SPY relative-strength deterioration, 30%.
- HY OAS 20-session widening, 25%.
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v3.
## Calibration
P3 drawdown anchors, as (drawdown %, score): 0→0, 4→10, 8→25, 16→50, 28→78,
40→100, flat outside. Credit impulse is relative (+35% over 20 sessions = 100)
rather than absolute, because +0.5pp means something very different at an OAS of
2.7 than at 8.0.
Bands are round, meaning-anchored numbers, not percentile fits — percentile
thresholds would drift on every rebuild and silently rewrite what past snapshots
meant. Realized shares over the calibration window:
| Axis | stable | watch | elevated | breaking | thresholds |
|------|--------|-------|----------|----------|------------|
| State | 73.3% | 15.0% | 8.3% | 3.4% | 20 / 50 / 80 |
| Warning | 69.4% | 19.6% | 7.6% | 3.4% | 20 / 40 / 60 |
Quadrant dividers sit at each axis's watch/elevated boundary: State 50,
Warning 40.
Scores renormalize over available fixed weights, but a band is published only at
75% or greater coverage. Trend deltas are suppressed when the participating
pillar set changes. Zero means ordinary/healthy; only stress contributes.
Credit level is the named HY OAS anchors alone: 3.5 mild, 5.0 elevated, 7.0
stressed, linear between, and nothing else. v2 blended those anchors at 70% with
a 30% upper-tail percentile over a nominally 10-year window.
That leg was removed rather than repaired. ICE restricted FRED to a rolling
3-year window for `BAMLH0A0HYM2` in April 2026 — the series metadata states it
outright ("Starting in April 2026, this series will only include 3 years of
observations"), and an unbounded request returns the same 795 observations as a
30-year one. The v2 percentile therefore ranked the current spread against three
uniformly tight years (range 2.594.61 over the calibration window), which made
it fire early and saturate absurdly: at an OAS of 3.50 — the level the anchors
call *mild*, scoring zero stress — the blended sensor read 20.1, and the
percentile leg pegged at 100 by an OAS of 4.5. Across the 408 sessions it
roughly tripled the credit sensor's average (2.70 vs 1.00) and more than doubled
its nonzero days (60 vs 27).
The anchors already encode the long-run distribution as constants, so the
percentile was a second, noisier estimate of the same thing. What it was
genuinely reaching for — "unusual versus recent history" — is now W3 on the
Warning axis, computed as a rate of change, which is where deterioration
belongs. Removing it moved State's average by 0.4 and its maximum by 3.8, left
Warning bit-identical, and did not shift any band threshold.
A long-history alternative (`BAA10Y`, Fed-published, 7,712 observations back to
1997) was considered and rejected: ranking an HY spread against investment-grade
history is not a coherent statistic, and it would rescue a leg that is redundant
anyway.
Every snapshot now records `data_quality.credit_history_days` and
`vix_history_days`. This defect was invisible for roughly three months because
nothing asserted the window the code claimed; the spans make a future upstream
truncation show up in the record instead of quietly reshaping a sensor.
**Survivorship caveat.** The basket was frozen 2026-07-15 but the calibration
window reaches back to 2024, so names were partly selected for having done well.
Every distribution above inherits that bias. It is the same bias v2 carried, so
the v2/v3 comparison is like-for-like, but the absolute band shares are
optimistic.
## Point-in-time record
The first run under a new `METHODOLOGY` rebuilds the latest 400 trading sessions
with sufficient sensor warm-up; routine runs thereafter insert/update only the
latest trading date. The history API and main chart show only snapshots matching
the current methodology, so a bump reseeds the series rather than splicing two
formulas into one line.
The fundamental overlay keeps its effective date (normally the next session after
collection) and is never replayed backward, so a rebuild cannot stamp today's
observation onto historical snapshots. Because the observation is stored in a
single slot, a refresh replaces the previously effective record: the snapshot
therefore reports the overlay as `pending` until the new effective date, and the
live reading additionally carries `fundamental_context` so a just-collected
observation is visible immediately rather than appearing to have done nothing.
Each snapshot stores the fixed basket symbols, hash, and freeze date.
Reconstructed history before that freeze date is retrospective/exploratory.
## Warning study
The study calls the outcome a **10% correction**, not a regime break. The first
70% of sessions freezes the 80th-percentile warning threshold; alarm episodes are
measured on the final 30%. Because v3 dropped fundamentals from the score, the
study now measures exactly the live Warning score rather than a technical-only
approximation of it, and both are computed from one shared sensor definition
(`warning_sensor_scores`) so they cannot drift apart.
A cached report is discarded when its methodology no longer matches, so the panel
reverts to "not run yet" after a bump rather than showing stale numbers. **Re-run
the Event Study job after cutting over to v3.**
### Reading the result
The report carries a `reliability` block and the UI renders its warnings, because
the headline numbers invite over-reading in two specific ways.
**The holdout is thin.** The study detects 11 corrections across 5 years but the
70/30 split leaves only 4 in the test period. Recall is therefore one event away
from a materially different headline, and in practice the event that flips is
decided by where the frozen threshold happens to land rather than by whether the
score saw anything. The v3 cutover run illustrates it: v3 scored 2/4 against v2's
3/4, but "v3 without the credit sensor" scores 3/4 at a *higher* threshold
(35.5) than shipped v3 misses it at (32.3) — because the alarm rule needs a
rising edge, and a lower threshold can mean the alarm already fired outside the
20-session horizon and never reset below. Below `MIN_EVENTS_FOR_CONFIDENCE`
holdout events the report says so explicitly.
Some events carry no information at all for comparison: in that run every
variant caught 2026-03-06, every variant missed 2026-06-05, and every variant
"caught" 2025-11-20 with a 1-session lead, which is coincident rather than a
warning.
**Sensor coverage can straddle the split.** The score renormalises over available
sensors, so a training window predating a sensor's history freezes the threshold
on a different construct than the holdout is measured against. At the v3 cutover
only 39% of training sessions had all three Warning sensors versus 100% of the
test period, because credit history begins 2023-07-25.
Restricting the threshold to sensor-matched training sessions was tried and is
*not* the fix: those sessions are a calm recent stretch, so the threshold drops
from 32.3 to 22.5 and false alarms rise from 3.3 to 8.6 per year. It trades a
coverage bias for a regime-selection bias. The honest position is that the
threshold is hypersensitive to window choice at this sample size; the report
states its limits rather than pretending to a precision it does not have.
## Operator rule
Quadrant alerts default off for new/reset configurations. When enabled they
require fresh inputs, at least 75% coverage on both axes, two consecutive daily
confirmations, hysteresis, and cooldown. Every alert states: **Risk thermometer —
not a trade signal.**
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FundamentalsPanel harness</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body class="bg-[#0a0b11] text-gray-100 font-sans">
<div id="root"></div>
<script type="module" src="/src/dev/harness.tsx"></script>
</body>
</html>
+65
View File
@@ -4,6 +4,7 @@ import type {
AdminUser, AdminUser,
AlertConfig, AlertConfig,
AlertTestResult, AlertTestResult,
FundamentalsCutoverConfig,
PipelineReadiness, PipelineReadiness,
RecommendationConfig, RecommendationConfig,
ScheduleConfig, ScheduleConfig,
@@ -56,6 +57,18 @@ export function updateSetting(key: string, value: string) {
.then((r) => r.data); .then((r) => r.data);
} }
export function getFundamentalsCutoverSettings() {
return apiClient
.get<FundamentalsCutoverConfig>('admin/settings/fundamentals-cutover')
.then((r) => r.data);
}
export function updateFundamentalsCutoverSettings(enabled: boolean) {
return apiClient
.put<FundamentalsCutoverConfig>('admin/settings/fundamentals-cutover', { enabled })
.then((r) => r.data);
}
export function getRecommendationSettings() { export function getRecommendationSettings() {
return apiClient return apiClient
.get<RecommendationConfig>('admin/settings/recommendations') .get<RecommendationConfig>('admin/settings/recommendations')
@@ -233,6 +246,40 @@ export interface TriggerJobResponse {
cadence?: BacktestCadence; cadence?: BacktestCadence;
} }
export interface ParityFieldStats {
legacy_available: number;
candidate_available: number;
both_available: number;
material_differences: number;
median_absolute_delta: number | null;
p95_absolute_delta: number | null;
max_absolute_delta: number | null;
}
export interface FundamentalsParityReport {
report_version: number;
generated_at: string;
as_of_date: string;
approval_status: string;
read_only: boolean;
summary: {
universe_count: number;
legacy_fundamental_score_available: number;
candidate_fundamental_score_available: number;
fundamental_scores_compared: number;
fundamental_score_material_changes: number;
fundamental_rank_changes: number;
field_stats: Record<string, ParityFieldStats>;
};
source_runs: Record<string, {
run_id: number;
status: string;
revision: string | null;
source_max_date: string | null;
completed_at: string | null;
} | null>;
}
export type BacktestTargetModel = 'production_gtl' | 'structural_sr'; export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
export type BacktestCadence = 'weekly' | 'daily'; export type BacktestCadence = 'weekly' | 'daily';
@@ -259,6 +306,24 @@ export function triggerJob(
.then((r) => r.data); .then((r) => r.data);
} }
export function getFundamentalsParityReport() {
return apiClient
.get<FundamentalsParityReport | null>('admin/fundamentals-parity')
.then((r) => r.data);
}
export function getFundamentalsParityCsv() {
return apiClient
.get<{ filename: string; content: string } | null>('admin/fundamentals-parity/csv')
.then((r) => r.data);
}
export function getFundamentalsParityJson() {
return apiClient
.get<{ filename: string; content: string } | null>('admin/fundamentals-parity/json')
.then((r) => r.data);
}
// System events (operational warnings / errors) // System events (operational warnings / errors)
export interface SystemEvent { export interface SystemEvent {
id: number; id: number;
@@ -0,0 +1,176 @@
import {
useFundamentalsCutoverSettings,
useJobs,
useTriggerJob,
useUpdateFundamentalsCutoverSettings,
} from '../../hooks/useAdmin';
import { SkeletonCard } from '../ui/Skeleton';
const SEC_JOB = 'sec_fundamentals_import';
function formatRun(iso: string | null | undefined): string {
if (!iso) return 'not run in this process';
const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`;
}
export function FundamentalsCutoverSettings() {
const cutover = useFundamentalsCutoverSettings();
const update = useUpdateFundamentalsCutoverSettings();
const trigger = useTriggerJob();
const { data: jobs } = useJobs();
if (cutover.isLoading) return <SkeletonCard />;
if (cutover.isError || !cutover.data) {
return (
<p className="text-sm text-red-400">
{(cutover.error as Error)?.message || 'Failed to load fundamentals data source'}
</p>
);
}
const enabled = cutover.data.enabled;
const secJob = jobs?.find((job) => job.name === SEC_JOB);
const runningJob = jobs?.find((job) => job.running);
const refreshBlocked = Boolean(runningJob && runningJob.name !== SEC_JOB);
const changeSource = () => {
const next = !enabled;
const confirmed = window.confirm(
next
? 'Activate SEC + Dolt fundamentals? The next SEC import will replace the legacy cache and mark affected scores stale.'
: 'Pause SEC + Dolt cache refreshes? Existing cache values will stay in place; legacy values are not restored automatically.',
);
if (confirmed) update.mutate(next);
};
return (
<section className="glass overflow-hidden" aria-labelledby="fundamentals-source-title">
<div className={`h-0.5 ${enabled ? 'bg-gradient-to-r from-sky-500 via-cyan-300 to-emerald-400' : 'bg-white/[0.06]'}`} />
<div className="space-y-5 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h3 id="fundamentals-source-title" className="text-sm font-semibold text-gray-200">
Fundamentals data source
</h3>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${
enabled
? 'border-cyan-400/25 bg-cyan-400/10 text-cyan-300'
: 'border-white/10 bg-white/[0.04] text-gray-500'
}`}
>
{enabled ? 'SEC + Dolt active' : 'Legacy cache'}
</span>
</div>
<p className="mt-1 max-w-3xl text-xs leading-relaxed text-gray-500">
Controls what repopulates <span className="num text-gray-400">fundamental_data</span>, the
compatibility cache used by scoring. SEC filings supply P/E, growth and estimated market
cap; Dolt supplies earnings dates and surprises. Everything is derived locally from PostgreSQL.
</p>
</div>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_5rem_minmax(0,1fr)] items-center gap-3 rounded-xl border border-white/[0.06] bg-black/10 px-4 py-3">
<div className={enabled ? 'text-gray-600' : 'text-amber-200/90'}>
<div className="num text-[10px] uppercase tracking-[0.16em]">Legacy APIs</div>
<div className="mt-0.5 text-[11px]">FMP / Finnhub / Alpha Vantage</div>
</div>
<div className="relative h-px bg-white/10" aria-hidden="true">
<span
className={`absolute top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border-2 border-[#0e120f] transition-all duration-300 ${
enabled
? 'right-0 bg-cyan-300 shadow-[0_0_12px_rgba(103,232,249,0.55)]'
: 'left-0 bg-amber-300'
}`}
/>
</div>
<div className={`text-right ${enabled ? 'text-cyan-200' : 'text-gray-600'}`}>
<div className="num text-[10px] uppercase tracking-[0.16em]">SEC + Dolt</div>
<div className="mt-0.5 text-[11px]">Bulk imports PostgreSQL cache</div>
</div>
</div>
<div className="grid gap-4 border-t border-white/[0.06] pt-4 md:grid-cols-2">
<div className="flex items-start justify-between gap-4 rounded-xl bg-white/[0.025] p-3.5">
<div>
<div className="num text-[10px] uppercase tracking-[0.14em] text-gray-600">1 · Source</div>
<div className="mt-1 text-sm text-gray-200">Use SEC + Dolt for scoring inputs</div>
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">
While active, the weekly legacy collector is skipped so it cannot overwrite the new cache.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="Use SEC and Dolt fundamentals"
onClick={changeSource}
disabled={update.isPending}
className={`relative mt-1 inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-cyan-400/70 focus:ring-offset-2 focus:ring-offset-[#0e120f] disabled:cursor-wait disabled:opacity-50 ${
enabled ? 'bg-gradient-to-r from-sky-500 to-cyan-400' : 'bg-white/10'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform ${
enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
<div className="rounded-xl bg-white/[0.025] p-3.5">
<div className="num text-[10px] uppercase tracking-[0.14em] text-gray-600">2 · Refresh</div>
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm text-gray-200">Apply the source now</div>
<p className="mt-1 text-[11px] text-gray-500">
{secJob?.running
? 'SEC import and cache refresh are running.'
: secJob?.runtime_message || `Last SEC run: ${formatRun(secJob?.runtime_finished_at)}`}
</p>
</div>
<button
type="button"
onClick={() => trigger.mutate(SEC_JOB)}
disabled={
!enabled ||
trigger.isPending ||
Boolean(secJob?.running) ||
refreshBlocked ||
secJob?.enabled === false
}
className="btn-primary px-3 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-40"
>
<span>
{secJob?.running
? 'Refreshing…'
: trigger.isPending
? 'Starting…'
: refreshBlocked
? 'Another job is running'
: 'Run refresh now'}
</span>
</button>
</div>
{!enabled && (
<p className="mt-2 text-[11px] text-amber-300/70">Activate the source before running the refresh.</p>
)}
{enabled && secJob?.enabled === false && (
<p className="mt-2 text-[11px] text-amber-300/70">Enable the SEC Fundamentals job on the Jobs tab first.</p>
)}
</div>
</div>
<p className="text-[11px] leading-relaxed text-gray-600">
Rollback pauses future writes only. To restore pre-cutover values, use the database backup or
pause this source and manually run the legacy collector while its provider keys remain installed.
</p>
</div>
</section>
);
}
@@ -0,0 +1,156 @@
import { useState } from 'react';
import {
getFundamentalsParityCsv,
getFundamentalsParityJson,
} from '../../api/admin';
import { useFundamentalsParityReport } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
const FIELD_LABELS: Record<string, string> = {
pe_ratio: 'P/E',
revenue_growth: 'Revenue growth',
earnings_surprise: 'Earnings surprise',
};
function downloadText(filename: string, content: string, type: string) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
export function FundamentalsParityPanel() {
const { data: report, isLoading, isError, error } = useFundamentalsParityReport();
const [downloading, setDownloading] = useState(false);
if (isLoading) return <SkeletonTable rows={2} cols={4} />;
if (isError) {
return <p className="text-sm text-red-400">{(error as Error).message}</p>;
}
if (!report) {
return (
<div className="glass p-5">
<h3 className="text-sm font-semibold text-gray-200">A5 Fundamentals Parity</h3>
<p className="mt-1 text-xs text-gray-500">
No report yet. Trigger Fundamentals Parity Report (read-only) below.
</p>
</div>
);
}
const summary = report.summary;
const generated = new Date(report.generated_at).toLocaleString();
async function downloadCsv() {
setDownloading(true);
try {
const artifact = await getFundamentalsParityCsv();
if (artifact) downloadText(artifact.filename, artifact.content, 'text/csv;charset=utf-8');
} finally {
setDownloading(false);
}
}
async function downloadJson() {
setDownloading(true);
try {
const artifact = await getFundamentalsParityJson();
if (artifact) downloadText(artifact.filename, artifact.content, 'application/json;charset=utf-8');
} finally {
setDownloading(false);
}
}
return (
<div className="glass p-5 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-gray-200">A5 Fundamentals Parity</h3>
<span className="rounded-full border border-amber-400/20 bg-amber-400/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-amber-300">
approval pending
</span>
<span className="rounded-full border border-cyan-400/20 bg-cyan-400/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-cyan-300">
read-only
</span>
</div>
<p className="mt-1 text-xs text-gray-500">
Generated {generated} · as of {report.as_of_date} · {summary.universe_count} tracked tickers
</p>
</div>
<div className="flex gap-2">
<button
type="button"
className="rounded border border-white/10 px-3 py-1.5 text-xs text-gray-300 hover:text-white"
onClick={downloadJson}
disabled={downloading}
>
Download JSON
</button>
<button
type="button"
className="rounded border border-white/10 px-3 py-1.5 text-xs text-gray-300 hover:text-white disabled:opacity-50"
onClick={downloadCsv}
disabled={downloading}
>
{downloading ? 'Preparing…' : 'Download CSV'}
</button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Summary label="Candidate score coverage" value={`${summary.candidate_fundamental_score_available}/${summary.universe_count}`} />
<Summary label="Scores compared" value={summary.fundamental_scores_compared} />
<Summary label="Material score moves" value={summary.fundamental_score_material_changes} />
<Summary label="Fundamental rank moves" value={summary.fundamental_rank_changes} />
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="text-[10px] uppercase tracking-wider text-gray-500">
<tr>
<th className="pb-2 pr-4 font-medium">Field</th>
<th className="pb-2 px-3 font-medium">Legacy</th>
<th className="pb-2 px-3 font-medium">Candidate</th>
<th className="pb-2 px-3 font-medium">Compared</th>
<th className="pb-2 px-3 font-medium">Material</th>
<th className="pb-2 pl-3 font-medium">Median |Δ|</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06] text-gray-300">
{Object.entries(summary.field_stats).map(([key, stats]) => (
<tr key={key}>
<td className="py-2.5 pr-4">{FIELD_LABELS[key] ?? key}</td>
<td className="py-2.5 px-3 num">{stats.legacy_available}</td>
<td className="py-2.5 px-3 num">{stats.candidate_available}</td>
<td className="py-2.5 px-3 num">{stats.both_available}</td>
<td className="py-2.5 px-3 num">{stats.material_differences}</td>
<td className="py-2.5 pl-3 num">
{stats.median_absolute_delta == null ? 'n/a' : stats.median_absolute_delta.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-[11px] leading-relaxed text-gray-500">
Materiality bands highlight review candidates only. They do not approve a cutover or write fundamentals,
scores, rankings, or qualification state.
</p>
</div>
);
}
function Summary({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-white/[0.07] bg-white/[0.025] px-3 py-2.5">
<div className="text-[10px] uppercase tracking-wider text-gray-500">{label}</div>
<div className="mt-1 num text-lg text-gray-200">{value}</div>
</div>
);
}
@@ -25,7 +25,7 @@ function formatAgo(iso: string | null | undefined): string {
function lastRunColor(status: string | null | undefined): string { function lastRunColor(status: string | null | undefined): string {
if (status === 'error') return 'text-red-300'; if (status === 'error') return 'text-red-300';
if (status === 'rate_limited') return 'text-amber-300'; if (status === 'rate_limited' || status === 'deferred') return 'text-amber-300';
return 'text-gray-500'; return 'text-gray-500';
} }
@@ -127,7 +127,7 @@ export function JobControls() {
className={`text-[11px] font-medium ${ className={`text-[11px] font-medium ${
job.running job.running
? 'text-blue-300' ? 'text-blue-300'
: job.runtime_status === 'rate_limited' : job.runtime_status === 'rate_limited' || job.runtime_status === 'deferred'
? 'text-amber-300' ? 'text-amber-300'
: job.runtime_status === 'error' : job.runtime_status === 'error'
? 'text-red-300' ? 'text-red-300'
@@ -140,6 +140,8 @@ export function JobControls() {
? 'Running' ? 'Running'
: job.runtime_status === 'rate_limited' : job.runtime_status === 'rate_limited'
? 'Paused (rate-limited)' ? 'Paused (rate-limited)'
: job.runtime_status === 'deferred'
? 'Deferred (retrying)'
: job.runtime_status === 'error' : job.runtime_status === 'error'
? 'Last run error' ? 'Last run error'
: job.enabled : job.enabled
@@ -6,10 +6,13 @@ import { SkeletonTable } from '../ui/Skeleton';
const DEFAULTS: ScheduleConfig = { const DEFAULTS: ScheduleConfig = {
schedule_timezone: 'America/New_York', schedule_timezone: 'America/New_York',
schedule_daily_pipeline_cron: '0 2 * * *', schedule_daily_pipeline_cron: '0 2 * * *',
schedule_near_close_pipeline_cron: '30 15 * * 1-5', schedule_dolt_earnings_cron: '30 2 * * *',
schedule_after_close_pipeline_cron: '45 16 * * 1-5', schedule_sec_fundamentals_cron: '0 4 * * *',
schedule_intraday_pipeline_cron: '0 10-15 * * 1-5', schedule_fundamentals_parity_cron: '30 5 * * *',
schedule_fundamentals_cron: '0 1 * * 1', schedule_near_close_pipeline_cron: '30 15 * * mon-fri',
schedule_after_close_pipeline_cron: '45 16 * * mon-fri',
schedule_intraday_pipeline_cron: '0 10-15 * * mon-fri',
schedule_fundamentals_cron: '0 1 * * mon',
}; };
const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: boolean }[] = [ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: boolean }[] = [
@@ -24,6 +27,24 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
hint: 'OHLCV → benchmark → sentiment → regime → alerts (no R:R scan). Default 02:00 ET so regime-quadrant changes hit Telegram in the morning.', hint: 'OHLCV → benchmark → sentiment → regime → alerts (no R:R scan). Default 02:00 ET so regime-quadrant changes hit Telegram in the morning.',
mono: true, mono: true,
}, },
{
key: 'schedule_dolt_earnings_cron',
label: 'Dolt earnings',
hint: 'Pull and import earnings dates/results daily at 02:30 ET. The activated cache refresh uses these local events.',
mono: true,
},
{
key: 'schedule_sec_fundamentals_cron',
label: 'SEC fundamentals',
hint: 'Import tracked-universe SEC facts daily at 04:00 ET and refresh the scoring cache when the cutover is active.',
mono: true,
},
{
key: 'schedule_fundamentals_parity_cron',
label: 'Fundamentals parity report',
hint: 'Read-only legacy vs SEC/Dolt comparison daily at 05:30 ET, after the bulk imports.',
mono: true,
},
{ {
key: 'schedule_near_close_pipeline_cron', key: 'schedule_near_close_pipeline_cron',
label: 'Near-close pipeline (scan + alert)', label: 'Near-close pipeline (scan + alert)',
@@ -44,8 +65,8 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
}, },
{ {
key: 'schedule_fundamentals_cron', key: 'schedule_fundamentals_cron',
label: 'Fundamentals (weekly)', label: 'Legacy fundamentals (weekly)',
hint: 'Slow, rate-limited. Default early Monday ET.', hint: 'Fallback provider chain. Automatically skipped while the SEC + Dolt cutover is active.',
mono: true, mono: true,
}, },
]; ];
@@ -3,6 +3,8 @@ import { useSettings, useUpdateSetting } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton'; import { SkeletonTable } from '../ui/Skeleton';
import type { SystemSetting } from '../../lib/types'; import type { SystemSetting } from '../../lib/types';
const MANAGED_SETTINGS = new Set(['fundamental_data_sec_dolt_cutover_enabled']);
export function SettingsForm() { export function SettingsForm() {
const { data: settings, isLoading, isError, error } = useSettings(); const { data: settings, isLoading, isError, error } = useSettings();
const updateSetting = useUpdateSetting(); const updateSetting = useUpdateSetting();
@@ -32,10 +34,11 @@ export function SettingsForm() {
if (isLoading) return <SkeletonTable rows={4} cols={2} />; if (isLoading) return <SkeletonTable rows={4} cols={2} />;
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load settings'}</p>; if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load settings'}</p>;
if (!settings || settings.length === 0) return <p className="text-sm text-gray-500">No settings found.</p>; if (!settings || settings.length === 0) return <p className="text-sm text-gray-500">No settings found.</p>;
const visibleSettings = settings.filter((setting) => !MANAGED_SETTINGS.has(setting.key));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{settings.map((setting) => ( {visibleSettings.map((setting) => (
<div key={setting.key} className="glass p-4 flex flex-wrap items-center gap-3 glass-hover"> <div key={setting.key} className="glass p-4 flex flex-wrap items-center gap-3 glass-hover">
<label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label> <label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label>
{setting.key === 'registration' ? ( {setting.key === 'registration' ? (
+12 -9
View File
@@ -378,16 +378,15 @@ export function TradeChart({
// it wanders left as more post-entry bars arrive. // it wanders left as more post-entry bars arrive.
const WINDOW = 21; const WINDOW = 21;
const MID = 10; const MID = 10;
let start: number; const start = postCount <= MID + 1
let entryIdx: number; ? Math.max(0, entryAbs - MID)
if (postCount <= MID + 1) {
start = Math.max(0, entryAbs - MID);
entryIdx = entryAbs - start;
} else {
// Enough history: keep the latest WINDOW bars; entry falls where it falls. // Enough history: keep the latest WINDOW bars; entry falls where it falls.
start = Math.max(0, bars.length - WINDOW); : Math.max(0, bars.length - WINDOW);
entryIdx = entryAbs - start; // A trade older than the window entered before the first visible bar. Clamp to
} // the left edge — a negative index reads past the start of `series`/`stopPath`
// and NaNs out the price and trail paths entirely.
const entryBeforeWindow = entryAbs < start;
const entryIdx = Math.max(0, entryAbs - start);
const windowBars = bars.slice(start); const windowBars = bars.slice(start);
const series = windowBars.map((b) => b.close); const series = windowBars.map((b) => b.close);
if (series.length < 2) return null; if (series.length < 2) return null;
@@ -601,7 +600,11 @@ export function TradeChart({
{entryIdx === lastIdx && ( {entryIdx === lastIdx && (
<circle cx={px(entryIdx)} cy={py(series[entryIdx])} r="2" fill="var(--ink-3)" /> <circle cx={px(entryIdx)} cy={py(series[entryIdx])} r="2" fill="var(--ink-3)" />
)} )}
{/* Entry marker only when the entry bar is actually in the window for an
older trade the entry level line carries it instead. */}
{!entryBeforeWindow && (
<circle cx={px(entryIdx)} cy={py(entry)} r="3.5" fill="var(--ink-2)" stroke="var(--surface)" strokeWidth="1.5" /> <circle cx={px(entryIdx)} cy={py(entry)} r="3.5" fill="var(--ink-2)" stroke="var(--surface)" strokeWidth="1.5" />
)}
<circle cx={px(lastIdx)} cy={py(series[lastIdx])} r="4" fill={nowCol} stroke="var(--surface)" strokeWidth="2" /> <circle cx={px(lastIdx)} cy={py(series[lastIdx])} r="4" fill={nowCol} stroke="var(--surface)" strokeWidth="2" />
</svg> </svg>
); );
@@ -22,6 +22,63 @@ function pnlColor(v: number): string {
return 'text-gray-300'; return 'text-gray-300';
} }
function maxHoldText(trade: PaperTrade): string | null {
const remaining = trade.sessions_remaining;
if (remaining == null) return null;
const held = trade.sessions_held ?? 0;
if (remaining < 0) return `${held} held · past max hold`;
if (remaining === 0) return `${held} held · max hold reached`;
return `${held} held · ${remaining} remaining`;
}
function maxHoldColor(trade: PaperTrade): string {
const remaining = trade.sessions_remaining;
if (remaining == null) return 'text-gray-400';
const holdDays = Math.max(1, (trade.sessions_held ?? 0) + remaining);
const warningAt = Math.max(1, Math.ceil(holdDays * 0.2));
return remaining <= warningAt ? 'text-amber-300' : 'text-gray-400';
}
/** Quiet secondary telemetry below the R bar. Exact timing stays in the
* expanded row; this only communicates how far through max hold the trade is. */
function HoldProgress({ trade }: { trade: PaperTrade }) {
const held = trade.sessions_held;
const remaining = trade.sessions_remaining;
if (held == null || remaining == null) return null;
const total = Math.max(1, held + Math.max(0, remaining));
const elapsedPct = remaining <= 0
? 100
: Math.min(100, Math.max(0, (held / total) * 100));
const warningAt = Math.max(1, Math.ceil(total * 0.2));
const urgent = remaining <= warningAt;
const color = urgent ? 'bg-amber-400/75' : 'bg-sky-400/40';
return (
<div
className="relative h-[3px] rounded-full bg-white/[0.06]"
role="progressbar"
aria-label="Holding period"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={Math.min(held, total)}
aria-valuetext={remaining < 0
? `${held} sessions held, past maximum hold`
: `${held} sessions held, ${remaining} remaining`}
title="Holding-period progress — click for the exact session count"
>
<span
className={`absolute inset-y-0 left-0 rounded-full ${color}`}
style={{ width: `${elapsedPct}%` }}
/>
<span
className={`absolute top-1/2 h-[5px] w-[2px] -translate-x-1/2 -translate-y-1/2 rounded-full ${color}`}
style={{ left: `${elapsedPct}%` }}
/>
</div>
);
}
function DirTag({ direction }: { direction: string }) { function DirTag({ direction }: { direction: string }) {
const isLong = direction === 'long'; const isLong = direction === 'long';
return ( return (
@@ -46,10 +103,22 @@ function Detail({ label, value, valueClass = 'text-gray-100' }: {
); );
} }
function Fact({ label, value, valueClass = 'text-gray-300' }: {
label: string;
value: ReactNode;
valueClass?: string;
}) {
return (
<span className="num inline-flex items-baseline gap-1.5 whitespace-nowrap">
<span className="text-[9px] uppercase tracking-[0.14em] text-gray-600">{label}</span>
<span className={`text-[11px] ${valueClass}`}>{value}</span>
</span>
);
}
/** Expanded row: full trade detail + price chart with entry / trail path. */ /** Expanded row: full trade detail + price chart with entry / trail path. */
function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, onClose, closing }: { function TradeDetail({ trade, exitMode, atrMultiplier, trailingPct, onClose, closing }: {
trade: PaperTrade; trade: PaperTrade;
exitLabel: string | null;
exitMode: 'time' | 'trailing' | 'atr_trailing' | 'target'; exitMode: 'time' | 'trailing' | 'atr_trailing' | 'target';
atrMultiplier: number; atrMultiplier: number;
trailingPct: number; trailingPct: number;
@@ -66,30 +135,28 @@ function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, o
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
const opened = new Date(trade.opened_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const opened = new Date(trade.opened_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const holdText = maxHoldText(trade);
const exitRuleText = exitMode === 'atr_trailing'
? `${atrMultiplier.toFixed(1)}× ATR trail`
: exitMode === 'trailing'
? `${Math.round(trailingPct)}% trailing stop`
: exitMode === 'target'
? 'target / stop'
: null;
const chartHint = trailMoved || exitMode === 'atr_trailing' || exitMode === 'trailing' const chartHint = trailMoved || exitMode === 'atr_trailing' || exitMode === 'trailing'
? 'entry · now · stop · trail · gate' ? 'entry · now · stop · trail · gate'
: 'entry · now · stop · gate'; : 'entry · now · stop · gate';
return ( return (
<div className="flex flex-col gap-4 px-2 pb-4 pt-1"> <div className="flex flex-col gap-4 px-2 pb-4 pt-1">
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4"> <dl className="grid grid-cols-2 gap-x-8 gap-y-3 md:grid-cols-4 xl:grid-cols-2">
<Detail label="opened" value={`${opened} · ${trade.shares} shares`} />
<Detail label="entry → now" value={
`${formatPrice(trade.entry_price)}${trade.current_price != null ? formatPrice(trade.current_price) : '—'}`
} />
<Detail <Detail
label="P&L" label="P&L"
value={p ? `${money(p.pnl)} · ${p.pct >= 0 ? '+' : ''}${p.pct.toFixed(1)}%` : '—'} value={p ? `${money(p.pnl)} · ${p.pct >= 0 ? '+' : ''}${p.pct.toFixed(1)}%` : '—'}
valueClass={p ? pnlColor(p.pnl) : 'text-gray-500'} valueClass={p ? pnlColor(p.pnl) : 'text-gray-500'}
/> />
<Detail <Detail label="entry → now" value={
label="alpha vs SPY" `${formatPrice(trade.entry_price)}${trade.current_price != null ? formatPrice(trade.current_price) : '—'}`
value={ } />
trade.alpha_pct != null
? `${trade.alpha_pct >= 0 ? '+' : ''}${trade.alpha_pct.toFixed(1)}%${trade.alpha_usd != null ? ` · ${money(trade.alpha_usd)}` : ''}`
: '—'
}
valueClass={trade.alpha_pct != null ? pnlColor(trade.alpha_pct) : 'text-gray-500'}
/>
<Detail <Detail
label={trailMoved ? 'trail' : 'stop'} label={trailMoved ? 'trail' : 'stop'}
value={ value={
@@ -105,27 +172,36 @@ function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, o
} }
/> />
<Detail <Detail
label="target" label="alpha vs SPY"
value={
trade.alpha_pct != null
? `${trade.alpha_pct >= 0 ? '+' : ''}${trade.alpha_pct.toFixed(1)}%${trade.alpha_usd != null ? ` · ${money(trade.alpha_usd)}` : ''}`
: '—'
}
valueClass={trade.alpha_pct != null ? pnlColor(trade.alpha_pct) : 'text-gray-500'}
/>
</dl>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-white/[0.06] pt-3">
<Fact label="position" value={`${trade.shares} shares`} />
<Fact
label="holding"
value={ value={
<> <>
{formatPrice(trade.target)} opened {opened}
{exitMode !== 'target' && ( {holdText && <span className={maxHoldColor(trade)}> · {holdText}</span>}
<span className="ml-1.5 text-[10px] text-gray-500">screening only</span>
)}
</> </>
} }
/> />
<Detail label="exit rule" value={exitLabel ?? 'target/stop'} /> <Fact label="screening target" value={formatPrice(trade.target)} />
<div className="flex items-end"> {exitRuleText && <Fact label="exit" value={exitRuleText} />}
<button <button
onClick={onClose} onClick={onClose}
disabled={closing} disabled={closing}
className="rounded-md border border-white/[0.1] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.06] hover:text-white disabled:opacity-50" className="ml-auto rounded-md border border-white/[0.1] px-3 py-1.5 text-[11px] text-gray-300 transition-colors hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
> >
Sell at market Sell at market
</button> </button>
</div> </div>
</dl>
{ohlcv.data && ( {ohlcv.data && (
<div> <div>
<p className="num text-[9.5px] uppercase tracking-[0.16em] text-gray-500"> <p className="num text-[9.5px] uppercase tracking-[0.16em] text-gray-500">
@@ -173,13 +249,14 @@ export function OpenTradesPanel() {
const trailingPct = policy?.trailing_pct ?? 12; const trailingPct = policy?.trailing_pct ?? 12;
const exitLabel = policy const exitLabel = policy
? policy.mode === 'atr_trailing' ? policy.mode === 'atr_trailing'
? `${atrMultiplier.toFixed(1)}x ATR trailing stop / ${policy.hold_days}d max` ? `${atrMultiplier.toFixed(1)}x ATR trailing stop / ${policy.hold_days} sessions max`
: policy.mode === 'trailing' : policy.mode === 'trailing'
? `trailing ${Math.round(trailingPct)}%` ? `trailing ${Math.round(trailingPct)}%`
: policy.mode === 'time' : policy.mode === 'time'
? `${policy.hold_days}d hold` ? `${policy.hold_days}-session hold`
: 'target/stop' : 'target/stop'
: null; : null;
const hasMaxHold = exitMode === 'atr_trailing' || exitMode === 'time';
const rows = trades ?? []; const rows = trades ?? [];
@@ -245,7 +322,10 @@ export function OpenTradesPanel() {
<span className="num hidden text-xs text-gray-400 sm:block"> <span className="num hidden text-xs text-gray-400 sm:block">
{formatPrice(t.entry_price)} {t.current_price != null ? formatPrice(t.current_price) : '—'} {formatPrice(t.entry_price)} {t.current_price != null ? formatPrice(t.current_price) : '—'}
</span> </span>
<div className={`min-w-0 ${hasMaxHold ? 'space-y-1.5' : ''}`}>
<RBar r={p?.r ?? null} max={rMax} /> <RBar r={p?.r ?? null} max={rMax} />
{hasMaxHold && <HoldProgress trade={t} />}
</div>
<span className={`num text-right text-[13px] font-semibold ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}> <span className={`num text-right text-[13px] font-semibold ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'} {p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
</span> </span>
@@ -256,7 +336,6 @@ export function OpenTradesPanel() {
{open && ( {open && (
<TradeDetail <TradeDetail
trade={t} trade={t}
exitLabel={exitLabel}
exitMode={exitMode} exitMode={exitMode}
atrMultiplier={atrMultiplier} atrMultiplier={atrMultiplier}
trailingPct={trailingPct} trailingPct={trailingPct}
@@ -1,157 +1,401 @@
import { useMemo, useState } from 'react'; import { useMemo, type ReactNode } from 'react';
import { formatPercent, formatLargeNumber } from '../../lib/format'; import type {
import { EarningsRecent,
fundamentalScore, FundamentalResponse,
metricStatus, MetricItem,
overallFundamentalStatus, MetricIndustry,
} from '../../lib/fundamentals'; } from '../../lib/types';
import type { FundamentalResponse } from '../../lib/types';
interface FundamentalsPanelProps { interface FundamentalsPanelProps {
data: FundamentalResponse; data: FundamentalResponse;
} }
const FIELD_LABELS: Record<string, string> = { /** Favorable / neutral / adverse — always paired with the read text. */
pe_ratio: 'P/E Ratio', type Tone = 'good' | 'flat' | 'bad';
revenue_growth: 'Revenue Growth',
earnings_surprise: 'Earnings Surprise',
market_cap: 'Market Cap',
};
type MetricKey = 'pe_ratio' | 'revenue_growth' | 'earnings_surprise' | 'market_cap'; // Horizon tokens.
const HZ = {
text: '#EDEEF3',
muted: '#9AA0B0',
track: '#5D6373',
fav: '#6EC9DB', // cyan
adv: '#EF9182', // coral
};
const toneColor = (t: Tone) => (t === 'good' ? HZ.fav : t === 'bad' ? HZ.adv : HZ.muted);
const POSITIVE_READS = new Set([
'accelerating', 'improving', 'above peers', 'above own average', 'buying back',
'attractively valued', 'conservative leverage',
]);
const NEGATIVE_READS = new Set([
'decelerating', 'deteriorating', 'priced above peers', 'elevated leverage', 'below peers',
]);
function readTone(read: string | null | undefined): Tone {
if (!read) return 'flat';
if (POSITIVE_READS.has(read)) return 'good';
if (NEGATIVE_READS.has(read)) return 'bad';
if (read.includes('dilution')) return 'bad';
return 'flat';
}
function pct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return 'n/a';
return `${Math.round(v * 10) / 10}%`;
}
function mult(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return 'n/a';
return `${v.toFixed(1)}×`;
}
function money(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return 'n/a';
const abs = Math.abs(v);
if (abs >= 1e12) return `$${(v / 1e12).toFixed(1)}T`;
if (abs >= 1e9) return `$${(v / 1e9).toFixed(1)}B`;
if (abs >= 1e6) return `$${(v / 1e6).toFixed(1)}M`;
return `$${v.toFixed(0)}`;
}
function signedPp(v: number): string {
return `${v >= 0 ? '+' : ''}${Math.round(v * 10) / 10}pp`;
}
function capitalize(s: string): string {
return s.length ? s[0].toUpperCase() + s.slice(1) : s;
}
function finiteOrNull(v: number | null | undefined): number | null {
return v != null && Number.isFinite(v) ? v : null;
}
function latestHistory(metric: MetricItem | undefined): number[] {
const run: number[] = [];
const history = metric?.history ?? [];
for (let i = history.length - 1; i >= 0; i -= 1) {
const value = finiteOrNull(history[i].value);
if (value == null) break;
run.unshift(value);
}
return run;
}
/** Parse YYYY-MM-DD as a LOCAL calendar date (no UTC off-by-one west of UTC). */
function parseLocalDate(s: string): Date {
const [y, m, d] = s.split('-').map(Number);
return new Date(y, (m ?? 1) - 1, d ?? 1);
}
function shortDate(s: string): string {
return parseLocalDate(s).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export function FundamentalsPanel({ data }: FundamentalsPanelProps) { export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
const [expanded, setExpanded] = useState<boolean>(false); const metrics = useMemo(
() => Object.fromEntries((data.metrics ?? []).map((m) => [m.key, m])),
[data.metrics],
) as Record<string, MetricItem | undefined>;
const reads = data.reads?.by_key ?? {};
const val = data.valuation;
const earnings = data.earnings;
const provenance = (data.metrics ?? []).find((m) => m.period_end) ?? null;
const score = useMemo( const hasAny = (data.metrics ?? []).some((m) => m.value != null) || !!val || !!earnings?.next
() => || (earnings?.recent?.length ?? 0) > 0;
fundamentalScore({
pe_ratio: data.pe_ratio,
revenue_growth: data.revenue_growth,
earnings_surprise: data.earnings_surprise,
}),
[data.pe_ratio, data.revenue_growth, data.earnings_surprise],
);
const overall = overallFundamentalStatus(score);
const items: { const trendRows: { key: string; label: string; kind: 'growth' | 'margin' | 'share' }[] = [
key: MetricKey; { key: 'revenue_growth_yoy', label: 'Revenue growth', kind: 'growth' },
label: string; { key: 'eps_growth_yoy', label: 'EPS growth', kind: 'growth' },
value: number | null; { key: 'operating_margin', label: 'Operating margin', kind: 'margin' },
format: (v: number) => string; { key: 'fcf_margin', label: 'FCF margin', kind: 'margin' },
}[] = [ { key: 'share_count_change_yoy', label: 'Share count YoY', kind: 'share' },
{ key: 'pe_ratio', label: 'P/E Ratio', value: data.pe_ratio, format: (v) => v.toFixed(2) },
{ key: 'revenue_growth', label: 'Revenue Growth', value: data.revenue_growth, format: formatPercent },
{ key: 'earnings_surprise', label: 'Earnings Surprise', value: data.earnings_surprise, format: formatPercent },
{ key: 'market_cap', label: 'Market Cap', value: data.market_cap, format: formatLargeNumber },
]; ];
const unavailableEntries = Object.entries(data.unavailable_fields ?? {}); const valueRows: {
label: string; value: number | null; industry: MetricIndustry | null;
readKey: string; fmt: (v: number | null) => string;
}[] = [
{ label: 'Net debt / EBITDA', value: metrics.net_debt_to_ebitda?.value ?? null,
industry: metrics.net_debt_to_ebitda?.industry ?? null, readKey: 'net_debt_to_ebitda', fmt: mult },
{ label: 'P/E', value: val?.pe ?? null, industry: val?.pe_industry ?? null, readKey: 'pe', fmt: mult },
{ label: 'FCF yield', value: val?.fcf_yield ?? null, industry: val?.fcf_yield_industry ?? null,
readKey: 'fcf_yield', fmt: pct },
];
return ( return (
<div className="glass p-5"> <section className="glass p-5" aria-label="Fundamentals">
<div className="mb-3 flex items-baseline justify-between gap-2"> <h3 className="text-[10px] font-medium uppercase tracking-widest" style={{ color: HZ.muted }}>Fundamentals</h3>
<h3 className="text-xs font-medium uppercase tracking-widest text-gray-500">Fundamentals</h3> {data.reads?.header ? (
{score != null && ( <p className="mt-0.5 text-[15px] leading-snug" style={{ color: HZ.text }}>
<span className="num text-[10px] text-gray-600" title="Equal average of available P/E, growth, and surprise (need 2+)"> {capitalize(data.reads.header)}
score {score.toFixed(0)}
</span>
)}
</div>
<p className={`text-sm font-semibold ${overall.tone}`}>{overall.text}</p>
<div className="mt-3 space-y-3 text-sm">
{items.map((item) => {
const reason = data.unavailable_fields?.[item.key];
const status = item.value !== null ? metricStatus(item.key, item.value) : null;
let display: React.ReactNode;
let valueClass = 'text-gray-200';
if (item.value !== null) {
display = item.format(item.value);
} else if (reason) {
display = reason;
valueClass = 'text-amber-400';
} else {
display = '—';
}
return (
<div key={item.key} className="flex items-start justify-between gap-3">
<span className="text-gray-400">{item.label}</span>
<div className="min-w-0 text-right">
<div className={`num ${valueClass}`}>{display}</div>
{status && (
<div className={`mt-0.5 text-[11.5px] font-medium ${status.tone}`}>{status.text}</div>
)}
</div>
</div>
);
})}
</div>
<p className="mt-4 text-[11px] leading-relaxed text-gray-500">
Score = average of available P/E, revenue growth, and earnings surprise (need 2+).
{' '}P/E: lower scores higher (15 best, 45 worst).
{' '}Growth / surprise: 0% is neutral; stronger positives lift the score.
{' '}Market cap is size context only not scored.
</p> </p>
) : !hasAny ? (
<p className="mt-1 text-[15px] leading-snug" style={{ color: HZ.muted }}>
No fundamentals reported yet.
</p>
) : null}
<button {hasAny && (
type="button" <>
onClick={() => setExpanded((prev) => !prev)} <EarningsStrip earnings={earnings} />
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-300"
aria-expanded={expanded}
aria-label={expanded ? 'Collapse details' : 'Expand details'}
>
<svg
className={`h-4 w-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded && ( <div className="mt-4 grid gap-x-8 gap-y-5 sm:grid-cols-2">
<div className="mt-3 space-y-3 border-t border-white/10 pt-3">
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Data Source</span>
<span className="text-gray-300">FMP</span>
</div>
{data.fetched_at && (
<div className="flex justify-between">
<span className="text-gray-500">Fetched</span>
<span className="text-gray-300">{new Date(data.fetched_at).toLocaleString()}</span>
</div>
)}
</div>
{unavailableEntries.length > 0 && (
<div> <div>
<span className="text-xs font-medium uppercase tracking-widest text-gray-500">Unavailable Fields</span> <SectionHead label="Operating trend" axis="less favorable ← ref → more favorable" />
<ul className="mt-1 space-y-1"> <div className="mt-2.5 space-y-3.5">
{unavailableEntries.map(([field, reason]) => ( {trendRows.map((r) => (
<li key={field} className="flex justify-between text-sm"> <TrendRow key={r.key} label={r.label} kind={r.kind}
<span className="text-gray-400">{FIELD_LABELS[field] ?? field}</span> metric={metrics[r.key]} read={reads[r.key]}
<span className="text-amber-400">{reason}</span> caveat={metrics[r.key]?.caveat} />
</li>
))} ))}
</ul>
</div> </div>
)}
</div> </div>
)} <div>
<SectionHead label="Valuation & balance" axis="less favorable ← median → more favorable" />
<div className="mt-2.5 space-y-3.5">
{valueRows.map((r) => (
<ValueRow key={r.label} {...r} read={reads[r.readKey]} />
))}
</div>
</div>
</div>
{!expanded && data.fetched_at && ( <Provenance provenance={provenance} priceDate={val?.price_date ?? null}
<p className="mt-2 text-xs text-gray-500"> marketCap={val?.market_cap_est ?? null} />
Updated {new Date(data.fetched_at).toLocaleDateString()} </>
</p>
)} )}
</section>
);
}
function SectionHead({ label, axis }: { label: string; axis: string }) {
return (
<div className="flex flex-wrap items-baseline justify-between gap-x-2">
<span className="text-[10px] font-medium uppercase tracking-widest" style={{ color: HZ.muted }}>{label}</span>
<span className="text-[11px]" style={{ color: HZ.track }}>{axis}</span>
</div> </div>
); );
} }
function Bullet({ label, value, rail, comparison }: {
label: string; value: ReactNode; rail: ReactNode | null; comparison: ReactNode;
}) {
return (
<div>
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm" style={{ color: HZ.muted }}>{label}</span>
<span className="num text-[15px]" style={{ color: HZ.text }}>{value}</span>
</div>
{rail && <div className="mt-1.5">{rail}</div>}
<div className={rail ? 'mt-1 text-[11.5px] leading-snug' : 'mt-1.5 text-[11.5px] leading-snug'}>
{comparison}
</div>
</div>
);
}
// ---- operating-trend row (delta vs reference, favorable = right) ------------
function TrendRow({ label, kind, metric, read, caveat }: {
label: string; kind: 'growth' | 'margin' | 'share';
metric: MetricItem | undefined; read: string | null | undefined;
caveat: string | null | undefined;
}) {
const tone = readTone(read);
const value = finiteOrNull(metric?.value);
const history = latestHistory(metric);
let ref: number | null = null;
let refWord = '';
let halfRange = 8;
let neutral = 2;
let favSign = 1; // +1: higher is favorable; -1: lower is favorable
if (kind === 'growth') {
ref = history.length >= 2 ? history[history.length - 2] : null;
refWord = 'prior'; halfRange = 8; neutral = 2; favSign = 1;
} else if (kind === 'margin') {
const prior = history.slice(0, -1);
ref = prior.length ? prior.reduce((a, b) => a + b, 0) / prior.length : null;
refWord = 'avg'; halfRange = 4; neutral = 1; favSign = 1;
} else {
ref = 0; refWord = ''; halfRange = 5; neutral = 1; favSign = -1; // buyback (negative) is favorable
}
const delta = value != null && ref != null ? value - ref : null;
const comparison = caveat ? (
<span style={{ color: HZ.muted }}>{caveat}</span>
) : value == null ? (
<span style={{ color: HZ.track }}>n/a</span>
) : delta == null ? (
<span style={{ color: HZ.track }}>history n/a</span>
) : (
<span style={{ color: toneColor(tone) }}>
{kind !== 'share' && (
<span className="num">{signedPp(delta)} vs {refWord} · </span>
)}
{read ?? '—'}
</span>
);
const rail = delta != null
? <DeltaRail favOffset={favSign * delta} halfRange={halfRange} neutral={neutral} tone={tone}
ariaLabel={`${label} ${pct(value)}, ${delta != null ? `${signedPp(delta)} vs reference` : ''}, ${read ?? 'no read'}`} />
: null;
return <Bullet label={label} value={pct(value)} rail={rail} comparison={comparison} />;
}
/** Comparison rail centered on a reference line (not a progress bar). favOffset > 0
* is favorable and moves the dot RIGHT for every metric. */
function DeltaRail({ favOffset, halfRange, neutral, tone, ariaLabel }: {
favOffset: number; halfRange: number; neutral: number; tone: Tone; ariaLabel: string;
}) {
const clamped = Math.max(-halfRange, Math.min(halfRange, favOffset));
const pos = 50 + (clamped / halfRange) * 50;
const barLeft = Math.min(50, pos);
const barWidth = Math.abs(pos - 50);
const bandHalf = (neutral / halfRange) * 50;
return (
<span className="relative block h-1.5 min-w-[7rem] flex-1 rounded-full" role="img" aria-label={ariaLabel}
style={{ background: 'rgba(93,99,115,0.22)' }}>
<span className="absolute inset-y-0 rounded-full" aria-hidden
style={{ left: `${50 - bandHalf}%`, width: `${2 * bandHalf}%`, background: 'rgba(93,99,115,0.4)' }} />
<span className="absolute inset-y-[-2px] w-px" aria-hidden style={{ left: '50%', background: 'rgba(237,238,243,0.55)' }} />
<span className="absolute inset-y-0 rounded-full" aria-hidden style={{ left: `${barLeft}%`, width: `${barWidth}%`, background: toneColor(tone) }} />
<Dot pos={pos} tone={tone} />
</span>
);
}
// ---- valuation/balance row (favorable percentile vs median) ----------------
function ValueRow({ label, value, industry, read, fmt }: {
label: string; value: number | null; industry: MetricIndustry | null;
read: string | null | undefined; fmt: (v: number | null) => string;
}) {
const tone = readTone(read);
const safeValue = finiteOrNull(value);
const rail = safeValue == null || !industry
? null
: <PercentileRail percentile={industry.favorable_percentile} tone={tone}
ariaLabel={`${label}: ${industry.favorable_percentile}th favorable percentile vs ${industry.label}, median ${fmt(industry.median)}, ${industry.peer_count} peers`} />;
const comparison = safeValue == null ? (
<span style={{ color: HZ.track }}>n/a</span>
) : industry ? (
<span style={{ color: toneColor(tone) }}>
{read ?? 'in line'}<span style={{ color: HZ.muted }}> · median {fmt(industry.median)} · {industry.peer_count} peers</span>
</span>
) : (
<span style={{ color: HZ.track }}>peers n/a</span>
);
return <Bullet label={label} value={fmt(safeValue)} rail={rail} comparison={comparison} />;
}
/** 0-100 favorable-percentile rail with the peer median fixed at 50; right = more favorable. */
function PercentileRail({ percentile, tone, ariaLabel }: {
percentile: number; tone: Tone; ariaLabel: string;
}) {
const p = Math.max(0, Math.min(100, percentile));
const barLeft = Math.min(50, p);
const barWidth = Math.abs(p - 50);
return (
<span className="relative block h-1.5 min-w-[7rem] flex-1 rounded-full" role="img" aria-label={ariaLabel}
style={{ background: 'rgba(93,99,115,0.22)' }}>
<span className="absolute inset-y-[-2px] w-px" aria-hidden style={{ left: '50%', background: 'rgba(237,238,243,0.55)' }} />
<span className="absolute inset-y-0 rounded-full" aria-hidden style={{ left: `${barLeft}%`, width: `${barWidth}%`, background: toneColor(tone) }} />
<Dot pos={p} tone={tone} />
</span>
);
}
function Dot({ pos, tone }: { pos: number; tone: Tone }) {
return (
<span className="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full" aria-hidden
style={{ left: `${pos}%`, background: toneColor(tone), boxShadow: '0 0 0 2px #11131C' }} />
);
}
// ---- earnings + provenance -------------------------------------------------
function Provenance({ provenance, priceDate, marketCap }: {
provenance: MetricItem | null; priceDate: string | null; marketCap: number | null;
}) {
if (!provenance?.period_end && !priceDate) return null;
return (
<p className="mt-4 border-t border-white/10 pt-2 text-[11px] leading-relaxed" style={{ color: HZ.track }}>
{provenance?.period_end && (
<>SEC filings · latest {shortDate(provenance.period_end)}
{provenance.filed_date && <> (filed {shortDate(provenance.filed_date)})</>}</>
)}
{priceDate && (
<>{provenance?.period_end ? ' · ' : ''}Valuation at {shortDate(priceDate)} close · market cap {money(marketCap)} est.</>
)}
</p>
);
}
function EarningsStrip({ earnings }: { earnings: FundamentalResponse['earnings'] }) {
const next = earnings?.next;
const recent = earnings?.recent ?? [];
const when = next ? (next.days_until === 0 ? 'today' : `${next.days_until}d`) : null;
return (
<div className="mt-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5">
<span className="text-sm" style={{ color: HZ.text }}>
<span style={{ color: HZ.muted }}>Next earnings </span>
{next ? (
<>
{shortDate(next.date)}
{' · '}
<span className="uppercase">{next.session === 'unknown' ? 'TBD' : next.session}</span>
<span style={{ color: HZ.muted }}> · {when}</span>
</>
) : (
<span style={{ color: HZ.muted }}>no date</span>
)}
</span>
{recent.length > 0 && <SurpriseSpark recent={recent} />}
</div>
);
}
/** Four tiny diverging bars around a zero baseline: beat above (cyan), miss below
* (coral), height ~ |surprise %|. Reads as a beat/miss history at a glance. */
function SurpriseSpark({ recent }: { recent: EarningsRecent[] }) {
const ordered = recent.slice().reverse();
const description = ordered.map((e) => {
const surprise = e.surprise_pct;
const amount = surprise != null ? ` ${surprise > 0 ? '+' : ''}${surprise}%` : '';
return `${e.announce_date} ${surpriseLabel(e)}${amount}`;
}).join(', ');
return (
<span className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-widest" style={{ color: HZ.track }}>
EPS surprises
</span>
<span
className="relative flex h-7 items-center gap-1.5 px-0.5"
role="img"
tabIndex={0}
title={description}
aria-label={`Recent EPS surprises, oldest to newest: ${description}`}
>
<span className="absolute inset-x-0 top-1/2 h-px" aria-hidden style={{ background: 'rgba(93,99,115,0.5)' }} />
{ordered.map((e, i) => <SurpriseBar key={i} e={e} />)}
</span>
</span>
);
}
function surpriseLabel(e: EarningsRecent): string {
const beat = e.eps_actual != null && e.eps_estimate != null ? e.eps_actual - e.eps_estimate : null;
return beat == null ? 'n/a' : beat > 0 ? 'beat' : beat < 0 ? 'miss' : 'in line';
}
function SurpriseBar({ e }: { e: EarningsRecent }) {
const beat = e.eps_actual != null && e.eps_estimate != null ? e.eps_actual - e.eps_estimate : null;
const tone: Tone = beat == null ? 'flat' : beat > 0 ? 'good' : beat < 0 ? 'bad' : 'flat';
const s = e.surprise_pct;
const mag = s != null ? Math.min(Math.abs(s), 15) / 15 : 0; // cap at 15%
const h = beat == null ? 2 : 3 + mag * 9; // px
const up = (beat ?? 0) >= 0;
return (
<span className="relative z-[1] block h-7 w-2"
title={`${e.announce_date}: ${surpriseLabel(e)}${s != null ? ` ${s > 0 ? '+' : ''}${s}%` : ''}`}>
<span className="absolute inset-x-0 rounded-[1px]" aria-hidden
style={{ height: h, background: toneColor(tone), ...(up ? { bottom: '50%' } : { top: '50%' }) }} />
</span>
);
}
+146
View File
@@ -0,0 +1,146 @@
/* Dev-only visual harness for FundamentalsPanel. Served at /harness.html by
* `vite`. Not imported by the app. Renders the three key states so desktop and
* mobile can be eyeballed with representative fixtures. */
import { createRoot } from 'react-dom/client';
import '../styles/globals.css';
import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel';
import type { FundamentalResponse, MetricItem } from '../lib/types';
function h(period: string, value: number | null) {
return { period_end: period, value };
}
const P = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-28'];
function dateFromToday(days: number): string {
const date = new Date();
date.setHours(12, 0, 0, 0);
date.setDate(date.getDate() + days);
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
}
function metric(key: string, value: number | null, hist: (number | null)[],
industry: MetricItem['industry'] = null,
caveat: string | null = null): MetricItem {
return {
key: key as MetricItem['key'], value,
history: hist.map((v, i) => h(P[i], v)),
industry, period_end: '2026-03-28', filed_date: '2026-05-01', caveat,
source: 'sec',
};
}
const ind = (median: number, favorable_percentile: number) =>
({ label: 'SIC 35 peers', median, favorable_percentile, peer_count: 12 });
const legacy = {
pe_ratio: null, revenue_growth: null, earnings_surprise: null, market_cap: null,
next_earnings_date: null, fetched_at: null, unavailable_fields: {},
setup_eligible: true, setup_block_code: null, setup_block_reason: null,
};
const full: FundamentalResponse = {
symbol: 'AAPL', ...legacy,
earnings: {
next: { date: dateFromToday(12), session: 'amc', days_until: 12 },
recent: [
{ announce_date: '2025-08-01', period_end: '2025-06-30', eps_estimate: 1.4, eps_actual: 1.6, surprise_pct: 14.3 },
{ announce_date: '2025-11-01', period_end: '2025-09-30', eps_estimate: 1.7, eps_actual: 1.9, surprise_pct: 11.8 },
{ announce_date: '2026-02-01', period_end: '2025-12-31', eps_estimate: 2.6, eps_actual: 2.4, surprise_pct: -7.7 },
{ announce_date: '2026-05-01', period_end: '2026-03-28', eps_estimate: 1.5, eps_actual: 1.65, surprise_pct: 10.0 },
],
},
metrics: [
metric('revenue_growth_yoy', 18, [8, 11, 15, 18], ind(11, 82)),
metric('eps_growth_yoy', 24, [10, 18, 22, 24], ind(15, 70)),
metric('operating_margin', 32, [30, 31, 31, 32], ind(22, 88)),
metric('fcf_margin', 28, [24, 25, 27, 28], ind(18, 80)),
metric('net_debt', 16.2e9, [46e9, 44e9, 24e9, 16.2e9], null),
metric('net_debt_to_ebitda', 1.4, [1.9, 1.7, 1.5, 1.4], ind(2.1, 68)),
metric('share_count_change_yoy', -1.7, [-2.4, -2.2, -2.3, -1.7], null),
],
valuation: {
pe: 29.2, fcf_yield: 3.8, market_cap_est: 3.2e12,
pe_industry: ind(23.5, 30), fcf_yield_industry: ind(3.1, 70), price_date: '2026-05-01',
},
reads: {
header: 'growth accelerating · margins improving · valuation priced above peers',
by_key: {
revenue_growth_yoy: 'accelerating', eps_growth_yoy: 'accelerating',
operating_margin: 'improving', fcf_margin: 'improving',
share_count_change_yoy: 'buying back', net_debt_to_ebitda: 'conservative leverage',
pe: 'priced above peers', fcf_yield: 'above peers', net_debt: null,
},
},
};
const partial: FundamentalResponse = {
symbol: 'NEWCO', ...legacy,
earnings: { next: { date: dateFromToday(0), session: 'unknown', days_until: 0 }, recent: [] },
metrics: [
metric('revenue_growth_yoy', 12, [null, 8, 10, 12], null),
metric(
'eps_growth_yoy',
null,
[null, null, null, null],
null,
'Not comparable: share count changed at least 25%; possible split or corporate action.',
),
metric('operating_margin', 25, [24, 24, 25, 25], null),
metric('fcf_margin', null, [null, null, null, null], null),
metric('net_debt', null, [], null),
metric('net_debt_to_ebitda', 1.9, [1.7, 1.8, 1.9, 1.9], null),
metric(
'share_count_change_yoy',
null,
[1.8, 2.0, 2.0, null],
null,
'Not comparable: share count changed at least 25%; possible split or corporate action.',
),
],
valuation: {
pe: 15.2, fcf_yield: null, market_cap_est: 5.4e8,
pe_industry: null, fcf_yield_industry: null, price_date: '2026-05-01',
},
reads: {
header: 'growth steady · margins stable',
by_key: {
revenue_growth_yoy: 'steady', operating_margin: 'stable',
share_count_change_yoy: '2.1% dilution', net_debt_to_ebitda: null,
pe: null, fcf_yield: null, eps_growth_yoy: null, fcf_margin: null, net_debt: null,
},
},
};
const empty: FundamentalResponse = {
symbol: 'ADR', ...legacy,
earnings: { next: null, recent: [] },
metrics: [
'revenue_growth_yoy', 'eps_growth_yoy', 'operating_margin', 'fcf_margin',
'net_debt', 'net_debt_to_ebitda', 'share_count_change_yoy',
].map((k) => metric(k, null, [])),
valuation: null,
reads: { header: null, by_key: {} },
};
function Case({ title, data }: { title: string; data: FundamentalResponse }) {
return (
<div>
<div className="mb-1.5 text-[11px] uppercase tracking-widest text-gray-500">{title}</div>
<FundamentalsPanel data={data} />
</div>
);
}
createRoot(document.getElementById('root')!).render(
<div className="mx-auto max-w-3xl space-y-8 p-6">
<p className="text-[11px] uppercase tracking-widest text-gray-500">
Desktop width (~768px, two columns). Resize the browser to ~390px to check mobile (single column).
</p>
<Case title="Full" data={full} />
<Case title="Partial · insufficient peers" data={partial} />
<Case title="Empty" data={empty} />
</div>,
);
+38
View File
@@ -90,6 +90,36 @@ export function useUpdateSetting() {
}); });
} }
export function useFundamentalsCutoverSettings() {
return useQuery({
queryKey: ['admin', 'fundamentals-cutover'],
queryFn: () => adminApi.getFundamentalsCutoverSettings(),
});
}
export function useUpdateFundamentalsCutoverSettings() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (enabled: boolean) =>
adminApi.updateFundamentalsCutoverSettings(enabled),
onSuccess: (config) => {
qc.setQueryData(['admin', 'fundamentals-cutover'], config);
qc.invalidateQueries({ queryKey: ['admin', 'settings'] });
addToast(
config.enabled ? 'success' : 'info',
config.enabled
? 'SEC + Dolt fundamentals activated'
: 'SEC + Dolt cache refresh paused',
);
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update fundamentals data source');
},
});
}
export function useRecommendationSettings() { export function useRecommendationSettings() {
return useQuery({ return useQuery({
queryKey: ['admin', 'recommendation-settings'], queryKey: ['admin', 'recommendation-settings'],
@@ -316,6 +346,14 @@ export function useJobs() {
}); });
} }
export function useFundamentalsParityReport() {
return useQuery({
queryKey: ['admin', 'fundamentals-parity'],
queryFn: () => adminApi.getFundamentalsParityReport(),
refetchInterval: 15_000,
});
}
export function usePipelineReadiness() { export function usePipelineReadiness() {
return useQuery({ return useQuery({
queryKey: ['admin', 'pipeline-readiness'], queryKey: ['admin', 'pipeline-readiness'],
+121 -1
View File
@@ -187,10 +187,17 @@ export interface ActivationConfig {
exclude_neutral: boolean; exclude_neutral: boolean;
} }
export interface FundamentalsCutoverConfig {
enabled: boolean;
}
// Cron schedule for morning / near-close / after-close / intraday + fundamentals // Cron schedule for morning / near-close / after-close / intraday + fundamentals
export interface ScheduleConfig { export interface ScheduleConfig {
schedule_timezone: string; schedule_timezone: string;
schedule_daily_pipeline_cron: string; schedule_daily_pipeline_cron: string;
schedule_dolt_earnings_cron: string;
schedule_sec_fundamentals_cron: string;
schedule_fundamentals_parity_cron: string;
schedule_near_close_pipeline_cron: string; schedule_near_close_pipeline_cron: string;
schedule_after_close_pipeline_cron: string; schedule_after_close_pipeline_cron: string;
schedule_intraday_pipeline_cron: string; schedule_intraday_pipeline_cron: string;
@@ -230,6 +237,8 @@ export interface PaperTrade {
close_reason: 'time' | 'trailing' | 'stop' | 'target' | 'manual' | null; close_reason: 'time' | 'trailing' | 'stop' | 'target' | 'manual' | null;
trailing_stop: number | null; trailing_stop: number | null;
trailing_distance_pct: number | null; trailing_distance_pct: number | null;
sessions_held: number | null;
sessions_remaining: number | null;
} }
export interface ExitPolicy { export interface ExitPolicy {
@@ -473,6 +482,9 @@ export interface RegimePillar {
export interface RegimeReading { export interface RegimeReading {
score: number | null; score: number | null;
band: RegimeBand | null; band: RegimeBand | null;
// Per axis: State and Warning have different realized ranges, so they do not
// share thresholds.
bands?: { watch: number; elevated: number; breaking: number };
coverage: number; coverage: number;
minimum_coverage: number; minimum_coverage: number;
available_pillars: string[]; available_pillars: string[];
@@ -480,6 +492,23 @@ export interface RegimeReading {
trend?: { delta_7: number | null; delta_30: number | null }; trend?: { delta_7: number | null; delta_30: number | null };
} }
/** Qualitative capex / earnings-reaction context. Not part of either score. */
export interface RegimeFundamentalOverlay {
available: boolean;
pending: boolean;
stale: boolean;
effective_date: string | null;
age_days: number | null;
capex: Record<string, CapexState> | null;
good_news_stock_down: GoodNewsReaction | null;
capex_stress: number | null;
earnings_stress: number | null;
reasoning: string | null;
source: string | null;
fetched_at: string | null;
observed_in_snapshot?: boolean;
}
export interface RegimeHistoryPoint { export interface RegimeHistoryPoint {
date: string; date: string;
state: number | null; state: number | null;
@@ -496,6 +525,10 @@ export interface RegimeMonitor {
date?: string; date?: string;
state?: RegimeReading; state?: RegimeReading;
warning?: RegimeReading; warning?: RegimeReading;
/** Point-in-time overlay recorded in the snapshot. */
fundamental_overlay?: RegimeFundamentalOverlay;
/** Current observation, even when it is not effective until the next session. */
fundamental_context?: RegimeFundamentalOverlay;
inputs?: { inputs?: {
vix: number | null; vix: number | null;
vix_date: string | null; vix_date: string | null;
@@ -527,7 +560,7 @@ export interface RegimeMonitor {
} }
export interface RegimeFundamentals { export interface RegimeFundamentals {
methodology: 'v2'; methodology: 'v3';
f1_score: number | null; f1_score: number | null;
f3_score: number | null; f3_score: number | null;
locked: boolean; locked: boolean;
@@ -573,6 +606,18 @@ export interface EventStudyReport {
warn_threshold: number; warn_threshold: number;
basket_hash: string; basket_hash: string;
basket_asof: string; basket_asof: string;
credit_sensor_from?: string | null;
};
/** How far the headline metrics can be trusted. See _reliability(). */
reliability?: {
events_detected: number;
events_in_holdout: number;
minimum_events: number;
underpowered: boolean;
sensors_expected: number;
train_full_sensor_share: number;
holdout_full_sensor_share: number;
sensor_coverage_mismatch: boolean;
}; };
sample?: { sample?: {
start: string; start: string;
@@ -703,8 +748,75 @@ export interface SentimentResponse {
} }
// Fundamentals // Fundamentals
export interface MetricIndustry {
label: string;
median: number;
favorable_percentile: number; // 0-100, polarity-aware (higher = more favorable)
peer_count: number;
}
export interface MetricHistoryPoint {
period_end: string | null; // YYYY-MM-DD
value: number | null;
}
export type MetricKey =
| 'revenue_growth_yoy'
| 'eps_growth_yoy'
| 'operating_margin'
| 'fcf_margin'
| 'net_debt'
| 'net_debt_to_ebitda'
| 'share_count_change_yoy';
export interface MetricItem {
key: MetricKey;
value: number | null;
history: MetricHistoryPoint[];
industry: MetricIndustry | null;
period_end: string | null;
filed_date: string | null;
caveat: string | null;
source: string; // 'sec' | 'legacy_api'
}
export interface EarningsNext {
date: string;
session: string; // bmo | amc | unknown
days_until: number;
}
export interface EarningsRecent {
announce_date: string;
period_end: string | null;
eps_estimate: number | null;
eps_actual: number | null;
surprise_pct: number | null;
}
export interface EarningsObject {
next: EarningsNext | null;
recent: EarningsRecent[];
}
export interface Valuation {
pe: number | null;
fcf_yield: number | null;
market_cap_est: number | null;
pe_industry: MetricIndustry | null;
fcf_yield_industry: MetricIndustry | null;
price_date: string | null;
}
export interface FundamentalsReads {
header: string | null;
// fixed map over every metric key plus 'pe' and 'fcf_yield'; null when unavailable
by_key: Record<string, string | null>;
}
export interface FundamentalResponse { export interface FundamentalResponse {
symbol: string; symbol: string;
// legacy fields (unchanged)
pe_ratio: number | null; pe_ratio: number | null;
revenue_growth: number | null; revenue_growth: number | null;
earnings_surprise: number | null; earnings_surprise: number | null;
@@ -712,6 +824,14 @@ export interface FundamentalResponse {
next_earnings_date: string | null; next_earnings_date: string | null;
fetched_at: string | null; fetched_at: string | null;
unavailable_fields: Record<string, string>; unavailable_fields: Record<string, string>;
// additive v1 (SEC/Dolt-derived) — present, with null/empty when unavailable
earnings: EarningsObject | null;
metrics: MetricItem[] | null;
valuation: Valuation | null;
reads: FundamentalsReads | null;
setup_eligible: boolean;
setup_block_code: string | null;
setup_block_reason: string | null;
} }
// Indicators // Indicators
+4
View File
@@ -5,6 +5,8 @@ import { AlertSettings } from '../components/admin/AlertSettings';
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings'; import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
import { DataCleanup } from '../components/admin/DataCleanup'; import { DataCleanup } from '../components/admin/DataCleanup';
import { JobControls } from '../components/admin/JobControls'; import { JobControls } from '../components/admin/JobControls';
import { FundamentalsParityPanel } from '../components/admin/FundamentalsParityPanel';
import { FundamentalsCutoverSettings } from '../components/admin/FundamentalsCutoverSettings';
import { PerformanceSettings } from '../components/admin/PerformanceSettings'; import { PerformanceSettings } from '../components/admin/PerformanceSettings';
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel'; import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel'; import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
@@ -35,6 +37,7 @@ export default function AdminPage() {
{activeTab === 'Tickers' && <TickerManagement />} {activeTab === 'Tickers' && <TickerManagement />}
{activeTab === 'Settings' && ( {activeTab === 'Settings' && (
<div className="space-y-4"> <div className="space-y-4">
<FundamentalsCutoverSettings />
<ActivationSettings /> <ActivationSettings />
<ExitPolicySettings /> <ExitPolicySettings />
<PerformanceSettings /> <PerformanceSettings />
@@ -48,6 +51,7 @@ export default function AdminPage() {
{activeTab === 'Jobs' && ( {activeTab === 'Jobs' && (
<div className="space-y-4"> <div className="space-y-4">
<ScheduleSettings /> <ScheduleSettings />
<FundamentalsParityPanel />
<JobControls /> <JobControls />
<PipelineReadinessPanel /> <PipelineReadinessPanel />
</div> </div>
+116 -6
View File
@@ -21,6 +21,7 @@ import type {
GoodNewsReaction, GoodNewsReaction,
RegimeBand, RegimeBand,
RegimeConfig, RegimeConfig,
RegimeFundamentalOverlay,
RegimeFundamentals, RegimeFundamentals,
RegimeFundamentalsUpdate, RegimeFundamentalsUpdate,
RegimeReading, RegimeReading,
@@ -64,6 +65,8 @@ function ScoreGauge({
const complete = reading?.band != null; const complete = reading?.band != null;
const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null; const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
const position = Math.min(100, Math.max(0, score ?? 0)); const position = Math.min(100, Math.max(0, score ?? 0));
const bands = reading?.bands;
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [30, 60, 80];
return ( return (
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}> <div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
@@ -98,8 +101,15 @@ function ScoreGauge({
style={{ left: `${position}%` }} style={{ left: `${position}%` }}
/> />
</div> </div>
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600"> {/* Thresholds come from the reading: the two axes no longer share them. */}
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span> <div className="relative mt-1.5 h-4 text-[10px] uppercase tracking-wider text-gray-600">
<span className="absolute left-0">0</span>
{ticks.map((tick) => (
<span key={tick} className="absolute -translate-x-1/2 num" style={{ left: `${tick}%` }}>
{tick}
</span>
))}
<span className="absolute right-0">100</span>
</div> </div>
</> </>
)} )}
@@ -108,6 +118,77 @@ function ScoreGauge({
); );
} }
const CAPEX_TONE: Record<CapexState, string> = {
raising: 'text-emerald-400',
holding: 'text-amber-400',
cutting: 'text-red-400',
unknown: 'text-gray-500',
};
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
const capex = overlay.capex ?? {};
const reaction = overlay.good_news_stock_down;
return (
<div className="glass border border-white/[0.06] p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">
Fundamental overlay · context, not scored
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
{overlay.source && <span>{overlay.source}</span>}
{overlay.effective_date && <span>· effective {overlay.effective_date}</span>}
{overlay.pending && <Badge label="pending" variant="manual" />}
{overlay.stale && <Badge label="stale" variant="manual" />}
</div>
</div>
{overlay.pending ? (
<p className="mt-3 text-xs leading-relaxed text-amber-400/90">
A newer observation was collected but is not effective until {overlay.effective_date ?? 'the next session'}.
Observations are never backdated, so the reading below appears from that session onward.
</p>
) : (
<>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 flex items-baseline justify-between text-xs">
<span className="font-medium text-gray-300">Hyperscaler capex guidance</span>
<span className="num text-gray-500">{overlay.capex_stress ?? 'n/a'}</span>
</div>
<div className="space-y-1">
{Object.entries(capex).map(([symbol, state]) => (
<div key={symbol} className="flex items-center justify-between text-xs">
<span className="font-mono text-gray-400">{symbol}</span>
<span className={CAPEX_TONE[state] ?? 'text-gray-500'}>{state}</span>
</div>
))}
</div>
</div>
<div>
<div className="mb-2 flex items-baseline justify-between text-xs">
<span className="font-medium text-gray-300">Good news, stock down</span>
<span className="num text-gray-500">{overlay.earnings_stress ?? 'n/a'}</span>
</div>
<div className={`text-sm font-medium ${reaction === 'yes' ? 'text-red-400' : reaction === 'no' ? 'text-emerald-400' : 'text-gray-500'}`}>
{reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'}
</div>
</div>
</div>
{overlay.reasoning && (
<p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>
)}
</>
)}
<p className="mt-4 text-[11px] leading-relaxed text-gray-600">
These observations are qualitative, refreshed roughly quarterly, and deliberately excluded from State and
Warning. In v2 they carried 20 of 100 Warning points not enough to cross the study's alarm threshold even
when both were pegged so they are reported here rather than diluted into a daily score.
</p>
</div>
);
}
function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) { function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) {
return ( return (
<Disclosure summary={`${title} pillars · ${Math.round(reading.coverage)}% coverage`}> <Disclosure summary={`${title} pillars · ${Math.round(reading.coverage)}% coverage`}>
@@ -190,6 +271,32 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
</table> </table>
</div> </div>
)} )}
{report.reliability && (report.reliability.underpowered || report.reliability.sensor_coverage_mismatch) && (
<Callout variant="warning">
<div className="space-y-1.5">
{report.reliability.underpowered && (
<p>
<strong>Underpowered.</strong> Only {report.reliability.events_in_holdout} of{' '}
{report.reliability.events_detected} detected corrections fall in the test period (
{report.reliability.minimum_events}+ needed). Recall is one event away from a materially
different headline, and which events flip is usually decided by where the frozen threshold
lands rather than by what the score saw. Read the direction, not the ratio.
</p>
)}
{report.reliability.sensor_coverage_mismatch && (
<p>
<strong>Sensor coverage differs across the split.</strong>{' '}
{report.reliability.train_full_sensor_share}% of training sessions had all{' '}
{report.reliability.sensors_expected} Warning sensors versus{' '}
{report.reliability.holdout_full_sensor_share}% of test sessions
{report.params?.credit_sensor_from && ` — credit history begins ${report.params.credit_sensor_from}`}
. The score renormalises over what is available, so the threshold was frozen on a partly
different construct than it is measured against.
</p>
)}
</div>
</Callout>
)}
<p className="text-[11px] leading-relaxed text-gray-600"> <p className="text-[11px] leading-relaxed text-gray-600">
The threshold is frozen on the training period and measured on the chronological test period. Reconstructed The threshold is frozen on the training period and measured on the chronological test period. Reconstructed
pre-freeze basket history remains exploratory. pre-freeze basket history remains exploratory.
@@ -235,8 +342,10 @@ function FundamentalsEditor({
const [capex, setCapex] = useState<Record<string, CapexState>>(() => ({ ...data.capex })); const [capex, setCapex] = useState<Record<string, CapexState>>(() => ({ ...data.capex }));
const [reaction, setReaction] = useState<GoodNewsReaction>(data.good_news_stock_down); const [reaction, setReaction] = useState<GoodNewsReaction>(data.good_news_stock_down);
const knownCapex = Object.values(capex).filter((state) => state !== 'unknown'); const knownCapex = Object.values(capex).filter((state) => state !== 'unknown');
const cutting = knownCapex.filter((state) => state === 'cutting').length; // Mirrors _CAPEX_STATE_SCORES: raising 0, holding 50, cutting 100. Holding is
const derivedF1 = knownCapex.length >= 3 ? Math.round((cutting / knownCapex.length) * 1000) / 10 : null; // the deceleration case and used to score identically to raising.
const capexPoints = knownCapex.reduce((sum, state) => sum + (state === 'cutting' ? 100 : state === 'holding' ? 50 : 0), 0);
const derivedF1 = knownCapex.length >= 3 ? Math.round((capexPoints / knownCapex.length) * 10) / 10 : null;
const derivedF3 = reaction === 'yes' ? 100 : reaction === 'no' ? 0 : null; const derivedF3 = reaction === 'yes' ? 100 : reaction === 'no' ? 0 : null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -266,7 +375,7 @@ function FundamentalsEditor({
</label> </label>
))} ))}
</div> </div>
<p className="mt-1.5 text-[11px] text-gray-600">Raising/holding = 0 stress; cutting = 100; at least three known names required.</p> <p className="mt-1.5 text-[11px] text-gray-600">Raising = 0, holding = 50, cutting = 100; at least three known names required. Display only this does not enter Warning.</p>
</div> </div>
<label className="flex items-center justify-between gap-3 text-xs text-gray-400"> <label className="flex items-center justify-between gap-3 text-xs text-gray-400">
<span> <span>
@@ -369,9 +478,10 @@ export default function RegimePage() {
label="Warning · deterioration & divergence" label="Warning · deterioration & divergence"
reading={data.warning} reading={data.warning}
divider={data.quadrant_config?.warning_divider} divider={data.quadrant_config?.warning_divider}
footnote={<>Breadth divergence, SMH/SPY rollover, and point-in-time fundamental observations. Unknown or stale fundamentals reduce coverage; they never default to 50.</>} footnote={<>Breadth divergence, SMH/SPY rollover, and HY credit impulse. Breadth loss counts fully when price masks it and partially when price confirms it. Missing sensors reduce coverage; they never default to 50.</>}
/> />
</div> </div>
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
<p className="text-xs text-gray-600"> <p className="text-xs text-gray-600">
Data quality · oldest market input:{' '} Data quality · oldest market input:{' '}
{data.data_quality?.oldest_market_input_age_days == null {data.data_quality?.oldest_market_input_age_days == null
+52 -3
View File
@@ -64,10 +64,44 @@ function timeAgo(iso: string): string {
return `${days}d ago`; return `${days}d ago`;
} }
function marketDate(date = new Date()): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const value = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
return value('year') + '-' + value('month') + '-' + value('day');
}
function formatSessionDate(isoDate: string): string {
const currentMarketDate = marketDate();
if (isoDate === currentMarketDate) return 'Today';
// Parse date-only market sessions explicitly. Parsing YYYY-MM-DD directly as
// a Date means midnight UTC and makes today's bar look many hours old.
const [year, month, day] = isoDate.split('-').map(Number);
if (!year || !month || !day) return isoDate;
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: year === Number(currentMarketDate.slice(0, 4)) ? undefined : 'numeric',
timeZone: 'UTC',
}).format(new Date(Date.UTC(year, month - 1, day)));
}
function formatOHLCVFreshness(sessionDate: string, updatedAt?: string | null): string {
const session = formatSessionDate(sessionDate);
return updatedAt ? session + ' · updated ' + timeAgo(updatedAt) : session;
}
interface DataStatusItem { interface DataStatusItem {
label: string; label: string;
available: boolean; available: boolean;
timestamp?: string | null; timestamp?: string | null;
timestampLabel?: string | null;
selector: FetchSelector; // what a refresh of this row fetches selector: FetchSelector; // what a refresh of this row fetches
paid?: boolean; // provider call that may cost money/quota paid?: boolean; // provider call that may cost money/quota
} }
@@ -100,7 +134,7 @@ function DataFreshnessBar({
}`} /> }`} />
<span className="text-xs text-gray-400">{item.label}</span> <span className="text-xs text-gray-400">{item.label}</span>
{item.available && item.timestamp ? ( {item.available && item.timestamp ? (
<span className="text-[10px] text-gray-500">{timeAgo(item.timestamp)}</span> <span className="text-[10px] text-gray-500">{item.timestampLabel ?? timeAgo(item.timestamp)}</span>
) : !item.available ? ( ) : !item.available ? (
<span className="text-[10px] text-gray-600">no data</span> <span className="text-[10px] text-gray-600">no data</span>
) : null} ) : null}
@@ -171,10 +205,16 @@ export default function TickerDetailPage() {
const dataStatus: DataStatusItem[] = useMemo(() => [ const dataStatus: DataStatusItem[] = useMemo(() => [
{ {
label: 'OHLCV', label: 'OHLCV',
// Market age of the latest bar (session date), not DB insert time — // Keep the market session date distinct from the last successful bar
// created_at stays frozen when the provider returns no new sessions. // write; treating YYYY-MM-DD as an instant makes today's session look old.
available: !!ohlcv.data && ohlcv.data.length > 0, available: !!ohlcv.data && ohlcv.data.length > 0,
timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.date, timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.date,
timestampLabel: ohlcv.data?.length
? formatOHLCVFreshness(
ohlcv.data[ohlcv.data.length - 1].date,
ohlcv.data[ohlcv.data.length - 1].created_at,
)
: null,
selector: ['ohlcv'] as FetchSelector, selector: ['ohlcv'] as FetchSelector,
paid: true, paid: true,
}, },
@@ -319,6 +359,15 @@ export default function TickerDetailPage() {
busy={ingestion.isPending} busy={ingestion.isPending}
/> />
</div> </div>
{fundamentals.data && !fundamentals.data.setup_eligible && (
<div className="border-b border-white/[0.06] px-6 py-3 sm:px-7">
<Callout variant="warning">
<span className="font-medium">New setups paused.</span>{' '}
{fundamentals.data.setup_block_reason ??
'SEC fundamentals are incomplete for this ticker.'}
</Callout>
</div>
)}
<div className="p-6 pb-5 sm:p-7 sm:pb-5"> <div className="p-6 pb-5 sm:p-7 sm:pb-5">
<div className="flex flex-wrap items-start justify-between gap-x-8 gap-y-5"> <div className="flex flex-wrap items-start justify-between gap-x-8 gap-y-5">
<div className="min-w-0"> <div className="min-w-0">
@@ -0,0 +1,925 @@
# A5 parity report — root-cause findings
Investigation of `fundamentals-parity-20260723T210658161480Z.json` (511 tickers,
generated 2026-07-23). Method: replayed the production parser
(`sec_facts_parser.parse_snapshots`) and derivation (`fundamentals_derivation.derive`)
against **live SEC companyfacts**, using the importer's own `_filing_meta` and
backfill accession set, then cross-checked prices against IBKR. No database was
available locally, so every conclusion below is reproduced from source data rather
than read out of prod.
Repro script: `scratchpad/diag.py` (`--history` replays the full backfill path).
Every claim below was verified on the named issuer. Names that were *not*
individually inspected are listed as unclassified — an earlier draft of this
document guessed their cause from fiscal-year-end dates and was wrong for most of
them, so the guessing is not repeated here.
## Verdict
Where both sides have a value the candidate data is good: P/E spearman 0.968,
revenue growth agreeing to 4 decimals for most names, score spearman 0.825. Every
defect found is a **parser/derivation bug or an identity problem** — not a data
quality problem with SEC or Dolt. The largest cluster is period identity, which is
exactly the risk A3 flagged as primary.
## 1. P/E outliers — splits corrupt TTM EPS, and the split guard doesn't cover it
`derive()` sets `result.ttm_diluted_eps` at `fundamentals_derivation.py:88` and only
calls `_guard_split_sensitive_metrics()` at line 105, which annotates `result.metrics`
(the `MetricSeries` objects). `ttm_diluted_eps` is a bare scalar and is never guarded.
`fundamentals_parity_service._pe()` consumes it directly.
The cleanest evidence that the *candidate* side is the broken one: reconcile each
P/E against the report's own price. Legacy comes out sane in both cases, candidate
does not.
**BKNG — guard fired, nobody listened.** Share count jumps 31.7M → 774.9M between the
FY2025 10-K and the 2026 Q1 10-Q (≈25:1 split). TTM EPS therefore sums three pre-split
quarters (27.31 + 84.01 + 44.18 = 155.50) plus one post-split quarter (1.36) =
**156.86** — mixed units. Live price $172.83 matches the price the report implies
exactly (1.1018 × 156.86 = 172.83), so the price is correct and current. Against that
price, legacy's P/E of 22.44 implies EPS ≈ 7.70 — a coherent post-split number, versus
the candidate's 156.86. The derivation *did* raise `"Not comparable: share count
changed at least 25%; possible split or corporate action."` on `eps_growth_yoy` and
`share_count_change_yoy` — P/E never sees it.
**KLAC — the guard cannot fire.** The split post-dates the most recent 10-Q (period end
2026-03-31), so no snapshot shows any share-count change (`share_count_change_yoy` =
1.2%). TTM EPS **35.31** is internally consistent and entirely pre-split; the price
($223.30 live, ≈218.7 in the report) is post-split. Reconciling: legacy P/E 60.21
against the report price implies EPS ≈ 3.63 ≈ 35.31/9.7 — i.e. legacy is consistent
with a ~10:1 split and correct, and the candidate is off by exactly the split factor.
(IBKR's split-adjusted `open_52w` of 89.36 corroborates 10:1.)
This is the important case: **a split after the latest filing is undetectable from
snapshots alone.** No share-count test can catch it. Reconciliation needs a corporate
actions source or a price-vs-EPS plausibility check.
**COF — not a bug, a definition difference.** Shares 383M → 639M in 2025 Q2 is the
Discover acquisition. TTM GAAP EPS is genuinely $3.92 because the merger-charge quarter
(10.19) sits in the window. Candidate P/E 51.01 is arithmetically correct on a GAAP TTM
basis; legacy's 11.61 is an adjusted/forward convention. Disclose, don't fix. Note this
single row drives the report's largest change (rank 1 → 456).
## 2. Bank revenue growth — concept-mapping gap (confirmed)
`sec_facts_parser._DURATION_USD["revenue"]` is:
```
RevenueFromContractWithCustomerExcludingAssessedTax, Revenues, SalesRevenueNet
```
Banks tag **`RevenuesNetOfInterestExpense`** in their 10-Qs:
| filer | 2026 Q1 10-Q tags present | parsed `revenue` |
|---|---|---|
| JPM | `RevenuesNetOfInterestExpense` 49,836M, `NoninterestIncome`, `InterestIncomeExpenseNet` | **null** |
| GS | `RevenuesNetOfInterestExpense` 17,227M, `InterestAndDividendIncomeOperating`, … | **null** |
| WFC | `RevenuesNetOfInterestExpense` 21,436M, … | **null** |
JPM's FY2025 10-K *also* tags `Revenues` (182,447M — identical value), so only the annual
row populates; GS never tags `Revenues` at all. Revenue growth needs five consecutive
quarterly values, so it is null for the whole cluster (JPM, GS, MS, WFC, TFC, MTB, FITB,
RF, SYF, BNY, BX, BLK, SPGI, ACGL, CBOE).
A second variant of the same gap: **ARE** and **KHC** tag
`RevenueFromContractWithCustomer**Including**AssessedTax` — also absent from the list —
so revenue is null on every row while EPS parses fine.
**Fix:** add `RevenuesNetOfInterestExpense` and the `IncludingAssessedTax` variant.
**Latent risk while you're in there:** `RevenueFromContractWithCustomerExcludingAssessedTax`
is *first* and "first present wins". For a bank that tags it, it captures only ASC-606 fee
revenue, not total revenue — a silently **understated** number rather than a null, which is
worse. DVN shows the same hazard from the other side: its 2026 Q1 tags both
`RevenueFromContractWithCustomerExcludingAssessedTax` (4,508M) and `Revenues` (3,807M),
an 18% difference decided purely by list order.
## 3. Period identity — the largest cluster, three confirmed mechanisms
### 3a. Fiscal-year label collisions (CRM, FRT, STX)
`_fiscal_context` majority-votes SEC's `fy`/`fp` fields, and `_select_latest_per_period`
keys on `(fiscal_year, fiscal_period)`. When SEC's labels disagree with the calendar, two
distinct periods collide on one key and **one is silently discarded**:
- **CRM** — two rows keyed `2025 FY`, ending 2025-01-31 and 2026-01-31.
- **FRT** — two rows keyed `2024 FY`, ending 2024-12-31 and 2025-12-31.
- **STX** — the year ending 2025-06-27 is labelled **`2027 FY`**, so it sorts *after*
`2026 Q3` (period end 2026-04-03) and is taken as the latest quarter.
The survivor's `period_end` then contradicts the fiscal ordering, Q4 derivation and the
consecutive-quarter chain break, and TTM EPS + YoY both go null.
**FRT is a calendar-year (Dec) filer**, so this is *not* limited to non-calendar fiscal
years — the earlier assumption that it was is wrong. Any filer SEC labels inconsistently
is exposed.
### 3b. Amendment selection blanks a period (DVN)
DVN has two rows for `2025 FY` (both ending 2025-12-31): the 10-K with complete financials,
and a **10-K/A carrying no financial facts at the report date** (`rev=None eps=None`).
`_select_latest_per_period` takes the newest `accepted_at`, so **the empty amendment wins**
and the FY2025 row becomes all-null, breaking the chain.
This is the most dangerous of the three: it is not exotic. Any issuer filing a 10-K/A —
including routine Part III amendments that restate nothing — silently loses that period.
The rule needs to prefer the newest accession *that actually carries the fact*, per field,
rather than the newest accession outright.
### 3c. 4-4-5 retail calendar — Q3 only, misses by ~2 days (COST, PEP)
`_EXPECTED_YTD_DAYS["Q3"] = 273` with `_YTD_TOLERANCE_DAYS = 20` accepts 253293 days. A
12/12/12/16-week filer's YTD-Q3 is 36 weeks ≈ **251252 days** — just under the floor.
Confirmed, facts present and rejected:
- COST 2026 Q3: `RevenueFromContractWithCustomerExcludingAssessedTax` span=**251d**
val=207,431M, `EarningsPerShareDiluted` span=251d val=14.01 → row stored with
`rev=None eps=None start=None`. Same for 2025 Q3 and 2024 Q3.
- PEP: every Q3 row is `rev=None eps=None`; Q1/Q2/FY all populate.
Q1 (83d vs 91±20), Q2 (167d vs 182±20) and FY (363364d vs 365±20) all pass — only Q3
fails, every year. The code comment claims the tolerance "covers 52/53-week fiscal
calendars"; it does not cover 4-4-5 ones.
Note this does **not** apply to ordinary 13-week 52/53-week filers (STX's Q3 YTD is 279d and
passes) — their failures are 3a, not this.
**Fix:** widen the Q3 tolerance to ~25 days, or derive the expected span from the filer's own
fiscal calendar rather than a fixed 91/182/273.
## 4. CIK identity (XOM)
SEC's `company_tickers.json` now maps **XOM → CIK 2115436 "ExxonMobil Holdings Corp", which
has 0 filings**. All 26 XBRL 10-K/10-Qs sit under the old CIK **34088 "EXXON MOBIL CORP"**.
XOM therefore has no snapshots at all, and nothing in the pipeline notices that a tracked
issuer resolved to a CIK with zero filings.
PSKY (5 filings) and Q (3 filings) are genuinely new registrants — expected, not a bug.
## Status of the 25 names that lose their fundamental score
Production requires ≥2 metrics (`scoring_service.py:502`), the same rule the parity harness
uses, so these genuinely drop the fundamental dimension and the composite renormalises over
the remaining four.
| cause (confirmed on the named issuer) | names |
|---|---|
| FY label collision (3a) | CRM, FRT, STX |
| 4-4-5 Q3 span (3c) | COST, PEP |
| revenue concept gap (§2) | ARE, KHC |
| amendment blanks period (3b) | DVN |
| CIK identity (§4) | XOM |
| new registrant — expected | PSKY, Q |
| **not yet classified** | AZO, BXP, CRWD, FCX, HAL, MOS, MTD, NTAP, PPL, REG, SJM, SWKS, WDAY |
13 of 25 confirmed. The unclassified 13 have not been inspected and should not be assumed to
share a cause — the confirmed set already spans five distinct mechanisms.
## Recommended order of work
1. **Amendment selection (3b)** — highest blast radius, affects any 10-K/A filer, and the
current rule is wrong in principle rather than at the margin.
2. **Revenue concept list (§2)** — add `RevenuesNetOfInterestExpense` and
`IncludingAssessedTax`; audit the ASC-606-first priority, which can understate rather
than null.
3. **Q3 YTD span tolerance (3c)** — effectively one line.
4. **XOM CIK remap (§4)** — plus a validation that flags any tracked ticker resolving to a
CIK with zero XBRL filings.
5. **Split safety for `ttm_diluted_eps` (§1)** — propagate the existing guard to the scalar,
and add a price-vs-EPS plausibility check for splits that post-date the last filing.
6. **Fiscal-period identity (3a)** — the deepest fix; consider keying period identity on
`period_end` rather than SEC's `fy`/`fp`.
Re-run the parity report after these and re-classify the remaining 13 before making a
cutover decision. The current report should not be approved as-is: its coverage gaps are
artifacts of the above, not real absences in the source data.
---
# Fixes applied (items 13)
| # | change | file | effective |
|---|---|---|---|
| 1 | amendment resolution is now **per field** — newest accession that actually reports a fact wins; only rows sharing the newest `period_end` are merged, so a mislabelled filing is never blended in | `fundamentals_derivation.py` | **read time — immediately** |
| 2 | appended `RevenueFromContractWithCustomerIncludingAssessedTax` and `RevenuesNetOfInterestExpense` to the revenue concept list | `sec_facts_parser.py` | parse time — **needs reparse** |
| 3 | YTD span tolerance 20 → 25 days, covering 4-4-5 retail calendars | `sec_facts_parser.py` | parse time — **needs reparse** |
Fix 2 is deliberately **additive**: the new tags go at the end of the priority list, so
every issuer that already resolved keeps the same concept and only issuers that resolved
to nothing gain a value. A regression test pins that ordering.
Tests: 7 added across `test_sec_facts_parser.py` and `test_fundamentals_derivation.py`.
The 5 behaviour-changing ones were confirmed to fail against the pre-fix code; the other 2
are invariance guards that pass both ways. Full unit suite: 795 passed.
## Validation against live SEC data
Re-ran the parser + derivation on live companyfacts. Every targeted name recovers, and
the recovered values independently agree with the legacy provider:
| name | cause | revenue growth before → after | legacy | TTM EPS after |
|---|---|---|---|---|
| COST | 4-4-5 Q3 | null → **9.2311** | 9.23 | 19.88 |
| PEP | 4-4-5 Q3 | null → **5.6197** | 5.62 | 7.63 |
| KHC | concept (Including) | null → **1.7457** | 1.75 | 4.85 |
| DVN | partial 10-K/A | null → **0.0956** | 1.51 | 3.59 |
| ARE | concept (Including) | null → **5.3462** | 9.53 | 6.27 |
| JPM | concept (bank) | null → **3.3388** | 108.98 | 20.89 |
| GS | concept (bank) | null → **11.1974** | 6.67 | 54.75 |
| WFC | concept (bank) | null → **4.1847** | 72.75 | 6.47 |
COST/PEP/KHC matching legacy to two decimals is strong evidence the parse is now correct.
The banks are the opposite case and worth noting for the cutover argument: legacy's JPM
109% and WFC 73% "revenue growth" are not plausible for a bank, while the SEC-derived
3.3% and 4.2% are — here the candidate is **better** than what it would replace. DVN and
ARE still differ from legacy; DVN is the `Revenues` vs ASC-606 ambiguity noted in §2 and
is the one open definition question.
Regression check on names that were already correct — IRM, KLAC, BKNG — reproduces their
previous values exactly (IRM 15.6375, KLAC 13.3895, BKNG 14.9506; TTM EPS unchanged).
Nothing that worked before moved.
### Concept consistency across the bank chains (checked, clean)
Because `Revenues` still outranks `RevenuesNetOfInterestExpense`, a filer could resolve the
FY row to one concept and its quarters to the other — which would make
`Q4 = YTD(FY) YTD(Q3)` a subtraction across two definitions, and poison every TTM window
containing it. Checked all 15 recovered banks (`scratchpad/concept_check.py`):
- **14 resolve a single concept across the whole chain** (GS, WFC, MS, TFC, MTB, FITB, RF,
SYF, BNY, BX, BLK, SPGI, ACGL, CBOE).
- **JPM is mixed but benign**: its FY2025 row tags both, at an *identical* 182,447M, so Q4
subtracts like for like. No filer showed the two tags disagreeing where both appear.
So the "candidate beats legacy for banks" claim above is safe as stated. **Residual risk:**
a future filer whose two tags differ would fail silently. Cheapest hardening is to treat
the two as one logical revenue concept rather than separate priority entries; the detector
script above turns this into a one-command check.
## Operational note — the parser fixes need a deliberate reparse
`sec_fundamentals_importer.promote()` treats snapshots as **immutable per accession**: a
re-run skips any accession already stored and records a `snapshot_discrepancy` SystemEvent
instead. So fixes 2 and 3 change nothing for rows already in the database — recovering
COST/PEP/JPM/etc. requires deleting the affected snapshot rows and re-importing, or adding
an explicit reparse path. Usefully, the discrepancy warning names exactly which stored
accessions now reconstruct differently, so a dry run over existing data will enumerate the
blast radius before anything is rewritten.
---
# Second pass — all 25 lost names now classified
Re-ran `diag.py --history` over every previously unclassified name, with fixes 13 in
place. (One name, DPZ, had been dropped from the unclassified list when this document was
rewritten; it is included here.)
## 11 of 25 recover
COST, PEP, KHC, DVN, ARE, **AZO, MOS, SJM, SWKS, HAL, DPZ** — and again the recovered
revenue growth matches the legacy provider to two decimals on every one:
| name | candidate | legacy | | name | candidate | legacy |
|---|---|---|---|---|---|---|
| AZO | 5.7405 | 5.74 | | SWKS | 2.3303 | 2.33 |
| MOS | 12.3388 | 12.34 | | HAL | 1.7201 | 1.72 |
| SJM | 3.7222 | 3.72 | | DPZ | 5.1573 | 5.16 |
Precisely: all 11 clear the ≥2-metric floor and regain a fundamental score. P/E returns for
AZO, MOS, SWKS, DPZ, COST, PEP and DVN. ARE, KHC and SJM have genuinely negative TTM EPS,
so their P/E stays null correctly. **HAL's TTM EPS is still null and the cause is not yet
established** — it scores on revenue growth + surprise. Loose end.
## 14 remain, in four causes
| cause | names | count |
|---|---|---|
| **fiscal-year label collisions (§3a)** | CRM, FRT, STX, BXP, CRWD, MTD, NTAP, WDAY, PPL | **9** |
| **EPS concept gap (new — §5 below)** | FCX, REG | 2 |
| CIK identity (§4) | XOM | 1 |
| new registrant — expected, not a bug | PSKY, Q | 2 |
The label bug is now the dominant cause by a wide margin, and it is more varied than first
described — it is not only colliding `fiscal_year` values:
- **BXP** — a *10-Q* for period end 2026-03-31 is labelled `2026 **FY**`. The **fiscal
period** is wrong, not just the year, so `_select_ytd` then measures the 90-day fact
against the 365-day FY expectation and rejects it too.
- **NTAP, WDAY, MTD, CRWD** — two different period-ends colliding on one key (the pattern
first seen on CRM/FRT).
- **PPL** — the worst observed: **four** rows keyed `2022 Q3`, with period ends 2022-09-30,
2023-03-31, 2023-06-30 and 2023-09-30.
## 5. New cause — EPS concept coverage
`_EPS_CONCEPTS = ["EarningsPerShareDiluted"]` is the only tag read. Confirmed by listing
every `USD/shares` duration concept in the relevant filings:
- **REG** tags only `IncomeLossFromContinuingOperationsPerDilutedShare`, on every filing —
EPS is null everywhere, so no TTM EPS and no P/E, ever.
- **FCX** is the nastier shape: its **10-Qs** tag `EarningsPerShareDiluted`, but its
**10-K** tags only `IncomeLossFromContinuingOperationsPerDilutedShare`. The FY row loses
EPS, so `Q4 = YTD(FY) YTD(Q3)` is undefined and TTM dies — an issuer that switches
concept *by form type* looks like partial data rather than a mapping gap.
**Fix:** append `IncomeLossFromContinuingOperationsPerDilutedShare` to `_EPS_CONCEPTS`.
Same additive shape as the revenue fix; recovers REG outright and FCX's FY row.
**Related decision, not a fix:** PPL's 2026 Q1 tags *no diluted variant at all* — only
`EarningsPerShareBasic` and `IncomeLossFromContinuingOperationsPerBasicShare`. Adding the
diluted continuing-ops tag does not help it. Falling back to basic EPS is a definition
change (basic ≠ diluted) and should be an explicit call, not a silent one.
---
# Third pass — fixes #2 and #3 applied
| # | change | file | effective |
|---|---|---|---|
| 2a | `_guard_split_sensitive_metrics()` now returns whether the *latest* period is split-suspect, and `derive()` nulls `ttm_diluted_eps` (setting `ttm_diluted_eps_caveat`) when it is | `fundamentals_derivation.py` | read time — immediately |
| 3 | appended `IncomeLossFromContinuingOperationsPerDilutedShare` to `_EPS_CONCEPTS` | `sec_facts_parser.py` | parse time — needs reparse |
5 tests added; the 3 behaviour-changing ones confirmed to fail against pre-fix code, 2 are
invariance guards. Full unit suite: **800 passed**.
## Validated on live data
| name | before | after | |
|---|---|---|---|
| FCX | TTM EPS null | **1.89** | recovered |
| REG | TTM EPS null | **2.92** | recovered |
| BKNG | TTM EPS 156.86 → P/E **1.10** | **null** + caveat | false perfect score removed |
| COF | TTM EPS 3.92 → P/E 51.01 | **null** + caveat | see side effect below |
| KLAC | TTM EPS 35.31 → P/E **6.19** | unchanged | **still wrong — 2b not fixed** |
| IRM, COST | — | unchanged | no regression |
FCX and REG regain a fundamental score (EPS + surprise clears the ≥2 floor). Their
**revenue growth is still null** — both are also blocked by the label bug (REG has a
mislabelled duplicate `2024 Q2`; FCX is missing its 2024 FY row entirely).
## Threshold decision — RESOLVED: keep 25%
Measured against the database (`scratchpad/share_change_check.sql`): **15 of 467 comparable
issuers (3.2%)** trip the ≥25% guard on their latest period.
| band | names | cause |
|---|---|---|
| ≥200% | BKNG 23.8×, ORLY 14.5×, NFLX 9.8×, NOW 5.0×, TPL 3.0× | forward splits |
| 50142% | CHTR (query artifact), **AMCR 68% (1-for-5 reverse split)**, WAT, COF | split + stock-funded M&A |
| 2547% | OMC, BG, HBAN, FITB, COHR, RKLB | stock-funded M&A, ordinary dilution |
**Keep the threshold at 25%**, for three reasons — the first of which is empirical and came
out of checking AMCR:
1. **A real split trips at only 68%.** AMCR's 1-for-5 reverse consolidation
(2,308,359,941 → 462,045,690 shares, ratio 4.996, between the Nov 2025 and Feb 2026
10-Qs) shows up as 68%. Raising the bar to 100% to spare the M&A cases would have let a
genuine split straight through. Split magnitude and M&A magnitude overlap in practice,
not just in theory.
2. **The cost is milder than first described.** Losing P/E leaves revenue growth + earnings
surprise = 2 metrics, which still clears the ≥2 floor. Affected issuers keep a
fundamental score; they lose one of three inputs.
3. **The severities are asymmetric.** A missed split yields a P/E off by 1025×, clamping to
a *perfect 100* sub-score. Over-nulling yields a missing input the scorer already handles
by renormalising.
Honest caveat: the guard is blunt — it detects that a share base moved, not how much damage
resulted. AMCR's pre-fix P/E was 28.61 against legacy's 29.47, i.e. only ~10-15% off, because
most of its YTD figures had already been restated on the post-split basis. So the guard
sometimes removes a roughly-usable number. That is the accepted price of a rule that cannot
measure the split factor.
Two data notes from the same check:
- **CHTR is a query artifact, not a guard trip.** The SQL picks the newest period *with* a
share count, while `derive()` picks the newest period and then reads shares off it. CHTR's
recent snapshots have a null `shares_outstanding`, so the query fell back to the 2016 Time
Warner merger. In the real path its change is None and the guard never fires — so the true
count is ~14. But it also means **CHTR has no recent share count, which breaks its market
cap in the API** — a separate small bug.
- **AMCR was suspected of being a `shares_outstanding` parsing bug and is not.** It is a real
corporate action, correctly detected. `abs()` in the guard already handles reverse splits.
## Side effect — COF
The guard fires on *any* ≥25% YoY share-count move, not only splits. COF's 383M → 639M jump
is the Discover acquisition, so it now nulls too and **loses the P/E of 51.01** that this
document previously called "arithmetically correct on a GAAP TTM basis".
I think nulling is right: TTM EPS sums four quarters whose per-share figures use different
weighted-average denominators, and across a 67% share change that sum is not a meaningful
per-share number regardless of whether the cause was a split or an acquisition. It follows
the formula without being a valid result.
But the cost is real and worth stating plainly: **any issuer doing a large stock-funded
acquisition loses its P/E for four quarters.** That frequency has not been measured — it
needs a count of `|share_count_change_yoy| ≥ 25%` across the universe, which needs the
database. If it turns out to be common, the alternative is a higher or split-shaped
threshold, at the cost of letting more BKNG-class errors through.
## 2b is genuinely unfixed
KLAC's split post-dates its most recent 10-Q, so no snapshot carries any share-count
evidence and no guard built on share counts can fire. Its P/E is still 6.19 — the true P/E
divided by the split factor. I did not ship a heuristic for this: the obvious one, flagging
implausibly low P/Es, would misfire on genuinely cheap names — CHTR (3.42) and CMCSA (4.30)
sit below KLAC's corrupted 6.19 in this very report. Detecting it needs an actual
corporate-actions source, or a price-vs-share-count reconciliation against an external
market-cap reference.
---
# Fourth pass — the reparse path
Snapshots are immutable per accession, so the parser fixes never reached stored rows.
`promote()` skipped them and logged a discrepancy. Reparse is the deliberate exception:
immutability protects *SEC's* record, but the stored row is **our reconstruction** — after a
parser fix, keeping it is preserving a stale cache, not preserving history.
| change | file |
|---|---|
| `run_import(..., force=True)` bypasses the unchanged-revision no-op. The revision tracks the *source*; a fix on our side leaves it unchanged, so the gate would skip the run | `data_import.py` |
| `SecFundamentalsImporter(reparse=True)` — forces full-history staging, and `promote()` rewrites the accessions whose reconstruction changed, stamping `import_run_id` | `sec_fundamentals_importer.py` |
| `scripts/reparse_fundamentals.py`**dry run by default**, `--apply` to write | new |
Unchanged rows are never touched; only accessions appearing in `staged.discrepancies` are
rewritten. The update writes the full `_SNAPSHOT_COLS` set via the same `_row_values()` the
insert uses, so a rewritten row can never be half old-parse and half new-parse. `created_at`
keeps its original value. Nothing is wired into the scheduler.
## A real bug the tests caught: false-positive discrepancies
`test_reparse_leaves_unchanged_rows_untouched` failed on first run — reparsing *identical*
data reported a change. Cause: `accepted_at` is written tz-aware UTC but
`DateTime(timezone=True)` only preserves tzinfo on Postgres; SQLite returns it naive, so
`_diff_fields` compared representations and saw a difference.
Left alone this would have made the dry-run report claim **every row needs rewriting**
exactly the misleading signal that makes a blast-radius report worthless. `_diff_fields` now
compares datetime *instants* via `_same_value()`. This also fixes a latent false positive in
the pre-existing `snapshot_discrepancy` warning, which shares the same code path.
## Verification
4 reparse tests added, driven through the real import framework with the fake SEC client.
The key one seeds the database through the **pre-fix parser** (monkeypatching
`_YTD_TOLERANCE_DAYS` back to 20 so a 4-4-5 Q3 is rejected and stored as null), then reparses
with the fixed parser and asserts the row is rewritten in place with new provenance — the
production scenario end to end. Also covered: unchanged rows keep their original
`import_run_id`; `reparse=False` still reports and refuses to mutate; `force` bypasses the
no-op. Full suite: **804 passed**.
Not verifiable here: this reads and writes production Postgres, which is unreachable from
this machine, so the SQLite harness is the limit of what could be self-tested. The UPDATE is
plain SQLAlchemy Core with no dialect-specific constructs.
## Running it
```
python scripts/reparse_fundamentals.py # dry run, writes nothing
python scripts/reparse_fundamentals.py --apply # rewrite changed rows
```
Two cautions for whoever runs it:
- **Read the dry run for *kinds* of change, not just the count.** The tolerance 20→25 change
newly accepts facts for arbitrary filers, not only the names investigated here. Sample
changed rows for issuers that were never on the list and confirm they are recovered nulls
and corrected values — not something unexpected.
- **It refetches Company Facts for every tracked issuer** under the SEC throttle, because the
facts a fixed parser now accepts were never stored. Expect a long run; the dry run pays
that cost too, so budget for two passes.
Scope: this rewrites `fundamental_snapshots` only. Those rows currently feed the fundamentals
API/UI and the parity report — scoring still reads the legacy `fundamental_data` table, and
nothing in the backtest path touches `FundamentalSnapshot`. So a reparse **cannot** move
composite scores or backtests until the A5 cutover happens. The plan's "changed history
changes backtests" caution applies to workstream B's OHLCV rewrites, not to this.
---
# Fifth pass — period identity
The parser's own stated rule was *"period identity comes from `end == reportDate`, never
`fy/fp`"* — but `_fiscal_context()` derived the stored `fiscal_year`/`fiscal_period` by
majority-voting exactly those fy/fp fields. The labelling contradicted the module's own
principle, and SEC's labels are unreliable enough to break the quarter chain.
`_period_identity()` now derives both from `period_end` against the issuer's
`submissions.fiscalYearEnd`: **the form decides FY vs quarter** (a 10-Q can no longer be
labelled FY), and **distance to the fiscal-year end decides which quarter**. The MMDD is
threaded through `parse_snapshots(..., fiscal_year_end=...)`; without it the old fy/fp path
is used unchanged, so nothing regresses for issuers lacking a calendar.
**Rejected approach:** classifying the period by fact spans. Every 10-Q carries both a YTD
*and* a discrete fact ending at reportDate, so "best span match" reads COST's Q2 (167d) as a
Q1; and taking the *longest* span mislabelled IRM's Q3 2020 10-Q as FY because that filing
carries a 12-month fact. The prototype caught this as a regression on a working name before
any code was written. Distance-to-year-end needs no facts at all and is unambiguous — the
quarter bands sit 91 days apart, so ±35 absorbs even a 4-4-5 filer's 16-week Q4.
**Labels no longer match issuer naming in one case, deliberately.** A filer whose year ends
in early January (DPZ, `fiscalYearEnd` 0102) shifts by one. That is harmless: `fiscal_year`
and `fiscal_period` appear nowhere in the API schemas or routers — they are internal keys the
derivation uses for ordering, YTD differencing and YoY pairing, and the API surfaces
`period_end`. The requirement is uniqueness, monotonicity and YoY alignment, not nomenclature.
DPZ's derived values are byte-identical before and after the shift, which is the proof.
## Prototype evidence (before implementing)
Collisions = two period ends on one key, one silently discarded. Inversions = a period
sorting before one that precedes it.
| | CRM | FRT | STX | BXP | PPL | MTD | NTAP | WDAY | CRWD | COST | PEP | IRM | DPZ | AMCR | AAPL |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| collisions before | 1 | 1 | 0 | 0 | 4 | 5 | 2 | 3 | 2 | 0 | 0 | 0 | 1 | 0 | 0 |
| collisions after | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| inversions before | 1 | 1 | 1 | 0 | 2 | 10 | 2 | 3 | 5 | 1 | 0 | 0 | 2 | 0 | 0 |
| inversions after | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
## Validated on live data
8 of the 9 recover fully, every one matching the legacy provider to two decimals:
| name | TTM EPS | revenue growth | legacy |
|---|---|---|---|
| CRM | 8.63 | 10.9818 | 10.98 |
| FRT | 5.77 | 7.4263 | 7.43 |
| STX | 10.54 | 28.9227 | 28.92 |
| BXP | 1.99 | 1.6227 | 1.62 |
| MTD | 42.57 | 6.7785 | 6.78 |
| NTAP | 6.35 | 5.3713 | 5.37 |
| WDAY | 3.21 | 13.3165 | 13.32 |
| CRWD | 0.10 | 23.1667 | 23.17 |
**PPL is partial**: revenue growth recovers (8.3353) but TTM EPS is still null — its 2026 Q1
tags no diluted EPS variant at all, which is the open basic-vs-diluted decision, not this bug.
Note legacy claims 58.81% revenue growth for a utility; 8.34% is far more plausible.
**Two bonus recoveries**: FCX and REG had recovered EPS in the fourth pass but their revenue
growth was still blocked by label collisions. REG now reads 7.7569 against legacy's 7.76.
FCX reads 5.4378 against legacy's 24.23 — a genuine disagreement, likely the same
`Revenues` vs ASC-606 ambiguity flagged for DVN in §2, and worth resolving with that decision.
**Regression check — all byte-identical:** IRM 0.92/15.637543, COST 19.88/9.231107,
PEP 7.63/5.619741, DPZ 17.64/5.157289, AMCR null/64.834349, JPM 20.89/3.338823,
DVN 3.59/0.095648, AZO 145.39/5.740494. Nothing that worked before moved.
7 tests added at `_period_identity` covering each production shape (10-Q-labelled-FY,
December collision, January and mid-year ends, 4-4-5 quarters, the January-crossing shift,
and the no-calendar fallback). Full suite: **811 passed**.
## Reparse note
This changes `fiscal_year`/`fiscal_period` for a large share of rows — every non-December
filer, not only the broken ones. The dry-run count will be **much** larger than for the
earlier fixes, and that is expected. Read it by field: `fiscal_year`/`fiscal_period` churn is
the intended relabelling; changes to *value* columns are the recoveries.
## Where the 25 stand now
22 of 25 have a fundamental score again. Remaining: **XOM** (CIK identity, still unfixed) and
**PSKY / Q**, which are new registrants without enough filing history — correct behaviour,
not a bug.
---
# Sixth pass — CIK identity, and a much larger finding about share counts
## XOM: pinned, plus the validation that should have caught it
`company_tickers.json` maps XOM to CIK 2115436 "ExxonMobil Holdings Corp", which has **zero
XBRL filings**, while every 10-K/10-Q — including one filed 2026-05-04 — is still under CIK
34088. Which registrant is the real filer is a judgement about a corporate event, so it is
**pinned explicitly** rather than guessed:
- `sec_universe.cik_overrides()` reads a `{symbol: cik}` JSON map from
`SystemSetting['sec_cik_overrides']` and applies it ahead of `company_tickers.json`.
A malformed setting is logged and ignored, never fatal.
- **To fix XOM, set:** `sec_cik_overrides = {"XOM": 34088}`.
The more valuable half is that nothing noticed. A tracked issuer resolving to a registrant
with no XBRL filings can never produce a snapshot, and is restaged on *every* run forever.
The importer now records those in `staged.no_xbrl_filings`, reports them in the validation
summary (`no_xbrl_filings_count`), and raises a `no_xbrl_filings` SystemEvent naming the CIKs
and pointing at the override setting. It warns rather than fails — one misresolved ticker
must not block the whole import.
3 tests added. Full suite: **814 passed**.
## CHTR was not a bug, and the real problem is much bigger
I previously called this "a separate small bug". Both halves were wrong.
CHTR's `dei:EntityCommonStockSharesOutstanding` facts stop at **2016-06-30** — exactly when
the Time Warner Cable / Bright House deal closed and Charter became a multi-class issuer.
Since then the cover page reports the count **per share class**, which is dimensional, and
companyfacts is non-dimensional — so the facts are simply not in the API. Its recent filings
tag no consolidated common-share concept at all, only preferred and treasury.
This is not specific to CHTR. Of 12 issuers checked, **7 have no share count at all**:
| issuer | latest `shares_outstanding` | dei fact history |
|---|---|---|
| META | null (4/4 recent) | **never tagged** (n=0) |
| CMCSA | null (4/4 recent) | stops 2009-12-31 |
| BRK-B | null (4/4 recent) | stops 2011-04-29 |
| CHTR | null (4/4 recent) | stops 2016-06-30 |
| FOXA, NWSA, LEN | null (4/4 recent) | — |
| GOOGL / GOOG | 12,230,000,000 | works via the `us-gaap` fallback |
So **market cap is silently unavailable for a meaningful slice of the large-cap universe**,
and it is a source limitation rather than a parser defect: the two obvious workarounds are
both already-rejected design decisions — class sums are impossible (the per-class facts are
not in companyfacts at all), and the weighted-average diluted count is explicitly excluded
because market cap needs a point-in-time value.
**No code change made.** Substituting weighted-average diluted shares would silently
overturn a deliberate design decision and produce a subtly wrong market cap for exactly the
biggest, most-watched names. That is a call to make explicitly, so it is listed as a decision
below rather than quietly implemented.
---
# Seventh pass — multi-class share counts (decision taken: weighted-average fallback)
## Why this fallback, and why not the alternatives
Two candidates existed. The one **not** taken: derive the count as
`net_income ÷ diluted_eps` from columns already stored — no migration at all, and measured
accurate (GOOGL +0.48%, MRNA 0.45%, AAPL +0.19%, MSFT +0.18%). Rejected because it depends
on the derived quarter chain — the very thing these fixes have been repairing, and FOXA
already fails it — and because the two-class EPS method makes `net_income` differ from the
EPS numerator for exactly the multi-class issuers this targets.
Taken instead: store the **reported** `WeightedAverageNumberOfDilutedSharesOutstanding`.
It is the number the filer computed, needs no chain, and covers one issuer more.
| control | point-in-time | wavg diluted (latest qtr) | ratio |
|---|---|---|---|
| GOOGL | 12,230,000,000 | 12,309,000,000 | 0.9936 |
| MRNA | 396,786,259 | 395,000,000 | 1.0045 |
| AAPL | 14,687,356,000 | 14,725,873,000 | 0.9974 |
| MSFT | 7,428,434,704 | 7,445,000,000 | 0.9978 |
## Shape of the change
- **Migration 027** adds `fundamental_snapshots.weighted_avg_diluted_shares`. A separate
column, never backfilled into `shares_outstanding`, so the point-in-time column keeps its
strict meaning and the fallback stays a read-time decision.
- **Parser** stores the **shortest**-span fact ending at `period_end` (the most recent
quarter's average, closest to the current count) — deliberately not the YTD one, since an
average is not cumulative and the YTD convention does not apply.
- **Derivation** falls back only when the cover-page count is absent, and sets
`shares_outstanding_estimated`.
- **API** exposes `shares_estimated`, so `market_cap_est` and `fcf_yield` are never presented
as exact when they rest on a period average.
## Validated on live data
| issuer | shares_outstanding | estimated |
|---|---|---|
| GOOGL, AAPL, MSFT, MRNA | unchanged point-in-time values | **False** |
| META | 2,564,000,000 | True |
| CMCSA | 3,570,000,000 | True |
| CHTR | 126,849,271 | True |
| FOXA | 432,000,000 | True |
| NWSA | 555,700,000 | True |
| LEN | 240,776,000 | True |
| **BRK-B** | **still null** | False |
6 of 7 recovered, no regression on the controls. **BRK-B remains unavailable** and honestly
so: Berkshire reports per *equivalent Class A share*, dimensionally, so it has no consolidated
weighted-average fact either. Nothing in companyfacts can give it a share count.
Known caveat, accepted: for issuers using the two-class method the count is the EPS
denominator. For CHTR that is Class A only — which is also the basis on which Charter's equity
market cap is normally quoted, so it is the right number for this purpose, but it is not
"all shares of all classes".
3 tests added. Full suite: **817 passed**. Alembic single head at 027.
**Needs the reparse to land:** existing rows have `weighted_avg_diluted_shares = NULL` until
`scripts/reparse_fundamentals.py --apply` runs, so market cap stays missing for these issuers
until then.
---
# Eighth pass — revenue basis (decision: keep ASC-606, no change)
The two concepts measure different things: `RevenueFromContractWithCustomerExcludingAssessedTax`
is customer-contract revenue (an E&P's oil/gas/NGL sales), while `Revenues` is the total
income-statement line, which for commodity producers folds in mark-to-market derivative
gains/losses. That is why DVN's ASC-606 figure is *larger*: 4,508M of sales minus ~701M of
hedging losses gives the 3,807M `Revenues` line.
Measured across 21 issuers (deliberately energy-weighted, where the gap concentrates):
- Both tags present and differing >1%: **5 of 21** — DVN +18.4%, COP 14.3%, OXY +6.5%,
FCX 2.8%, PPL +1.6%. Everyone else tags one, or they are identical (COST +0.0%).
- Concept choice **flips within an issuer's chain: 0 of 21**. Whichever tag wins, the series
is internally consistent, so YoY never compares two definitions.
**Decision: keep ASC-606 first, change nothing.** Derivative gains/losses are mean-reverting
and sign-flipping; folding them into "revenue growth" turns the sub-score into a partial
hedging-P&L read for exactly the affected names. The consistency argument for switching is
empirically absent (zero flips), and changing would churn every dual-tagging issuer's stored
value — widening the reparse diff — to make ~5 names noisier.
**Correction to the fourth/fifth-pass note:** FCX's disagreement with legacy (+5.44% vs
24.23%) is **not** this ambiguity. Its two tags differ by only 2.8%, and FCX's own revenue
rose 22,703M → 25,186M YoY, so 24% is not credible — legacy is simply wrong there, and this
decision does not touch it. So the basis choice moves only DVN, COP, OXY.
The mirror hazard — an issuer where ASC-606 is only a *fragment* of revenue (a bank's fee
income) — was checked (all 15 recovered banks resolve total revenue, not a fragment). A
fragment-detection warning was prototyped and then **removed**: with no UI surface it would
only have lived in the run summary, and the case it guards against is not currently present.
Documented and closed rather than shipped as dead plumbing. If a fragment case ever appears,
it shows up as an implausibly low revenue in the next parity report.
---
# Ninth pass — basic-EPS fallback (PPL) and HAL resolved
## PPL: basic-EPS fallback (decision taken)
PPL's 2026 Q1 tags no diluted EPS variant at all, only basic — a single-filing omission
(its other quarters tag diluted), but that one missing period broke the quarter chain and
nulled TTM. `EarningsPerShareBasic` / `IncomeLossFromContinuingOperationsPerBasicShare` are
now appended to `_EPS_CONCEPTS`, last, so they only fire when no diluted variant exists.
Evidence (19-name scan): a basic fallback helps exactly **1 name (PPL)**. Basic-vs-diluted is
~0.51.2% for most, +1.2% for PPL. The one name where it genuinely diverges (TSLA +13.3%)
already tags diluted, so it never reaches the fallback. Basic is always ≥ diluted, so the
result slightly overstates EPS / understates P/E — accepted, since it fires only on an
otherwise-null period.
Validated: PPL TTM EPS null → **1.63** (≈$36 / 1.63 = 22.1 vs legacy P/E 22.43). AAPL, MSFT,
DUK, HAL unchanged — diluted still wins wherever present. 2 tests added. Full suite: **819
passed**.
## HAL: resolved, and it was never our bug
HAL's TTM EPS is now **1.81** (≈$33 / 1.81 = 18.2 vs legacy P/E 18.01) — the period-identity
and EPS-concept work already fixed it. The "unexplained null" is closed.
Its 2024 EPS values are garbage (680000, 1480000, …) because **Halliburton's own 2024 XBRL
tags `EarningsPerShareDiluted = 680000` in unit USD/shares** — a filer scale error in the
source, faithfully stored. It only poisons TTM windows that include 2024, which the current
point-in-time report does not use, so no code change: clamping EPS to "plausible" values would
risk masking real ones. Documented as a known source-data quirk.
This does surface a latent robustness point (not acted on): a single fat-fingered per-share
value poisons any TTM window it lands in. It is invisible in the current report and out of
scope here, but worth a note if historical TTM series are ever surfaced.
## All 25 lost names accounted for
| status | names |
|---|---|
| **recovered** (22) | ARE, AZO, BXP, COST, CRM, CRWD, DPZ, DVN, FCX, FRT, HAL, KHC, MOS, MTD, NTAP, PEP, PPL, REG, SJM, STX, SWKS, WDAY |
| **XOM** | fixed by the `sec_cik_overrides` pin (needs the setting applied) |
| **PSKY, Q** | new registrants without enough filing history — correct behaviour, not a bug |
## Still outstanding
Revised after the second pass, in the order I would take them:
Everything actionable without a live database is now done. What remains is one hard
data limitation and two operational steps that only run against production.
1. ~~**§3a fiscal-period identity**~~**done**, fifth pass.
2. ~~**§1 split contamination, part (a)**~~**done**, third pass.
3. ~~**§5 EPS concept gap**~~**done**, third pass.
4. ~~**§4 XOM CIK remap** + zero-filings validation~~**done**, sixth pass.
5. ~~**Reparse path**~~**done**, fourth pass.
6. ~~**Multi-class share counts**~~**done**, seventh pass (weighted-average fallback).
7. ~~**DVN/FCX revenue basis**~~**decided**, eighth pass (keep ASC-606, no change).
8. ~~**PPL basic-EPS fallback**~~**done**, ninth pass.
9. ~~**HAL null TTM EPS**~~**resolved**, ninth pass (already fixed; 2024 is a filer error).
## Review finding — two fixes on this branch silently interacted
Caught in review, not by me. `_merge_amendments` (the per-field amendment fix, first pass)
builds the merged period from `_MERGED_FIELDS` + `_CARRIED_FIELDS` alone, so a column in
neither list is **absent** from the merged row, not merely stale — and every caller reads it
with `getattr(row, name, None)`, which quietly returns `None`.
`weighted_avg_diluted_shares` (the market-cap fallback, seventh pass) was never added to
`_MERGED_FIELDS`. The failure needed both fixes to be present at once: a multi-class issuer
*and* a partial amendment on its latest period — META with a Part-III-only 10-K/A — would
silently lose market cap and FCF yield again, i.e. the seventh pass's fix undone by the
first pass's mechanism. I updated `_SNAPSHOT_COLS` in the importer when adding the column
but not `_MERGED_FIELDS` in the derivation.
Fixed, with a regression test for the specific case. The more useful addition is a guard —
`test_merge_lists_cover_every_parser_field` asserts the two lists cover every `SnapshotRow`
field, so the *next* column added fails loudly instead of losing data quietly. Verified it
would have caught this one.
Lesson worth keeping: a hand-maintained field list that reconstructs an object is a silent
data-loss footgun. `_SNAPSHOT_COLS` (importer) and `_MERGED_FIELDS` (derivation) must both
track the parser's `SnapshotRow`, and only one of them is now enforced by a test.
## Genuinely unfixable from this data
- **§1 part (b)** — a split post-dating the last filing (KLAC). No snapshot carries
share-count evidence, so no guard built on share counts can fire. Needs a corporate-actions
source or an external market-cap reconciliation.
- **BRK-B market cap** — Berkshire reports per equivalent Class A share, dimensionally, so it
has neither a cover-page count nor a weighted-average one. Nothing in companyfacts can give
it a share count.
## Operational steps (production only — cannot run from here)
- Apply the setting `sec_cik_overrides = {"XOM": 34088}`.
- Run `scripts/reparse_fundamentals.py` — dry run first, then `--apply`. This is what lands
every parser-side fix (revenue/EPS concepts, Q3 span, period identity, weighted-average
shares via migration 027) onto existing rows. Until it runs, those fixes are inert in prod.
## Standing decision, revisit only if it bites
- **COF-class share-change threshold** — kept at 25%. Revisit only if the 3.2% universe
hit-rate proves painful.
## Known source-data quirk, not acted on
- A single fat-fingered per-share value in a filer's XBRL (HAL 2024) poisons any TTM window
it lands in. Invisible in the current point-in-time report; relevant only if historical TTM
series are ever surfaced.
PSKY and Q need nothing — they are new registrants without enough filing history, which is
correct behaviour.
---
# Closing — post-reparse verification (2026-07-24)
## Production reparse
Two apply runs against prod (`scripts/reparse_fundamentals.py --apply`):
- **Run 6** (all fixes through the seventh pass): 262 inserted, 28,664 rewritten —
99.4% of which was backfilling the new `weighted_avg_diluted_shares` column; the
behavioural churn matched the dry run exactly. The five duration facts clustering at
190234 changed rows each is the 4-4-5 Q3 recovery signature. `accepted_at` changed on
only 76 rows (0.25%), confirming the tz-comparison fix works against real Postgres.
- **Run 7** (after the fiscal-year-end fix below): 3 inserted, 322 rewritten — BEN, DELL,
and boundary-year relabels for 53-week filers whose derived MMDD shifted a few days.
`cik_updates: 1` on run 6 was the XOM pin taking effect; XOM now has 68 snapshot rows,
latest period end 2026-03-31.
## Regression caught by the collision check — and its fix
The before/after key-collision query (~130 rows max 6 → 44 rows all 2) surfaced one real
regression: **BEN**. `submissions.fiscalYearEnd` declares `1231` while every Franklin
Resources 10-K ends 09-30, so `_period_identity` — which trusted the declared value — put
BEN's real fiscal Q1 zero days from the claimed year end (no band matched) and labelled its
fiscal Q2 as Q1. The collision discarded a period and BEN lost TTM EPS and revenue growth it
had before the branch. Fixed in `3d42ca7`: `resolve_fiscal_year_end()` prefers the issuer's
own most recent 10-K reportDate (which *is* the fiscal year end by definition) and treats
the declared field as fallback. Full-universe scan: 2 of 506 issuers mis-declare (BEN 91d,
DELL 29d); both now derive correctly (BEN rg 3.8243 vs legacy 3.82; DELL 38.5735 vs 38.57).
Residual collisions after run 7: 36 rows, all count-2, **latest year 2023** — the 53-week
drift class (AVY/CDNS/RVTY/JNJ/TDY/DPZ at 56-year intervals). Newest-wins degrades one
historical FY row; no current period is affected. Left alone deliberately: eliminating them
means modelling each filer's actual 52/53-week calendar per year, for rows feeding no
current metric.
## The verdict: 2026-07-24 parity report vs the 2026-07-23 baseline
| metric | baseline | after | |
|---|---|---|---|
| candidate scored | 482 | **504** | legacy scores 507; gap = PSKY, Q (new registrants) + FITB |
| revenue_growth candidate available | 442 | **489** | banks, REITs, 4-4-5 recovered |
| pe_ratio candidate available | 432 | **452** | net of the split-guard nulls |
| revenue_growth median abs delta | 0.0038 | **0.0038** | 47 names added at unchanged agreement |
| pe_ratio median / p95 abs delta | 0.5883 / 7.73 | **0.5576 / 6.03** | corrupted outliers gone |
Revenue `material_differences` rose 84 → 96: the newly compared names include the cases
where **legacy is the wrong side** (JPM 108.98% vs 3.34%, PPL 58.81% vs 8.34%, FCX 24.23%
vs +5.44%). Material is symmetric; these flag the provider being corrected.
The split guard is visible in the report: BKNG (1.10), COF, TPL, AMCR, WAT all null P/E now.
**KLAC (6.19) is the one known-wrong value left** — the post-filing split documented as
unfixable without a corporate-actions source.
**Correction to the seventh pass:** the claim that guard-tripped issuers "keep their
fundamental score, losing one of three inputs" fails for **FITB**, the one name that also
lacks revenue growth (its recent filings tag only ASC-606 fee-income fragments, then nothing)
— nulling its contaminated P/E (Comerica merger, 661M → 902M shares) drops it to one metric
and no score. Accepted: the composite renormalises, and legacy's 58% "revenue growth" for a
bank was itself junk.
## Recommendation
The A5 gate evidence now supports approving the cutover: coverage within 3 of legacy with
every gap explained, agreement essentially exact where both sides exist, every corrupted
value either fixed or deliberately nulled with a caveat, and the remaining score deltas are
documented definition differences — called out, not averaged away, as the plan requires.
Carry KLAC as the one known caveat in the approval note.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,114 @@
# Focused daily portfolio-capacity matrix
Generated: 2026-08-05T19:25:17.150472+00:00
## Question
The current daily Phase A control admitted 472 trades and rejected 519 qualified opportunities because the ten-slot book was full. This run brackets the economic cost of that binding constraint; it has no formal promotion gate.
> Universe caveat: today's production membership is projected backward. Use paired arm-versus-control differences, not absolute profitability, for construction conclusions.
## Validated universes
- Tradable setup symbols with prices: 505.
- Rank-only symbols with prices: 4149.
- Full ranking symbols with prices: 4654.
- Tradable qualified longs: 6118.
- Rank-only qualified rows removed: 136286.
## Paired annual medians
### Empty Book — 0.10% per fill
| Arm | ΔEV net R | 90% context | ΔCalmar | 90% context |
|---|---:|---:|---:|---:|
| cap10_incumbent | 0.000 | [0.000, 0.000] | 0.000 | [0.000, 0.000] |
| cash_unbounded | 0.044 | [-0.011, 0.060] | 0.030 | [-0.030, 0.120] |
| cap10_weekly_top10 | 0.000 | [-0.091, 0.000] | 0.000 | [-0.260, 0.000] |
| cap15_incumbent | 0.000 | [0.000, 0.011] | 0.000 | [0.000, 0.130] |
| Arm | ΔPF | ΔGain-to-Pain | ΔSortino | ΔCAGR pp | ΔMaxDD pp |
|---|---:|---:|---:|---:|---:|
| cap10_incumbent | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 |
| cash_unbounded | 0.079 | 0.047 | -0.013 | 1.350 | 0.000 |
| cap10_weekly_top10 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 |
| cap15_incumbent | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 |
### Warm Book — 0.10% per fill
| Arm | ΔEV net R | 90% context | ΔCalmar | 90% context |
|---|---:|---:|---:|---:|
| cap10_incumbent | 0.000 | [0.000, 0.000] | 0.000 | [0.000, 0.000] |
| cash_unbounded | 0.034 | [-0.014, 0.100] | 0.050 | [-0.160, 0.250] |
| cap10_weekly_top10 | 0.000 | [-0.158, 0.065] | 0.000 | [-0.200, 0.330] |
| cap15_incumbent | 0.000 | [-0.006, 0.000] | 0.000 | [0.000, 0.180] |
| Arm | ΔPF | ΔGain-to-Pain | ΔSortino | ΔCAGR pp | ΔMaxDD pp |
|---|---:|---:|---:|---:|---:|
| cap10_incumbent | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 |
| cash_unbounded | 0.062 | 0.085 | -0.004 | 2.200 | 0.400 |
| cap10_weekly_top10 | 0.000 | 0.012 | 0.018 | 0.300 | 0.000 |
| cap15_incumbent | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 |
### Empty Book — 0.20% per fill
| Arm | ΔEV net R | 90% context | ΔCalmar | 90% context |
|---|---:|---:|---:|---:|
| cap10_incumbent | 0.000 | [0.000, 0.000] | 0.000 | [0.000, 0.000] |
| cash_unbounded | 0.041 | [-0.010, 0.052] | 0.030 | [-0.015, 0.100] |
| cap10_weekly_top10 | 0.000 | [-0.090, 0.000] | 0.000 | [-0.260, 0.000] |
| cap15_incumbent | 0.000 | [0.000, 0.010] | 0.000 | [0.000, 0.110] |
| Arm | ΔPF | ΔGain-to-Pain | ΔSortino | ΔCAGR pp | ΔMaxDD pp |
|---|---:|---:|---:|---:|---:|
| cap10_incumbent | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 |
| cash_unbounded | 0.066 | 0.035 | -0.014 | 0.900 | 0.000 |
| cap10_weekly_top10 | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 |
| cap15_incumbent | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 |
### Warm Book — 0.20% per fill
| Arm | ΔEV net R | 90% context | ΔCalmar | 90% context |
|---|---:|---:|---:|---:|
| cap10_incumbent | 0.000 | [0.000, 0.000] | 0.000 | [0.000, 0.000] |
| cash_unbounded | 0.034 | [-0.022, 0.102] | 0.040 | [-0.130, 0.230] |
| cap10_weekly_top10 | 0.000 | [-0.158, 0.065] | 0.000 | [-0.190, 0.310] |
| cap15_incumbent | 0.000 | [-0.006, 0.000] | 0.000 | [0.000, 0.170] |
| Arm | ΔPF | ΔGain-to-Pain | ΔSortino | ΔCAGR pp | ΔMaxDD pp |
|---|---:|---:|---:|---:|---:|
| cap10_incumbent | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 |
| cash_unbounded | 0.060 | 0.083 | -0.003 | 2.100 | 0.300 |
| cap10_weekly_top10 | 0.000 | 0.017 | 0.020 | 0.300 | 0.000 |
| cap15_incumbent | 0.000 | 0.000 | 0.000 | 0.000 | 0.000 |
## Warm-seed initialization dispersion
| Arm | Cost/fill | Median EV IQR ratio | Median Calmar IQR ratio |
|---|---:|---:|---:|
| cap10_incumbent | 0.10% | 1.000 | 1.000 |
| cash_unbounded | 0.10% | 1.000 | 1.000 |
| cap10_weekly_top10 | 0.10% | 1.000 | 1.000 |
| cap15_incumbent | 0.10% | 1.000 | 1.000 |
| cap10_incumbent | 0.20% | 1.000 | 1.000 |
| cash_unbounded | 0.20% | 1.000 | 1.000 |
| cap10_weekly_top10 | 0.20% | 1.000 | 1.000 |
| cap15_incumbent | 0.20% | 1.000 | 1.000 |
## Capacity and operations — 0.10% per fill
| Arm | Median trades | Median blocked | Median positions | Peak | Turnover | Min-risk rejects |
|---|---:|---:|---:|---:|---:|---:|
| cap10_incumbent | 76.0 | 21.6% | 4.98 | 10 | 26.36 | 0 |
| cash_unbounded | 74.0 | 0.0% | 4.82 | 12 | 26.76 | 85517 |
| cap10_weekly_top10 | 88.0 | 18.1% | 5.13 | 10 | 28.44 | 0 |
| cap15_incumbent | 79.0 | 0.0% | 5.15 | 12 | 27.32 | 0 |
## Weekly-ranking opportunity set
- Median fresh entrant pool: 0.0.
- Median zero-entrant fraction: 0.558.
- Replacements across reported paths: 2170.
- Same-symbol re-entries within 10 sessions: 529.
Bootstrap intervals above resample seven annual summaries and are descriptive context only. They are not gates or independent-population confidence claims.
+764
View File
@@ -0,0 +1,764 @@
'''Pure helpers for the focused daily portfolio-capacity research matrix.'''
from __future__ import annotations
import hashlib
import math
import random
import statistics
from collections import defaultdict
from datetime import date, timedelta
from typing import Any, Iterable
ARMS: tuple[dict[str, Any], ...] = (
{
'id': 'cap10_incumbent',
'label': 'Cap 10, arrival-order incumbents',
'max_positions': 10,
'min_initial_risk_fraction': None,
'weekly_top_n_rebalance': False,
},
{
'id': 'cash_unbounded',
'label': 'Cash-constrained, no count cap',
'max_positions': None,
'min_initial_risk_fraction': 0.005,
'weekly_top_n_rebalance': False,
},
{
'id': 'cap10_weekly_top10',
'label': 'Cap 10, weekly current-rank top 10',
'max_positions': 10,
'min_initial_risk_fraction': None,
'weekly_top_n_rebalance': True,
},
{
'id': 'cap15_incumbent',
'label': 'Cap 15, arrival-order incumbents',
'max_positions': 15,
'min_initial_risk_fraction': None,
'weekly_top_n_rebalance': False,
},
)
ARM_BY_ID = {arm['id']: arm for arm in ARMS}
RISK_FLOOR_ARMS: tuple[dict[str, Any], ...] = (
ARMS[0],
{
'id': 'cap10_min_risk_005',
'label': 'Cap 10, 0.5% minimum effective initial risk',
'max_positions': 10,
'min_initial_risk_fraction': 0.005,
'weekly_top_n_rebalance': False,
},
)
COSTS_PER_SIDE_PCT = (0.1, 0.2)
ANCHOR_YEARS = tuple(range(2019, 2026))
SCORING_SESSIONS = 504
MEASUREMENT_SESSIONS = 252
RESIDUAL_BENCHMARK_SESSIONS = 252
WARM_SEED_MIN_OFFSET = 63
WARM_SEED_MAX_OFFSET = 126
BOOTSTRAP_REPLICATES = 10_000
BOOTSTRAP_SEED = 20260805
PRIMARY_METRICS = (
'ev_net_r',
'calmar',
'profit_factor',
'gain_to_pain',
'sortino',
)
PAIRED_METRICS = (
*PRIMARY_METRICS,
'cagr_pct',
'max_drawdown_pct',
'total_return_pct',
'sharpe',
)
def _end_exclusive(
sessions: list[date], start_index: int, count: int
) -> date:
end_index = start_index + count
if end_index < len(sessions):
return sessions[end_index]
return sessions[-1] + timedelta(days=1)
def build_cohort_manifest(session_dates: Iterable[date]) -> dict[str, Any]:
sessions = sorted(set(session_dates))
minimum = RESIDUAL_BENCHMARK_SESSIONS + SCORING_SESSIONS
if len(sessions) <= minimum + MEASUREMENT_SESSIONS:
raise ValueError('Snapshot is too short for the frozen cohort design')
index_of = {session: index for index, session in enumerate(sessions)}
first_eligible_index = RESIDUAL_BENCHMARK_SESSIONS - 1 + SCORING_SESSIONS
last_eligible_index = len(sessions) - MEASUREMENT_SESSIONS
first_by_month: dict[tuple[int, int], date] = {}
for session in sessions:
first_by_month.setdefault((session.year, session.month), session)
empty: list[dict[str, Any]] = []
for (year, month), session in sorted(first_by_month.items()):
index = index_of[session]
if year not in ANCHOR_YEARS:
continue
if index < first_eligible_index or index > last_eligible_index:
continue
empty.append({
'protocol': 'empty_book',
'path_id': f'empty-{year:04d}-{month:02d}',
'cluster': year,
'simulation_start': session.isoformat(),
'measurement_start': session.isoformat(),
'hard_end_exclusive': _end_exclusive(
sessions, index, MEASUREMENT_SESSIONS
).isoformat(),
})
first_by_year: dict[int, date] = {}
for session in sessions:
first_by_year.setdefault(session.year, session)
warm: list[dict[str, Any]] = []
warm_seed_counts: dict[str, int] = {}
for year in ANCHOR_YEARS:
anchor = first_by_year.get(year)
if anchor is None:
continue
anchor_index = index_of[anchor]
if (
anchor_index < WARM_SEED_MAX_OFFSET
or anchor_index > last_eligible_index
):
continue
seed_window = sessions[
anchor_index - WARM_SEED_MAX_OFFSET:
anchor_index - WARM_SEED_MIN_OFFSET + 1
]
first_by_iso_week: dict[tuple[int, int], date] = {}
for session in seed_window:
iso = session.isocalendar()
first_by_iso_week.setdefault((iso.year, iso.week), session)
seeds = sorted(first_by_iso_week.values())
warm_seed_counts[str(year)] = len(seeds)
for seed_index, seed in enumerate(seeds, 1):
warm.append({
'protocol': 'warm_book',
'path_id': f'warm-{year}-seed-{seed_index:02d}',
'cluster': year,
'simulation_start': seed.isoformat(),
'measurement_start': anchor.isoformat(),
'hard_end_exclusive': _end_exclusive(
sessions, anchor_index, MEASUREMENT_SESSIONS
).isoformat(),
'seed_offset_sessions': anchor_index - index_of[seed],
})
return {
'snapshot_first_session': sessions[0].isoformat(),
'snapshot_last_session': sessions[-1].isoformat(),
'session_count': len(sessions),
'expected_clusters': list(ANCHOR_YEARS),
'empty_book': empty,
'warm_book': warm,
'empty_cluster_counts': dict(
sorted(
(
str(year),
sum(1 for row in empty if row['cluster'] == year),
)
for year in {row['cluster'] for row in empty}
)
),
'warm_seed_counts': warm_seed_counts,
'empty_cluster_count': len({row['cluster'] for row in empty}),
'warm_cluster_count': len({row['cluster'] for row in warm}),
}
def validate_cohort_manifest(manifest: dict[str, Any]) -> list[str]:
errors: list[str] = []
expected = set(ANCHOR_YEARS)
empty_clusters = {row['cluster'] for row in manifest['empty_book']}
warm_clusters = {row['cluster'] for row in manifest['warm_book']}
if empty_clusters != expected:
errors.append(
f'empty-book clusters {sorted(empty_clusters)} != {sorted(expected)}'
)
if warm_clusters != expected:
errors.append(
f'warm-book clusters {sorted(warm_clusters)} != {sorted(expected)}'
)
for year in ANCHOR_YEARS:
seed_count = int(manifest['warm_seed_counts'].get(str(year), 0))
if seed_count < 12:
errors.append(f'warm anchor {year} has only {seed_count} seeds')
return errors
def build_cells(
manifest: dict[str, Any],
*,
arms: tuple[dict[str, Any], ...] = ARMS,
protocols: tuple[str, ...] = ('empty_book', 'warm_book'),
costs: tuple[float, ...] = COSTS_PER_SIDE_PCT,
) -> list[dict[str, Any]]:
paths = [
path
for protocol in protocols
for path in manifest[protocol]
]
cells: list[dict[str, Any]] = []
for cost in costs:
for path in paths:
for arm in arms:
cell_id = (
f'{arm["id"]}|{path["protocol"]}|{path["path_id"]}'
f'|cost={cost:.1f}'
)
cells.append({
**path,
'cell_id': cell_id,
'arm_id': arm['id'],
'cost_per_side_pct': cost,
})
return cells
def percentile(values: Iterable[float], probability: float) -> float | None:
ordered = sorted(float(value) for value in values if value is not None)
if not ordered:
return None
if len(ordered) == 1:
return ordered[0]
location = (len(ordered) - 1) * probability
lower = math.floor(location)
upper = math.ceil(location)
if lower == upper:
return ordered[lower]
weight = location - lower
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
def iqr(values: Iterable[float]) -> float | None:
clean: list[float] = []
for value in values:
if value is None:
continue
parsed = float(value)
if math.isfinite(parsed):
clean.append(parsed)
q25 = percentile(clean, 0.25)
q75 = percentile(clean, 0.75)
if q25 is None or q75 is None:
return None
return q75 - q25
def median(values: Iterable[float | None]) -> float | None:
clean = [float(value) for value in values if value is not None]
return statistics.median(clean) if clean else None
def _safe_ratio(numerator: float | None, denominator: float | None) -> float | None:
if numerator is None or denominator is None:
return None
if abs(denominator) <= 1e-12:
return 1.0 if abs(numerator) <= 1e-12 else None
return numerator / denominator
def _stable_seed(*parts: object) -> int:
digest = hashlib.sha256('|'.join(map(str, parts)).encode('utf-8')).digest()
return BOOTSTRAP_SEED + int.from_bytes(digest[:4], 'big')
def bootstrap_median_interval(
values: Iterable[float | None],
*,
seed_parts: tuple[object, ...],
replicates: int = BOOTSTRAP_REPLICATES,
) -> dict[str, float | int | None]:
clean = [float(value) for value in values if value is not None]
if not clean:
return {'n': 0, 'point': None, 'p05': None, 'p95': None}
rng = random.Random(_stable_seed(*seed_parts))
draws = [
statistics.median(rng.choices(clean, k=len(clean)))
for _ in range(replicates)
]
return {
'n': len(clean),
'replicates': replicates,
'point': statistics.median(clean),
'p05': percentile(draws, 0.05),
'p95': percentile(draws, 0.95),
}
def _monthly_returns(
equity_curve: list[dict[str, Any]], base_equity: float
) -> list[float]:
month_ends: dict[tuple[int, int], float] = {}
for point in equity_curve:
point_date = date.fromisoformat(str(point['date']))
month_ends[(point_date.year, point_date.month)] = float(point['equity'])
previous = float(base_equity)
returns: list[float] = []
for month in sorted(month_ends):
equity = month_ends[month]
if previous > 0:
returns.append(equity / previous - 1.0)
previous = equity
return returns
def _time_underwater(equities: list[float]) -> tuple[int, float]:
peak = float('-inf')
current = 0
longest = 0
underwater = 0
for equity in equities:
peak = max(peak, equity)
if peak > 0 and equity < peak - 1e-9:
current += 1
underwater += 1
longest = max(longest, current)
else:
current = 0
percentage = underwater / len(equities) * 100.0 if equities else 0.0
return longest, percentage
def summarize_simulation(sim: dict[str, Any]) -> dict[str, Any]:
trades = list(sim.get('trade_details') or [])
equity_curve = list(sim.get('equity_curve') or [])
net_rs = [float(trade['net_r']) for trade in trades]
positive_rs = [value for value in net_rs if value > 0]
negative_rs = [value for value in net_rs if value < 0]
ev_net_r = statistics.fmean(net_rs) if net_rs else None
profit_factor = (
sum(positive_rs) / abs(sum(negative_rs))
if negative_rs
else None
)
base_equity = float(
sim.get('measurement_start_equity') or sim.get('starting_capital') or 0.0
)
curve_equities = [float(point['equity']) for point in equity_curve]
daily_equities = [base_equity, *curve_equities]
daily_returns = [
current / previous - 1.0
for previous, current in zip(daily_equities, daily_equities[1:])
if previous > 0
]
downside_deviation = (
math.sqrt(
statistics.fmean(min(value, 0.0) ** 2 for value in daily_returns)
)
if daily_returns
else None
)
sortino = (
statistics.fmean(daily_returns) / downside_deviation * math.sqrt(252.0)
if downside_deviation is not None and downside_deviation > 0
else None
)
monthly_returns = _monthly_returns(equity_curve, base_equity)
negative_monthly = sum(value for value in monthly_returns if value < 0)
gain_to_pain = (
sum(monthly_returns) / abs(negative_monthly)
if negative_monthly < 0
else None
)
longest_underwater, underwater_pct = _time_underwater(daily_equities)
transaction_cost = sum(
float(trade.get('transaction_cost') or 0.0) for trade in trades
)
traded_notional = sum(
float(trade.get('shares') or 0.0)
* (float(trade.get('entry') or 0.0) + float(trade.get('fill') or 0.0))
for trade in trades
)
turnover_multiple = (
traded_notional / base_equity if base_equity > 0 else None
)
ordered_rs = sorted(net_rs, reverse=True)
ev_without_best: dict[str, float | None] = {}
for count in (1, 5, 10):
remaining = ordered_rs[count:]
ev_without_best[str(count)] = (
statistics.fmean(remaining) if remaining else None
)
events = list(sim.get('weekly_rebalance_events') or [])
entrant_sizes = [int(event['fresh_entrant_pool']) for event in events]
eligible_sizes = [
int(event['rank_eligible_entrant_pool']) for event in events
]
replacements = [int(event['replacements']) for event in events]
capacity_skips = int(
sim.get('measurement_skipped_book_full', sim.get('skipped_book_full', 0))
)
opened = int(sim.get('opened_positions', sim.get('trades', 0)))
capacity_opportunities = opened + capacity_skips
result = {
'start_date': sim.get('start_date'),
'end_date': sim.get('end_date'),
'simulation_start_date': sim.get('simulation_start_date'),
'measurement_start_equity': base_equity,
'measurement_start_positions': sim.get('measurement_start_positions', 0),
'trades': len(trades),
'ev_net_r': ev_net_r,
'profit_factor': profit_factor,
'gain_to_pain': gain_to_pain,
'sortino': sortino,
'ev_without_best': ev_without_best,
'total_return_pct': sim.get('total_return_pct'),
'cagr_pct': sim.get('cagr_pct'),
'max_drawdown_pct': sim.get('max_drawdown_pct'),
'calmar': sim.get('calmar'),
'sharpe': sim.get('sharpe'),
'win_rate': sim.get('win_rate'),
'avg_hold_days': sim.get('avg_hold_days'),
'longest_underwater_sessions': longest_underwater,
'underwater_pct': underwater_pct,
'transaction_cost': transaction_cost,
'turnover_multiple': turnover_multiple,
'skipped_book_full': capacity_skips,
'opened_positions': opened,
'capacity_opportunities': capacity_opportunities,
'blocked_fraction': (
capacity_skips / capacity_opportunities
if capacity_opportunities
else 0.0
),
'skipped_min_initial_risk': int(
sim.get('measurement_skipped_min_initial_risk', 0)
),
'avg_positions': sim.get('avg_positions'),
'peak_positions': sim.get('peak_positions'),
'sessions_at_capacity': sim.get('sessions_at_capacity'),
'sessions_measured': sim.get('sessions_measured'),
'avg_cash_pct': sim.get('avg_cash_pct'),
'avg_gross_exposure_pct': sim.get('avg_gross_exposure_pct'),
'exit_reasons': sim.get('exit_reasons'),
}
if events:
result['weekly_rebalance'] = {
'events': len(events),
'zero_entrant_fraction': (
sum(1 for value in entrant_sizes if value == 0) / len(events)
),
'entrant_pool_mean': statistics.fmean(entrant_sizes),
'entrant_pool_median': statistics.median(entrant_sizes),
'entrant_pool_p90': percentile(entrant_sizes, 0.9),
'eligible_pool_mean': statistics.fmean(eligible_sizes),
'replacements': sum(replacements),
'weekly_rank_rejected_entries': int(
sim.get('weekly_rank_rejected_entries', 0)
),
'reentries_within_5_sessions': int(
sim.get('rebalance_reentries_within_5_sessions', 0)
),
'reentries_within_10_sessions': int(
sim.get('rebalance_reentries_within_10_sessions', 0)
),
'reentries_within_20_sessions': int(
sim.get('rebalance_reentries_within_20_sessions', 0)
),
}
return result
def _cluster_rows(
cells: list[dict[str, Any]],
*,
arm_id: str,
protocol: str,
cost: float,
) -> list[dict[str, Any]]:
treatment = {
row['path_id']: row
for row in cells
if row['arm_id'] == arm_id
and row['protocol'] == protocol
and float(row['cost_per_side_pct']) == cost
}
control = {
row['path_id']: row
for row in cells
if row['arm_id'] == 'cap10_incumbent'
and row['protocol'] == protocol
and float(row['cost_per_side_pct']) == cost
}
shared_paths = sorted(set(treatment) & set(control))
by_cluster: dict[int, list[tuple[dict, dict]]] = defaultdict(list)
for path_id in shared_paths:
row = treatment[path_id]
by_cluster[int(row['cluster'])].append((row, control[path_id]))
summaries: list[dict[str, Any]] = []
for cluster, pairs in sorted(by_cluster.items()):
metrics: dict[str, Any] = {}
for metric in PAIRED_METRICS:
arm_values = [
pair[0]['metrics'].get(metric)
for pair in pairs
if pair[0]['metrics'].get(metric) is not None
and math.isfinite(float(pair[0]['metrics'][metric]))
]
control_values = [
pair[1]['metrics'].get(metric)
for pair in pairs
if pair[1]['metrics'].get(metric) is not None
and math.isfinite(float(pair[1]['metrics'][metric]))
]
deltas = [
float(arm['metrics'][metric])
- float(base['metrics'][metric])
for arm, base in pairs
if arm['metrics'].get(metric) is not None
and base['metrics'].get(metric) is not None
and math.isfinite(float(arm['metrics'][metric]))
and math.isfinite(float(base['metrics'][metric]))
]
arm_median = median(arm_values)
control_median = median(control_values)
metrics[metric] = {
'arm_median': arm_median,
'control_median': control_median,
'paired_delta_median': median(deltas),
'arm_control_ratio': _safe_ratio(
arm_median, control_median
),
'paired_paths': len(deltas),
}
summaries.append({
'cluster': cluster,
'paths': len(pairs),
'metrics': metrics,
})
return summaries
def aggregate_results(
cells: list[dict[str, Any]],
*,
arms: tuple[dict[str, Any], ...] = ARMS,
protocols: tuple[str, ...] = ('empty_book', 'warm_book'),
costs: tuple[float, ...] = COSTS_PER_SIDE_PCT,
include_warm_dispersion: bool = True,
) -> dict[str, Any]:
paired: list[dict[str, Any]] = []
path_distributions: list[dict[str, Any]] = []
for cost in costs:
for protocol in protocols:
control_by_path = {
row['path_id']: row
for row in cells
if row['arm_id'] == 'cap10_incumbent'
and row['protocol'] == protocol
and float(row['cost_per_side_pct']) == float(cost)
}
for arm in arms:
arm_id = str(arm['id'])
clusters = _cluster_rows(
cells,
arm_id=arm_id,
protocol=protocol,
cost=float(cost),
)
headline: dict[str, Any] = {}
for metric in PAIRED_METRICS:
deltas = [
cluster['metrics'][metric]['paired_delta_median']
for cluster in clusters
]
arm_levels = [
cluster['metrics'][metric]['arm_median']
for cluster in clusters
]
control_levels = [
cluster['metrics'][metric]['control_median']
for cluster in clusters
]
arm_level = median(arm_levels)
control_level = median(control_levels)
metric_summary: dict[str, Any] = {
'paired_delta_median': median(deltas),
'arm_median': arm_level,
'control_median': control_level,
'arm_control_ratio': _safe_ratio(
arm_level, control_level
),
}
if metric in ('ev_net_r', 'calmar'):
metric_summary['bootstrap_90'] = (
bootstrap_median_interval(
deltas,
seed_parts=(
arm_id,
protocol,
cost,
metric,
'paired-delta',
),
)
)
headline[metric] = metric_summary
paired.append({
'arm_id': arm_id,
'protocol': protocol,
'cost_per_side_pct': cost,
'clusters': clusters,
'headline': headline,
})
treatment_by_path = {
row['path_id']: row
for row in cells
if row['arm_id'] == arm_id
and row['protocol'] == protocol
and float(row['cost_per_side_pct']) == float(cost)
}
shared_paths = sorted(
set(treatment_by_path) & set(control_by_path)
)
path_metrics: dict[str, Any] = {}
for metric in PAIRED_METRICS:
deltas = [
float(treatment_by_path[path_id]['metrics'][metric])
- float(control_by_path[path_id]['metrics'][metric])
for path_id in shared_paths
if treatment_by_path[path_id]['metrics'].get(metric)
is not None
and control_by_path[path_id]['metrics'].get(metric)
is not None
and math.isfinite(
float(treatment_by_path[path_id]['metrics'][metric])
)
and math.isfinite(
float(control_by_path[path_id]['metrics'][metric])
)
]
path_metrics[metric] = {
'paired_paths': len(deltas),
'paired_delta_mean': (
statistics.fmean(deltas) if deltas else None
),
'paired_delta_median': median(deltas),
'paired_delta_p25': percentile(deltas, 0.25),
'paired_delta_p75': percentile(deltas, 0.75),
'positive_fraction': (
sum(delta > 0.0 for delta in deltas) / len(deltas)
if deltas
else None
),
'identical_fraction': (
sum(abs(delta) <= 1e-12 for delta in deltas)
/ len(deltas)
if deltas
else None
),
}
path_distributions.append({
'arm_id': arm_id,
'protocol': protocol,
'cost_per_side_pct': cost,
'metrics': path_metrics,
})
warm_rows = [
row for row in cells if row['protocol'] == 'warm_book'
]
warm_dispersion: list[dict[str, Any]] = []
for cost in costs:
for arm in arms:
arm_id = str(arm['id'])
anchor_rows: list[dict[str, Any]] = []
for cluster in ANCHOR_YEARS:
arm_paths = [
row
for row in warm_rows
if row['arm_id'] == arm_id
and int(row['cluster']) == cluster
and float(row['cost_per_side_pct']) == float(cost)
]
control_by_path = {
row['path_id']: row
for row in warm_rows
if row['arm_id'] == 'cap10_incumbent'
and int(row['cluster']) == cluster
and float(row['cost_per_side_pct']) == float(cost)
}
metric_rows: dict[str, Any] = {}
for metric in ('ev_net_r', 'calmar'):
arm_spread = iqr(
row['metrics'].get(metric) for row in arm_paths
)
control_spread = iqr(
control_by_path[row['path_id']]['metrics'].get(metric)
for row in arm_paths
if row['path_id'] in control_by_path
)
metric_rows[metric] = {
'arm_iqr': arm_spread,
'control_iqr': control_spread,
'iqr_ratio': _safe_ratio(
arm_spread, control_spread
),
}
anchor_rows.append({
'cluster': cluster,
'seeds': len(arm_paths),
'metrics': metric_rows,
})
headline: dict[str, Any] = {}
for metric in ('ev_net_r', 'calmar'):
ratios = [
row['metrics'][metric]['iqr_ratio']
for row in anchor_rows
]
headline[metric] = {
'median_iqr_ratio': median(ratios),
'bootstrap_90': bootstrap_median_interval(
ratios,
seed_parts=(
arm_id,
cost,
metric,
'warm-iqr-ratio',
),
),
}
warm_dispersion.append({
'arm_id': arm_id,
'cost_per_side_pct': cost,
'anchors': anchor_rows,
'headline': headline,
})
if not include_warm_dispersion:
warm_dispersion = []
return {
'paired_per_year': paired,
'paired_path_distributions': path_distributions,
'warm_seed_dispersion': warm_dispersion,
'bootstrap': {
'replicates': BOOTSTRAP_REPLICATES,
'seed': BOOTSTRAP_SEED,
'interval': 'central 90% percentile, context only',
'resampling_unit': 'seven annual paired summaries',
},
}
+120
View File
@@ -0,0 +1,120 @@
"""Re-derive every stored SEC snapshot with the current parser.
Snapshots are immutable per accession, so a parser fix does not reach rows that
are already stored: a normal import skips them and only logs a
``snapshot_discrepancy``. This script is the deliberate, manual exception --
it restages every accession from SEC Company Facts and rewrites the rows whose
reconstruction changed.
**Dry run by default.** Nothing is written unless ``--apply`` is passed. The dry
run stages and validates exactly as the real run does (both are read-only) and
reports the full blast radius: how many rows would change, which fields, and
per-symbol before/after samples.
Cost: a reparse cannot be served from the database -- the facts a fixed parser now
accepts were never stored -- so it refetches Company Facts for every tracked issuer
under the SEC fair-access throttle. Expect a long run and a lot of network.
Scope note: this rewrites ``fundamental_snapshots`` only. As of the A5 gate those
rows feed the fundamentals API/UI and the parity report; scoring still reads the
legacy ``fundamental_data`` table, so a reparse does not move composite scores or
backtests until the cutover happens.
Examples
--------
# dry run: report what would change, write nothing
python scripts/reparse_fundamentals.py
# dry run, showing more per-field detail
python scripts/reparse_fundamentals.py --samples 40
# actually rewrite the changed rows
python scripts/reparse_fundamentals.py --apply
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.database import async_session_factory # noqa: E402
from app.services.data_import import run_import # noqa: E402
from app.services.sec_fundamentals_importer import SecFundamentalsImporter # noqa: E402
def _parse_args() -> argparse.Namespace:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--apply", action="store_true",
help="rewrite changed rows (default: dry run, writes nothing)")
ap.add_argument("--samples", type=int, default=20,
help="how many changed accessions to show in detail (default 20)")
return ap.parse_args()
async def _dry_run(samples: int) -> int:
importer = SecFundamentalsImporter(reparse=True)
async with async_session_factory() as db:
print("staging every tracked issuer from SEC Company Facts (this is the slow part)...")
revision = await importer.detect_revision(db)
staged = await importer.stage(db)
result = await importer.validate(db, staged)
print(f"\nrevision : {revision}")
print(f"issuers fetched : {staged.issuers_fetched}")
print(f"rows reconstructed : {len(staged.rows)}")
print(f"already stored : {len(staged.existing_accessions)}")
print(f"WOULD BE REWRITTEN : {len(staged.discrepancies)}")
print(f"new inserts : {len(staged.rows) - len(staged.existing_accessions)}")
print(f"validation ok : {result.ok}")
if not result.ok:
print(f"validation messages : {result.messages}")
if staged.discrepancies:
field_counts = Counter(f for d in staged.discrepancies for f in d["fields"])
print("\nchanged fields (accession count per field):")
for name, count in field_counts.most_common():
print(f" {name:28s} {count}")
by_accession = {r.accession: r for r in staged.rows}
print(f"\nfirst {min(samples, len(staged.discrepancies))} changed accessions:")
for d in staged.discrepancies[:samples]:
row = by_accession.get(d["accession"])
where = f"{row.cik} {row.fiscal_year} {row.fiscal_period}" if row else "?"
print(f" {d['accession']} {where:28s} {', '.join(d['fields'])}")
print(
"\nDRY RUN -- nothing was written."
"\nCheck that the changes are the *kinds* you expect (recovered nulls,"
"\ncorrected values) and sample issuers you did not anticipate before"
"\nre-running with --apply."
)
return 0 if result.ok else 1
async def _apply() -> int:
# force=True: the revision tracks SEC, which has not changed — the staleness
# is on our side, so the normal no-op gate would skip this.
run = await run_import(SecFundamentalsImporter(reparse=True), force=True)
if run is None:
print("another sec_facts import holds the lock; nothing done")
return 1
print(f"run {run.id}: status={run.status}")
print(f" revision : {run.revision}")
print(f" row_counts : {run.row_counts_json}")
if run.error_details:
print(f" error : {run.error_details}")
return 0 if run.status == "promoted" else 1
def main() -> int:
args = _parse_args()
return asyncio.run(_apply() if args.apply else _dry_run(args.samples))
if __name__ == "__main__":
raise SystemExit(main())
+84
View File
@@ -0,0 +1,84 @@
'''Shared production-style historical ranking helpers for research runners.'''
from __future__ import annotations
from datetime import date
def _period_percentiles(
observations: list[dict], value_key: str
) -> dict[tuple[str, str], float]:
'''Rank one deterministic ticker observation per historical period.'''
by_period: dict[tuple, list[dict]] = {}
seen: set[tuple[str, str]] = set()
for row in observations:
identity = (str(row['symbol']), str(row['date']))
if identity in seen:
raise ValueError(f'Duplicate universe rank observation: {identity}')
seen.add(identity)
if row.get(value_key) is None:
continue
period = tuple(row['ranking_period'])
by_period.setdefault(period, []).append(row)
result: dict[tuple[str, str], float] = {}
for group in by_period.values():
ordered = sorted(
group,
key=lambda row: (float(row[value_key]), str(row['symbol'])),
)
denominator = len(ordered) - 1
for rank, row in enumerate(ordered):
result[(str(row['symbol']), str(row['date']))] = round(
rank / denominator * 100.0 if denominator > 0 else 100.0,
2,
)
return result
def _live_universe_rank_map(
observations: list[dict],
benchmark_closes: dict[date, float],
momentum_weight: float,
) -> dict[tuple[str, str], dict[str, float | None]]:
'''Historical equivalent of production compute_activation_ranks.
Every ticker contributes at most once per session. Residual momentum starts
only once 252 benchmark closes were point-in-time available; earlier dates
use the same raw-momentum fallback as production.
'''
identities = [(str(row['symbol']), str(row['date'])) for row in observations]
if len(identities) != len(set(identities)):
raise ValueError('Universe ranking requires one observation per ticker/date')
raw_pct = _period_percentiles(observations, 'momentum')
residual_pct = _period_percentiles(observations, 'residual_momentum')
vol_pct = _period_percentiles(observations, 'vol_6m')
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
for row in observations:
identity = (str(row['symbol']), str(row['date']))
asof_ord = date.fromisoformat(identity[1]).toordinal()
momentum_pct = (
residual_pct.get(identity)
if residual_start_ord is not None and asof_ord >= residual_start_ord
else raw_pct.get(identity)
)
volatility_pct = vol_pct.get(identity)
strategy_rank = (
round(
momentum_pct * momentum_weight
+ volatility_pct * (1.0 - momentum_weight),
2,
)
if momentum_pct is not None and volatility_pct is not None
else momentum_pct
)
ranks[identity] = {
'momentum_percentile': momentum_pct,
'volatility_percentile': volatility_pct,
'strategy_rank': strategy_rank,
}
return ranks
+5 -79
View File
@@ -29,6 +29,11 @@ ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from scripts.research_rankings import ( # noqa: E402
_live_universe_rank_map,
_period_percentiles,
)
POLICY_NAMES = ( POLICY_NAMES = (
"immediate", "immediate",
"next_session", "next_session",
@@ -107,85 +112,6 @@ def _default_output_path() -> Path:
return Path("reports") / f"daily-reentry-matrix-{stamp}.json" return Path("reports") / f"daily-reentry-matrix-{stamp}.json"
def _period_percentiles(
observations: list[dict], value_key: str
) -> dict[tuple[str, str], float]:
"""Production-style percentiles, one deterministic symbol row per period."""
by_period: dict[tuple, list[dict]] = {}
seen: set[tuple[str, str]] = set()
for row in observations:
identity = (str(row["symbol"]), str(row["date"]))
if identity in seen:
raise ValueError(f"Duplicate universe rank observation: {identity}")
seen.add(identity)
if row.get(value_key) is None:
continue
period = tuple(row["ranking_period"])
by_period.setdefault(period, []).append(row)
result: dict[tuple[str, str], float] = {}
for group in by_period.values():
ordered = sorted(
group,
key=lambda row: (float(row[value_key]), str(row["symbol"])),
)
denominator = len(ordered) - 1
for rank, row in enumerate(ordered):
result[(str(row["symbol"]), str(row["date"]))] = round(
rank / denominator * 100.0 if denominator > 0 else 100.0,
2,
)
return result
def _live_universe_rank_map(
observations: list[dict],
benchmark_closes: dict[date, float],
momentum_weight: float,
) -> dict[tuple[str, str], dict[str, float | None]]:
"""Historical equivalent of ``compute_activation_ranks``.
Every ticker contributes at most once per session. Residual momentum starts
only once 252 benchmark closes were point-in-time available; earlier dates
use the same raw-momentum fallback as production.
"""
identities = [(str(row["symbol"]), str(row["date"])) for row in observations]
if len(identities) != len(set(identities)):
raise ValueError("Universe ranking requires one observation per ticker/date")
raw_pct = _period_percentiles(observations, "momentum")
residual_pct = _period_percentiles(observations, "residual_momentum")
vol_pct = _period_percentiles(observations, "vol_6m")
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
for row in observations:
identity = (str(row["symbol"]), str(row["date"]))
asof_ord = date.fromisoformat(identity[1]).toordinal()
momentum_pct = (
residual_pct.get(identity)
if residual_start_ord is not None and asof_ord >= residual_start_ord
else raw_pct.get(identity)
)
volatility_pct = vol_pct.get(identity)
strategy_rank = (
round(
momentum_pct * momentum_weight
+ volatility_pct * (1.0 - momentum_weight),
2,
)
if momentum_pct is not None and volatility_pct is not None
else momentum_pct
)
ranks[identity] = {
"momentum_percentile": momentum_pct,
"volatility_percentile": volatility_pct,
"strategy_rank": strategy_rank,
}
return ranks
class PrecomputedDailyEngine: class PrecomputedDailyEngine:
"""Exact date/symbol lookup over the already-ranked production gate.""" """Exact date/symbol lookup over the already-ranked production gate."""
+5 -60
View File
@@ -55,6 +55,11 @@ ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from scripts.research_rankings import ( # noqa: E402
_live_universe_rank_map,
_period_percentiles,
)
# Must match Phase A cache when reusing research-cands.pkl # Must match Phase A cache when reusing research-cands.pkl
CACHE_VERSION = "research-matrix-v1-daily-prod" CACHE_VERSION = "research-matrix-v1-daily-prod"
@@ -104,66 +109,6 @@ def _parse_args() -> argparse.Namespace:
return p.parse_args() return p.parse_args()
def _period_percentiles(
observations: list[dict], value_key: str
) -> dict[tuple[str, str], float]:
by_period: dict[tuple, list[dict]] = {}
for row in observations:
if row.get(value_key) is None:
continue
period = tuple(row["ranking_period"])
by_period.setdefault(period, []).append(row)
result: dict[tuple[str, str], float] = {}
for group in by_period.values():
ordered = sorted(
group, key=lambda row: (float(row[value_key]), str(row["symbol"]))
)
denominator = len(ordered) - 1
for rank, row in enumerate(ordered):
result[(str(row["symbol"]), str(row["date"]))] = round(
rank / denominator * 100.0 if denominator > 0 else 100.0,
2,
)
return result
def _live_universe_rank_map(
observations: list[dict],
benchmark_closes: dict[date, float],
momentum_weight: float,
) -> dict[tuple[str, str], dict[str, float | None]]:
raw_pct = _period_percentiles(observations, "momentum")
residual_pct = _period_percentiles(observations, "residual_momentum")
vol_pct = _period_percentiles(observations, "vol_6m")
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
for row in observations:
identity = (str(row["symbol"]), str(row["date"]))
asof_ord = date.fromisoformat(identity[1]).toordinal()
momentum_pct = (
residual_pct.get(identity)
if residual_start_ord is not None and asof_ord >= residual_start_ord
else raw_pct.get(identity)
)
volatility_pct = vol_pct.get(identity)
strategy_rank = (
round(
momentum_pct * momentum_weight
+ volatility_pct * (1.0 - momentum_weight),
2,
)
if momentum_pct is not None and volatility_pct is not None
else momentum_pct
)
ranks[identity] = {
"momentum_percentile": momentum_pct,
"volatility_percentile": volatility_pct,
"strategy_rank": strategy_rank,
}
return ranks
def _window(arm: dict, name: str) -> dict | None: def _window(arm: dict, name: str) -> dict | None:
for row in arm.get("windows") or []: for row in arm.get("windows") or []:
if row.get("window") == name: if row.get("window") == name:
File diff suppressed because it is too large Load Diff
+5 -60
View File
@@ -68,6 +68,11 @@ ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from scripts.research_rankings import ( # noqa: E402
_live_universe_rank_map,
_period_percentiles,
)
CACHE_VERSION = "research-matrix-v1-daily-prod" CACHE_VERSION = "research-matrix-v1-daily-prod"
# Pre-registered arm catalogue (order is report order). Control is a0. # Pre-registered arm catalogue (order is report order). Control is a0.
@@ -210,66 +215,6 @@ def _sqlite_url(path: Path) -> str:
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}" return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
def _period_percentiles(
observations: list[dict], value_key: str
) -> dict[tuple[str, str], float]:
by_period: dict[tuple, list[dict]] = {}
for row in observations:
if row.get(value_key) is None:
continue
period = tuple(row["ranking_period"])
by_period.setdefault(period, []).append(row)
result: dict[tuple[str, str], float] = {}
for group in by_period.values():
ordered = sorted(
group, key=lambda row: (float(row[value_key]), str(row["symbol"]))
)
denominator = len(ordered) - 1
for rank, row in enumerate(ordered):
result[(str(row["symbol"]), str(row["date"]))] = round(
rank / denominator * 100.0 if denominator > 0 else 100.0,
2,
)
return result
def _live_universe_rank_map(
observations: list[dict],
benchmark_closes: dict[date, float],
momentum_weight: float,
) -> dict[tuple[str, str], dict[str, float | None]]:
raw_pct = _period_percentiles(observations, "momentum")
residual_pct = _period_percentiles(observations, "residual_momentum")
vol_pct = _period_percentiles(observations, "vol_6m")
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
for row in observations:
identity = (str(row["symbol"]), str(row["date"]))
asof_ord = date.fromisoformat(identity[1]).toordinal()
momentum_pct = (
residual_pct.get(identity)
if residual_start_ord is not None and asof_ord >= residual_start_ord
else raw_pct.get(identity)
)
volatility_pct = vol_pct.get(identity)
strategy_rank = (
round(
momentum_pct * momentum_weight
+ volatility_pct * (1.0 - momentum_weight),
2,
)
if momentum_pct is not None and volatility_pct is not None
else momentum_pct
)
ranks[identity] = {
"momentum_percentile": momentum_pct,
"volatility_percentile": volatility_pct,
"strategy_rank": strategy_rank,
}
return ranks
def _parse_args() -> argparse.Namespace: def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=__doc__, description=__doc__,
+20 -3
View File
@@ -15,6 +15,8 @@ from sqlalchemy.ext.asyncio import (
create_async_engine, create_async_engine,
) )
from sqlalchemy import delete
from app.database import Base from app.database import Base
from app.providers.protocol import OHLCVData from app.providers.protocol import OHLCVData
@@ -32,14 +34,29 @@ _test_session_factory = async_sessionmaker(
) )
_schema_created = False
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
async def _setup_db(): async def _setup_db():
"""Create all tables before each test and drop them after.""" """Hand every test an empty database.
The schema is built once and then truncated per test rather than dropped and
recreated. A create_all/drop_all cycle costs ~49ms against these 22 tables and
ran for every test in the suite including the many that never open a session
where deleting every row costs ~6ms for the same guarantee. No model sets
``sqlite_autoincrement``, so SQLite reuses rowids after a full delete and
generated ids still restart at 1.
"""
global _schema_created
async with _test_engine.begin() as conn: async with _test_engine.begin() as conn:
if not _schema_created:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
_schema_created = True
else:
for table in reversed(Base.metadata.sorted_tables):
await conn.execute(delete(table))
yield yield
async with _test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture @pytest.fixture
+17
View File
@@ -8,7 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import ValidationError from app.exceptions import ValidationError
from app.services.admin_service import ( from app.services.admin_service import (
get_activation_config, get_activation_config,
get_fundamentals_cutover_config,
update_activation_config, update_activation_config,
update_fundamentals_cutover_config,
) )
@@ -76,3 +78,18 @@ class TestActivationConfig:
async def test_rejects_out_of_range_confidence(self, session: AsyncSession): async def test_rejects_out_of_range_confidence(self, session: AsyncSession):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
await update_activation_config(session, {"min_confidence": 120.0}) await update_activation_config(session, {"min_confidence": 120.0})
class TestFundamentalsCutoverConfig:
async def test_defaults_off_when_unset(self, session: AsyncSession):
assert await get_fundamentals_cutover_config(session) == {"enabled": False}
async def test_round_trips_explicit_switch(self, session: AsyncSession):
assert await update_fundamentals_cutover_config(session, True) == {
"enabled": True
}
assert await get_fundamentals_cutover_config(session) == {"enabled": True}
assert await update_fundamentals_cutover_config(session, False) == {
"enabled": False
}
+306
View File
@@ -0,0 +1,306 @@
"""Orchestration tests for the source-agnostic import framework.
These drive ``run_import`` with a fake importer to prove the framework's
guarantees: idempotent no_op on an unchanged revision, atomic promotion on a
new revision, and the load-bearing one a failed validation or a mid-run
exception leaves the live tables untouched.
The advisory-lock branch is a no-op on SQLite, so lock mutual-exclusion has NO
coverage here (PG-verify-pending); only the deterministic key derivation is
unit-tested. Per the SQLite StaticPool caveat we never share a connection: each
test uses its own temp-file engine and seeds/asserts with short-lived sessions
sequenced around the ``run_import`` call.
"""
from __future__ import annotations
import asyncio
import os
import tempfile
from datetime import date, datetime, timedelta, timezone
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base
import app.models # noqa: F401 register models on Base.metadata
from app.models.data_import_run import DataImportRun
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.system_event import SystemEvent
from app.services.data_import import (
STATUS_DEFERRED,
STATUS_FAILED,
STATUS_NO_OP,
STATUS_PROMOTED,
ValidationResult,
_advisory_key,
run_import,
)
@pytest.fixture
async def engine():
"""A dedicated temp-file SQLite engine (independent connections, unlike the
shared in-memory test engine) so ``run_import`` can pin its own connection."""
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
eng = create_async_engine(f"sqlite+aiosqlite:///{path}")
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
yield eng
finally:
await eng.dispose()
try:
os.unlink(path)
except OSError:
pass
def _factory(engine) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class FakeImporter:
"""Writes ``n_rows`` fundamental_snapshots on promote (CIK-keyed, no ticker
FK) so the live-table effect is easy to count."""
source = "sec_facts"
def __init__(
self, revision, *, ok=True, retryable=False, alert_days=None,
n_rows=3, raise_in="none",
alert_messages=None,
):
self.revision = revision
self.ok = ok
self.retryable = retryable
self.alert_days = alert_days
self.alert_messages = alert_messages or []
self.n_rows = n_rows
self.raise_in = raise_in
self.staged_called = False
self.promoted = False
async def detect_revision(self, db):
if self.raise_in == "detect":
raise RuntimeError("boom-detect")
return self.revision
async def stage(self, db):
self.staged_called = True
if self.raise_in == "stage":
raise RuntimeError("boom-stage")
if self.raise_in == "cancel":
raise asyncio.CancelledError()
return list(range(self.n_rows))
async def validate(self, db, staged):
return ValidationResult(
ok=self.ok,
summary={"staged_rows": len(staged)},
source_max_date=date(2026, 7, 21),
messages=[] if self.ok else ["coverage below threshold"],
retryable=self.retryable,
deferred_alert_after_days=self.alert_days,
deferred_alert_messages=self.alert_messages,
)
async def promote(self, db, staged, run_id):
if self.raise_in == "promote":
# write one row THEN raise, to prove rollback undoes partial writes
db.add(_snapshot(self.revision, 999))
raise RuntimeError("boom-promote")
for i in staged:
db.add(_snapshot(self.revision, i, run_id=run_id))
self.promoted = True
self.promoted_run_id = run_id
return {"fundamental_snapshots": len(staged)}
def _snapshot(revision: str, i: int, run_id: int | None = None) -> FundamentalSnapshot:
return FundamentalSnapshot(
cik=f"{i:010d}",
accession=f"{revision}-{i:06d}",
form="10-Q",
filed_date=date(2026, 7, 1),
accepted_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
period_end=date(2026, 6, 30),
fiscal_year=2026,
fiscal_period="Q2",
revenue=1000.0 + i,
import_run_id=run_id,
)
async def _count(factory, model) -> int:
async with factory() as s:
return (await s.execute(select(func.count()).select_from(model))).scalar_one()
async def _runs(factory) -> list[DataImportRun]:
async with factory() as s:
return list(
(await s.execute(select(DataImportRun).order_by(DataImportRun.id))).scalars()
)
# ---------------------------------------------------------------------------
def test_advisory_key_deterministic_and_distinct():
assert _advisory_key("sec_facts") == _advisory_key("sec_facts")
assert _advisory_key("sec_facts") != _advisory_key("dolt_earnings")
for src in ("sec_facts", "dolt_earnings", "dolt_stocks"):
k = _advisory_key(src)
assert -(2**63) <= k < 2**63 # fits Postgres bigint
async def test_promote_writes_and_records_run(engine):
factory = _factory(engine)
run = await run_import(FakeImporter("rev1", n_rows=4), engine=engine)
assert run is not None and run.status == STATUS_PROMOTED
assert run.revision == "rev1"
assert run.row_counts_json is not None and "fundamental_snapshots" in run.row_counts_json
assert run.source_max_date == date(2026, 7, 21)
assert run.completed_at is not None
assert await _count(factory, FundamentalSnapshot) == 4
# rows stamped with the run id
async with factory() as s:
stamped = (
await s.execute(select(FundamentalSnapshot.import_run_id))
).scalars().all()
assert stamped and all(rid == run.id for rid in stamped)
async def test_no_op_on_repeated_revision(engine):
factory = _factory(engine)
await run_import(FakeImporter("rev1", n_rows=4), engine=engine)
second = FakeImporter("rev1", n_rows=4)
run = await run_import(second, engine=engine)
assert run is not None and run.status == STATUS_NO_OP
assert second.staged_called is False # never fetched
assert await _count(factory, FundamentalSnapshot) == 4 # unchanged
runs = await _runs(factory)
assert [r.status for r in runs] == [STATUS_PROMOTED, STATUS_NO_OP]
async def test_new_revision_after_promote_stages_again(engine):
factory = _factory(engine)
await run_import(FakeImporter("rev1", n_rows=2), engine=engine)
run = await run_import(FakeImporter("rev2", n_rows=3), engine=engine)
assert run is not None and run.status == STATUS_PROMOTED
assert await _count(factory, FundamentalSnapshot) == 5 # 2 + 3
async def test_failed_validation_leaves_data_untouched(engine):
factory = _factory(engine)
await run_import(FakeImporter("rev1", n_rows=3), engine=engine) # baseline
run = await run_import(FakeImporter("rev2", ok=False, n_rows=5), engine=engine)
assert run is not None and run.status == STATUS_FAILED
assert "coverage" in (run.error_details or "")
assert await _count(factory, FundamentalSnapshot) == 3 # untouched
assert await _count(factory, SystemEvent) == 1 # alerted
async def test_retryable_validation_defers_without_alerting(engine):
factory = _factory(engine)
await run_import(FakeImporter("rev1", n_rows=3), engine=engine)
run = await run_import(
FakeImporter("rev2", ok=False, retryable=True, n_rows=5), engine=engine
)
assert run is not None and run.status == STATUS_DEFERRED
assert "coverage" in (run.error_details or "")
assert await _count(factory, FundamentalSnapshot) == 3 # untouched
assert await _count(factory, SystemEvent) == 0 # expected retry does not alert
async def test_stale_deferred_validation_emits_deduplicated_warning(engine):
factory = _factory(engine)
promoted = await run_import(FakeImporter("rev1", n_rows=3), engine=engine)
async with factory() as s:
promoted.started_at = datetime.now(timezone.utc) - timedelta(days=4)
await s.merge(promoted)
await s.commit()
importer = FakeImporter(
"rev2", ok=False, retryable=True, alert_days=3,
alert_messages=["source detail names OLD-ACCESSION"],
n_rows=5,
)
first = await run_import(importer, engine=engine)
second = await run_import(importer, engine=engine)
assert first is not None and first.status == STATUS_DEFERRED
assert second is not None and second.status == STATUS_DEFERRED
async with factory() as s:
events = (await s.execute(select(SystemEvent))).scalars().all()
assert len(events) == 1
assert events[0].severity == "warning"
assert events[0].code == "sec_facts_deferred_stale"
assert "OLD-ACCESSION" in events[0].message
assert "aged-out" not in events[0].message
async def test_never_promoted_deferred_warning_says_never(engine):
factory = _factory(engine)
run = await run_import(
FakeImporter("rev1", ok=False, retryable=True, alert_days=3),
engine=engine,
)
assert run is not None and run.status == STATUS_DEFERRED
async with factory() as s:
event = (await s.execute(select(SystemEvent))).scalar_one()
assert "has never promoted successfully" in event.message
async def test_exception_in_promote_rolls_back(engine):
factory = _factory(engine)
await run_import(FakeImporter("rev1", n_rows=3), engine=engine) # baseline
run = await run_import(FakeImporter("rev2", raise_in="promote"), engine=engine)
assert run is not None and run.status == STATUS_FAILED
assert "boom-promote" in (run.error_details or "")
assert await _count(factory, FundamentalSnapshot) == 3 # partial write rolled back
assert await _count(factory, SystemEvent) == 1
async def test_detection_failure_records_and_alerts(engine):
"""The external revision probe is the most likely failure — it must produce a
recorded failed run + alert, not an unrecorded escaping exception."""
factory = _factory(engine)
imp = FakeImporter("rev1", raise_in="detect")
run = await run_import(imp, engine=engine)
assert run is not None and run.status == STATUS_FAILED
assert "boom-detect" in (run.error_details or "")
assert imp.staged_called is False
assert await _count(factory, FundamentalSnapshot) == 0
assert await _count(factory, SystemEvent) == 1 # alerted
runs = await _runs(factory)
assert len(runs) == 1 and runs[0].status == STATUS_FAILED # attempt recorded
async def test_cancellation_marks_failed_and_reraises(engine):
factory = _factory(engine)
with pytest.raises(asyncio.CancelledError):
await run_import(FakeImporter("rev1", raise_in="cancel"), engine=engine)
runs = await _runs(factory)
assert len(runs) == 1 and runs[0].status == STATUS_FAILED # no lingering running row
assert runs[0].error_details == "cancelled"
assert await _count(factory, FundamentalSnapshot) == 0
+47
View File
@@ -0,0 +1,47 @@
"""Tests for the async dolt subprocess wrapper's failure handling.
Uses the Python interpreter as a stand-in subprocess (cross-platform, no dolt
needed) to prove a non-zero exit and a hung command both raise DoltError the
latter is what stops a hung pull from pinning the import connection + advisory
lock forever.
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import pytest
from app.services import dolt_client
from app.services.dolt_client import DoltError
async def test_run_raises_on_nonzero_exit():
with pytest.raises(DoltError) as exc:
await dolt_client._run(
sys.executable, ["-c", "import sys; sys.exit(3)"], cwd=Path.cwd(), timeout=30
)
assert "3" in str(exc.value)
async def test_run_times_out_and_kills():
start = time.monotonic()
with pytest.raises(DoltError) as exc:
await dolt_client._run(
sys.executable,
["-c", "import time; time.sleep(30)"],
cwd=Path.cwd(),
timeout=0.5,
)
elapsed = time.monotonic() - start
assert "timed out" in str(exc.value)
assert elapsed < 10 # killed promptly, not waited out
async def test_run_returns_stdout_on_success():
out = await dolt_client._run(
sys.executable, ["-c", "print('hello')"], cwd=Path.cwd(), timeout=30
)
assert out.strip() == "hello"
+264
View File
@@ -0,0 +1,264 @@
"""Integration tests for the DoltHub earnings importer, driven through the real
import framework with a fake dolt client (no subprocess, no clone).
Covers the load-bearing behaviors: symbol-normalized join to the tracked
universe, calendar<->history pairing (matched EPS, unmatched null), the
destructive-but-safe reschedule/cancel promotion, past rows never deleted, and
the fail-closed forward-calendar gates that guard the destructive promote.
"""
from __future__ import annotations
import os
import shutil
import tempfile
from datetime import date
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base
import app.models # noqa: F401
from app.models.earnings_event import EarningsEvent
from app.models.ticker import Ticker
from app.services.data_import import STATUS_FAILED, STATUS_PROMOTED, run_import
from app.services.dolt_earnings_importer import DoltEarningsImporter
TODAY = date(2026, 7, 22)
@pytest.fixture
async def engine():
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
eng = create_async_engine(f"sqlite+aiosqlite:///{path}")
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
yield eng
finally:
await eng.dispose()
try:
os.unlink(path)
except OSError:
pass
def _factory(engine):
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class FakeDolt:
def __init__(self, calendar, history, commit="c1"):
self.calendar = calendar
self.history = history
self.commit = commit
self.pulled = False
async def pull(self, repo_dir, *, binary, timeout=None):
self.pulled = True
async def current_commit(self, repo_dir, *, binary, timeout=None):
return self.commit
async def query_csv(self, repo_dir, sql, *, binary, timeout=None):
if "earnings_calendar" in sql:
return self.calendar
if "eps_history" in sql:
return self.history
return []
def _cal(symbol, d, when="After market close"):
return {"act_symbol": symbol, "date": d, "when": when}
def _hist(symbol, pe, reported, estimate):
return {"act_symbol": symbol, "period_end_date": pe, "reported": str(reported), "estimate": str(estimate)}
def _importer(fake, commit=None):
if commit:
fake.commit = commit
return DoltEarningsImporter(
repo_dir="unused", binary="unused", today=TODAY, do_pull=False, dolt=fake
)
async def _seed_tickers(factory, symbols):
async with factory() as s:
for sym in symbols:
s.add(Ticker(symbol=sym))
await s.commit()
async with factory() as s:
return {sym: tid for tid, sym in (await s.execute(select(Ticker.id, Ticker.symbol))).all()}
async def _events(factory):
async with factory() as s:
rows = (
await s.execute(select(EarningsEvent).order_by(EarningsEvent.announce_date))
).scalars().all()
return list(rows)
# ---------------------------------------------------------------------------
async def test_stage_and_promote_basic(engine):
factory = _factory(engine)
await _seed_tickers(factory, ["AAPL"])
fake = FakeDolt(
calendar=[_cal("AAPL", "2026-05-01"), _cal("AAPL", "2026-08-20")],
history=[_hist("AAPL", "2026-03-31", 1.5, 1.4)], # only the reported quarter
)
run = await run_import(_importer(fake), engine=engine)
assert run.status == STATUS_PROMOTED
assert run.source_max_date == date(2026, 8, 20)
events = await _events(factory)
assert len(events) == 2
past = next(e for e in events if e.announce_date == date(2026, 5, 1))
future = next(e for e in events if e.announce_date == date(2026, 8, 20))
# past announcement paired to the reported quarter
assert past.eps_actual == 1.5 and past.eps_estimate == 1.4
assert past.period_end == date(2026, 3, 31) and past.session == "amc"
assert past.import_run_id == run.id
# future announcement has no results yet → null EPS/period, session kept
assert future.eps_actual is None and future.period_end is None
assert future.session == "amc"
async def test_symbol_normalisation_join(engine):
factory = _factory(engine)
ids = await _seed_tickers(factory, ["AAPL", "BRK.B"])
fake = FakeDolt(
calendar=[_cal("AAPL", "2026-08-20"), _cal("BRK.B", "2026-08-25")], # dotted source symbol
history=[],
)
run = await run_import(_importer(fake), engine=engine)
assert run.status == STATUS_PROMOTED
events = await _events(factory)
mapped = {e.ticker_id for e in events}
assert mapped == {ids["AAPL"], ids["BRK.B"]} # dotted BRK.B joined via normalization
async def test_reschedule_moves_future_row(engine):
factory = _factory(engine)
await _seed_tickers(factory, ["AAPL"])
fake1 = FakeDolt(calendar=[_cal("AAPL", "2026-08-20")], history=[], commit="c1")
await run_import(_importer(fake1), engine=engine)
fake2 = FakeDolt(calendar=[_cal("AAPL", "2026-08-27")], history=[], commit="c2") # moved
run2 = await run_import(_importer(fake2), engine=engine)
assert run2.status == STATUS_PROMOTED
dates = {e.announce_date for e in await _events(factory)}
assert dates == {date(2026, 8, 27)} # old future date gone, new one present
async def test_cancellation_removes_future_row(engine):
factory = _factory(engine)
await _seed_tickers(factory, ["AAPL"])
fake1 = FakeDolt(
calendar=[_cal("AAPL", "2026-08-01"), _cal("AAPL", "2026-08-15")], history=[], commit="c1"
)
await run_import(_importer(fake1), engine=engine)
fake2 = FakeDolt(calendar=[_cal("AAPL", "2026-08-15")], history=[], commit="c2") # 08-01 cancelled
await run_import(_importer(fake2), engine=engine)
dates = {e.announce_date for e in await _events(factory)}
assert dates == {date(2026, 8, 15)}
async def test_past_row_never_deleted(engine):
factory = _factory(engine)
await _seed_tickers(factory, ["AAPL"])
fake1 = FakeDolt(
calendar=[_cal("AAPL", "2026-05-01"), _cal("AAPL", "2026-08-20")], history=[], commit="c1"
)
await run_import(_importer(fake1), engine=engine)
# Second import's calendar omits the past date but keeps a future one.
fake2 = FakeDolt(calendar=[_cal("AAPL", "2026-08-20")], history=[], commit="c2")
await run_import(_importer(fake2), engine=engine)
dates = {e.announce_date for e in await _events(factory)}
assert date(2026, 5, 1) in dates # past result survived
assert date(2026, 8, 20) in dates
async def test_validate_fails_when_no_future(engine):
factory = _factory(engine)
await _seed_tickers(factory, ["AAPL"])
fake = FakeDolt(calendar=[_cal("AAPL", "2026-05-01")], history=[]) # only past
run = await run_import(_importer(fake), engine=engine)
assert run.status == STATUS_FAILED
assert "future" in (run.error_details or "")
assert len(await _events(factory)) == 0 # nothing promoted
async def test_validate_fails_on_forward_collapse(engine):
factory = _factory(engine)
await _seed_tickers(factory, ["AAPL", "MSFT", "NVDA", "AMZN"])
fake1 = FakeDolt(
calendar=[
_cal("AAPL", "2026-08-20"),
_cal("MSFT", "2026-08-21"),
_cal("NVDA", "2026-08-22"),
_cal("AMZN", "2026-08-23"),
],
history=[],
commit="c1",
)
await run_import(_importer(fake1), engine=engine)
assert len([e for e in await _events(factory) if e.announce_date > TODAY]) == 4
# Only one future row now → 1 < 50% of 4 → fail-closed, no destructive wipe.
fake2 = FakeDolt(calendar=[_cal("AAPL", "2026-08-20")], history=[], commit="c2")
run2 = await run_import(_importer(fake2), engine=engine)
assert run2.status == STATUS_FAILED
assert "collapsed" in (run2.error_details or "")
assert len([e for e in await _events(factory) if e.announce_date > TODAY]) == 4 # preserved
# --- Real-clone smoke test: exercises the actual dolt subprocess + parse + align
# against the local clone. Skips in CI / anywhere the binary or clone is absent.
_DOLT_BIN = os.environ.get("DOLT_BINARY") or shutil.which("dolt") or r"C:\Program Files\Dolt\bin\dolt.exe"
_CLONE_DIR = Path("dolt-data/earnings")
@pytest.mark.skipif(
not (Path(_DOLT_BIN).exists() and _CLONE_DIR.exists()),
reason="real dolt binary / earnings clone not available",
)
async def test_real_clone_smoke(engine):
from app.services import dolt_client
factory = _factory(engine)
# A few tickers spanning near + further-out reporters so the initial-load
# forward-horizon gate (>= 21d) is satisfied on the fixed clone.
await _seed_tickers(factory, ["AAPL", "MSFT", "NVDA", "JPM", "BRK.B"])
imp = DoltEarningsImporter(
repo_dir=_CLONE_DIR, binary=_DOLT_BIN, today=date.today(), do_pull=False, dolt=dolt_client
)
run = await run_import(imp, engine=engine)
assert run.status == STATUS_PROMOTED
events = await _events(factory)
assert events, "no earnings parsed from the real clone"
assert any(e.announce_date > date.today() for e in events), "no forward calendar"
assert any(e.eps_actual is not None for e in events), "no calendar<->history pairing"
+104
View File
@@ -0,0 +1,104 @@
"""Unit tests for the pure calendar<->EPS-history alignment.
Anchored on the research script's exact constants (SKIP costs = 45, typical lag
= 30, session penalty = 3, windows 90/14) so a silently changed constant fails
here rather than quietly corrupting surprise-history pairing.
"""
from __future__ import annotations
from datetime import date
from app.services import earnings_alignment as ea
def test_normalise_symbol():
assert ea.normalise_symbol("bf.b ") == "BF-B"
assert ea.normalise_symbol(" aapl") == "AAPL"
assert ea.normalise_symbol(None) == ""
def test_normalise_session():
assert ea.normalise_session("Before market open") == "bmo"
assert ea.normalise_session("After market close") == "amc"
assert ea.normalise_session("During market hours") == "unknown"
assert ea.normalise_session(None) == "unknown"
assert ea.normalise_session("") == "unknown"
def test_safe_number():
assert ea.safe_number("1.5") == 1.5
assert ea.safe_number("") is None
assert ea.safe_number("not-a-number") is None
assert ea.safe_number("nan") is None # non-finite rejected
def test_constants_pinned():
assert ea.SKIP_EVENT_COST == 45.0
assert ea.SKIP_PERIOD_COST == 45.0
assert ea._TYPICAL_ANNOUNCE_LAG_DAYS == 30
assert ea._MISSING_SESSION_PENALTY == 3.0
def test_match_cost_uses_pinned_lag_and_penalty():
period = {"period_end_date": date(2026, 3, 31)}
# delta == 30 (typical lag) → base cost 0; known session → no penalty
e_known = {"announce_date": date(2026, 4, 30), "session": "amc"}
assert ea.match_cost(e_known, period) == 0.0
# unknown session adds the penalty
e_unknown = {"announce_date": date(2026, 4, 30), "session": "unknown"}
assert ea.match_cost(e_unknown, period) == 3.0
# delta 45 → |45-30| == 15
e_far = {"announce_date": date(2026, 5, 15), "session": "amc"}
assert ea.match_cost(e_far, period) == 15.0
def test_dedup_calendar_prefers_known_session():
rows = [
{"symbol": "AAPL", "announce_date": date(2026, 5, 1), "session": "unknown"},
{"symbol": "AAPL", "announce_date": date(2026, 5, 1), "session": "amc"},
]
grouped, stats = ea.dedup_calendar(rows)
assert stats["duplicate_rows"] == 1
assert grouped["AAPL"][0]["session"] == "amc"
def test_dedup_history_prefers_fuller_row():
rows = [
{"symbol": "AAPL", "period_end_date": date(2026, 3, 31), "eps_actual": 2.0, "eps_estimate": None},
{"symbol": "AAPL", "period_end_date": date(2026, 3, 31), "eps_actual": 2.0, "eps_estimate": 1.9},
]
grouped, stats = ea.dedup_history(rows)
assert stats["duplicate_rows"] == 1
kept = grouped["AAPL"][0]
assert kept["eps_actual"] == 2.0 and kept["eps_estimate"] == 1.9
def _events(*days):
return [{"announce_date": d, "session": "amc"} for d in days]
def _periods(*days):
return [{"period_end_date": d, "eps_actual": 1.0, "eps_estimate": 0.9} for d in days]
def test_align_matches_monotonic_pairs():
# two announcements ~30d after two quarter ends
events = _events(date(2026, 4, 30), date(2026, 7, 30))
periods = _periods(date(2026, 3, 31), date(2026, 6, 30))
matches, um_events, um_periods = ea.align_symbol(
events, periods, max_lag_days=90, max_lead_days=14
)
assert matches == [(0, 0), (1, 1)]
assert um_events == [] and um_periods == []
def test_align_leaves_out_of_window_unmatched():
# announcement 200 days after the only period end → outside the 90d window
events = _events(date(2026, 10, 17))
periods = _periods(date(2026, 3, 31))
matches, um_events, um_periods = ea.align_symbol(
events, periods, max_lag_days=90, max_lead_days=14
)
assert matches == []
assert um_events == [0] and um_periods == [0]
+47 -4
View File
@@ -1,4 +1,4 @@
"""Tests for v2 correction events and warning alarm episodes.""" """Tests for v3 correction events, warning alarm episodes, and report caveats."""
from __future__ import annotations from __future__ import annotations
@@ -6,7 +6,9 @@ from datetime import date, timedelta
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
from app.services.event_study_service import ( from app.services.event_study_service import (
MIN_EVENTS_FOR_CONFIDENCE,
_percentile, _percentile,
_reliability,
alarm_episodes, alarm_episodes,
detect_events, detect_events,
evaluate_alarms, evaluate_alarms,
@@ -23,6 +25,40 @@ def test_detect_events_uses_rising_edge_and_cooldown():
assert [event["index"] for event in events] == [300, 355] assert [event["index"] for event in events] == [300, 355]
def test_reliability_flags_a_thin_holdout():
"""2/4 must not read like a property of the score."""
dates = _days(100)
backing = dict.fromkeys(dates, 3)
thin = _reliability(dates, 70, backing, events_detected=11, events_in_holdout=4)
assert thin["underpowered"] is True
assert thin["events_detected"] == 11
assert thin["events_in_holdout"] == 4
assert thin["minimum_events"] == MIN_EVENTS_FOR_CONFIDENCE
ample = _reliability(dates, 70, backing, events_detected=20, events_in_holdout=12)
assert ample["underpowered"] is False
def test_reliability_flags_a_sensor_coverage_split():
"""The threshold must not be frozen on a different construct than it is tested on.
Credit history starts partway through the training window, so the score
renormalises over two sensors early and three later.
"""
dates = _days(100)
matched = dict.fromkeys(dates, 3)
assert _reliability(dates, 70, matched, 20, 12)["sensor_coverage_mismatch"] is False
# Training is 40% three-sensor; the holdout is entirely three-sensor.
split_backing = {d: (3 if index >= 42 else 2) for index, d in enumerate(dates)}
mismatched = _reliability(dates, 70, split_backing, 20, 12)
assert mismatched["sensor_coverage_mismatch"] is True
assert mismatched["train_full_sensor_share"] == 40.0
assert mismatched["holdout_full_sensor_share"] == 100.0
assert mismatched["sensors_expected"] == 3
def test_percentile_is_fixed_from_supplied_values(): def test_percentile_is_fixed_from_supplied_values():
values = [float(value) for value in range(0, 101, 10)] values = [float(value) for value in range(0, 101, 10)]
assert _percentile(values, 50) == 50.0 assert _percentile(values, 50) == 50.0
@@ -52,7 +88,7 @@ def test_evaluate_alarms_counts_episodes_not_alarm_days():
assert result["median_lead_days"] == 17.5 assert result["median_lead_days"] == 17.5
def test_breadth_from_fixed_closes_and_pure_divergence(): def test_breadth_from_fixed_closes_and_tapered_divergence():
dates = _days(10) dates = _days(10)
closes_by_symbol = { closes_by_symbol = {
"A": list(zip(dates, [1.0 + index for index in range(10)])), "A": list(zip(dates, [1.0 + index for index in range(10)])),
@@ -67,6 +103,13 @@ def test_breadth_from_fixed_closes_and_pure_divergence():
divergence = compute_divergence_series(falling_breadth, rising_benchmark, lookback=3) divergence = compute_divergence_series(falling_breadth, rising_benchmark, lookback=3)
assert divergence[dates[-1]] > 0 assert divergence[dates[-1]] > 0
# v3: breadth loss with price confirming it is still deterioration, scored at
# DIVERGENCE_CONFIRMED_FLOOR of the masked case rather than discarded. v2's
# hard gate zeroed this and left Warning at 0 through every selloff.
falling_benchmark = list(zip(dates, [100.0 - index for index in range(10)])) falling_benchmark = list(zip(dates, [100.0 - index for index in range(10)]))
no_divergence = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3) confirmed = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3)
assert no_divergence[dates[-1]] == 0 assert 0 < confirmed[dates[-1]] < divergence[dates[-1]]
# Flat breadth is not deterioration regardless of price direction.
flat_breadth = {day: 60.0 for day in dates}
assert compute_divergence_series(flat_breadth, falling_benchmark, lookback=3)[dates[-1]] == 0
+293
View File
@@ -0,0 +1,293 @@
"""A5 activation: local candidate derivation and compat-cache refresh."""
from __future__ import annotations
import json
from datetime import date, datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base
from app.models.earnings_event import EarningsEvent
from app.models.fundamental import FundamentalData
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.ohlcv import OHLCVRecord
from app.models.score import CompositeScore, DimensionScore
from app.models.settings import SystemSetting
from app.models.ticker import Ticker
from app.services import fundamentals_candidate_service as candidates
from app.services import fundamentals_derivation as deriv
from app.services import fundamental_data_refresh_service as refresh_service
UTC = timezone.utc
NOW = datetime(2026, 7, 24, 10, 0, tzinfo=UTC)
TODAY = date(2026, 7, 24)
_engine = create_async_engine("sqlite+aiosqlite://", echo=False)
_session_factory = async_sessionmaker(
_engine, class_=AsyncSession, expire_on_commit=False
)
@pytest.fixture(autouse=True)
async def _setup_tables():
async with _engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
yield
async with _engine.begin() as connection:
await connection.run_sync(Base.metadata.drop_all)
@pytest.fixture
async def session() -> AsyncSession:
async with _session_factory() as db:
yield db
def _snapshot_rows(cik: str) -> list[FundamentalSnapshot]:
rows: list[FundamentalSnapshot] = []
periods = ("Q1", "Q2", "Q3", "FY")
months = (3, 6, 9, 12)
for fiscal_year, multiplier in ((2025, 1.0), (2026, 1.1)):
revenue = [100 * multiplier, 110 * multiplier, 120 * multiplier, 130 * multiplier]
eps = [1.0 * multiplier, 1.1 * multiplier, 1.2 * multiplier, 1.3 * multiplier]
for index, fiscal_period in enumerate(periods):
period_end = date(fiscal_year, months[index], 28)
rows.append(
FundamentalSnapshot(
cik=cik,
accession=f"{cik}-{fiscal_year}-{fiscal_period}",
form="10-K" if fiscal_period == "FY" else "10-Q",
filed_date=period_end,
accepted_at=datetime(
fiscal_year, months[index], 28, tzinfo=UTC
),
period_end=period_end,
fiscal_year=fiscal_year,
fiscal_period=fiscal_period,
revenue=sum(revenue[: index + 1]),
diluted_eps=sum(eps[: index + 1]),
shares_outstanding=1_000,
)
)
return rows
async def test_default_off_performs_no_candidate_read_or_write(
session: AsyncSession, monkeypatch
):
ticker = Ticker(symbol="AAA")
session.add(ticker)
await session.flush()
session.add(
FundamentalData(
ticker_id=ticker.id,
pe_ratio=12,
revenue_growth=3,
earnings_surprise=1,
market_cap=100,
fetched_at=NOW,
)
)
await session.commit()
async def should_not_read(*args, **kwargs):
raise AssertionError("default-off refresh derived candidates")
monkeypatch.setattr(candidates, "build_candidates", should_not_read)
summary = await refresh_service.refresh_if_enabled(session, today=TODAY)
stored = await session.scalar(
select(FundamentalData).where(FundamentalData.ticker_id == ticker.id)
)
assert summary == {
"enabled": False,
"refreshed": 0,
"score_inputs_changed": 0,
"dimension_scores_staled": 0,
"composite_scores_staled": 0,
}
assert stored.pe_ratio == 12
async def test_activated_refresh_updates_all_fields_and_invalidates_scores(
session: AsyncSession,
):
session.add(
SystemSetting(key=refresh_service.ACTIVATION_KEY, value="true")
)
first = Ticker(symbol="AAA", cik="0000000001")
second = Ticker(symbol="AAB", cik="0000000001")
session.add_all([first, second])
await session.flush()
session.add_all(_snapshot_rows(first.cik))
session.add_all(
[
OHLCVRecord(
ticker_id=first.id,
date=TODAY - timedelta(days=1),
open=100,
high=100,
low=100,
close=100,
volume=100,
),
OHLCVRecord(
ticker_id=second.id,
date=TODAY - timedelta(days=1),
open=200,
high=200,
low=200,
close=200,
volume=100,
),
EarningsEvent(
ticker_id=first.id,
announce_date=TODAY - timedelta(days=10),
session="amc",
eps_estimate=2,
eps_actual=2.2,
source="dolt_earnings",
),
EarningsEvent(
ticker_id=first.id,
announce_date=TODAY,
session="amc",
source="dolt_earnings",
),
]
)
for ticker in (first, second):
session.add(
FundamentalData(
ticker_id=ticker.id,
pe_ratio=1,
revenue_growth=1,
earnings_surprise=1,
market_cap=1,
fetched_at=NOW - timedelta(days=1),
)
)
session.add(
DimensionScore(
ticker_id=ticker.id,
dimension="fundamental",
score=50,
is_stale=False,
computed_at=NOW,
)
)
session.add(
CompositeScore(
ticker_id=ticker.id,
score=50,
is_stale=False,
weights_json="{}",
computed_at=NOW,
)
)
await session.commit()
summary = await refresh_service.refresh_if_enabled(
session, now=NOW, today=TODAY
)
stored = {
row.ticker_id: row
for row in (
await session.execute(select(FundamentalData))
).scalars()
}
assert summary["refreshed"] == 2
assert summary["score_inputs_changed"] == 2
assert stored[first.id].pe_ratio == pytest.approx(100 / 5.06)
assert stored[second.id].pe_ratio == pytest.approx(200 / 5.06)
assert stored[first.id].revenue_growth == pytest.approx(10)
assert stored[first.id].earnings_surprise == pytest.approx(10)
assert stored[first.id].market_cap == pytest.approx(100_000)
assert stored[first.id].next_earnings_date == TODAY
metadata = json.loads(stored[first.id].unavailable_fields_json)
assert metadata["source_pe_ratio"] == "sec_facts+ohlcv_records"
assert metadata["source_next_earnings_date"] == "dolt_earnings"
dimensions = (
await session.execute(select(DimensionScore))
).scalars().all()
composites = (
await session.execute(select(CompositeScore))
).scalars().all()
assert all(row.is_stale for row in dimensions)
assert all(row.is_stale for row in composites)
for row in (*dimensions, *composites):
row.is_stale = False
await session.commit()
unchanged = await refresh_service.refresh_if_enabled(
session, now=NOW + timedelta(hours=1), today=TODAY
)
assert unchanged["score_inputs_changed"] == 0
assert not any(
(await session.execute(select(DimensionScore.is_stale))).scalars()
)
assert not any(
(await session.execute(select(CompositeScore.is_stale))).scalars()
)
async def test_candidate_uses_guarded_derive_outputs_and_share_fallback(
session: AsyncSession, monkeypatch
):
ticker = Ticker(symbol="GUARD", cik="0000000002")
session.add(ticker)
await session.flush()
session.add(
FundamentalSnapshot(
cik=ticker.cik,
accession="raw-accession",
form="10-Q",
filed_date=TODAY,
accepted_at=NOW,
period_end=TODAY,
fiscal_year=2026,
fiscal_period="Q2",
diluted_eps=99,
shares_outstanding=999,
)
)
session.add(
OHLCVRecord(
ticker_id=ticker.id,
date=TODAY,
open=50,
high=50,
low=50,
close=50,
volume=100,
)
)
await session.commit()
def guarded(_rows):
return deriv.DerivedFundamentals(
metrics={
"revenue_growth_yoy": deriv.MetricSeries(value=7)
},
ttm_diluted_eps=None,
ttm_diluted_eps_caveat="split guard applied",
shares_outstanding=123,
shares_outstanding_estimated=True,
latest_period_end=TODAY,
latest_filed_date=TODAY,
)
monkeypatch.setattr(candidates.deriv, "derive", guarded)
candidate = (await candidates.build_candidates(session, today=TODAY))[0]
assert candidate.pe_ratio is None
assert candidate.market_cap == 50 * 123
assert candidate.revenue_growth == 7
assert candidate.unavailable_fields["pe_ratio"] == "split guard applied"
assert "weighted-average" in candidate.unavailable_fields["market_cap_estimated"]
+252
View File
@@ -0,0 +1,252 @@
"""Integration tests for the additive fundamentals API v1 assembly."""
from __future__ import annotations
import os
import tempfile
from datetime import date, datetime, timezone
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base
import app.models # noqa: F401
from app.models.earnings_event import EarningsEvent
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.ohlcv import OHLCVRecord
from app.models.ticker import Ticker
from app.schemas.fundamental import FundamentalResponse
from app.services.fundamentals_api_service import METRIC_KEYS, build_fundamentals_v1
UTC = timezone.utc
TODAY = date(2026, 10, 15)
@pytest.fixture
async def factory():
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
eng = create_async_engine(f"sqlite+aiosqlite:///{path}")
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
yield async_sessionmaker(eng, class_=AsyncSession, expire_on_commit=False)
finally:
await eng.dispose()
try:
os.unlink(path)
except OSError:
pass
_MONTHS = [3, 6, 9, 12]
_FP = ["Q1", "Q2", "Q3", "FY"]
async def _seed_issuer(s, symbol, cik, sic, rev_base, price, *, eps_base=1.0, snapshots=True):
t = Ticker(symbol=symbol, cik=cik, sic=sic)
s.add(t)
await s.flush()
if snapshots:
# three fiscal years so YoY growth reads have a >=3 consecutive run
for fy, mult in [(2024, 0.9), (2025, 1.0), (2026, 1.1)]:
shares = {2024: 1050, 2025: 1000, 2026: 950}[fy] # steady buyback
rev = [rev_base * mult * x for x in (1.0, 1.05, 1.1, 1.15)]
eps = [eps_base * mult * x for x in (1.0, 1.05, 1.1, 1.15)]
for i, fp in enumerate(_FP):
pe = date(fy, _MONTHS[i], 28)
s.add(FundamentalSnapshot(
cik=cik, accession=f"{cik}-{fy}-{fp}", form="10-K" if fp == "FY" else "10-Q",
filed_date=pe, accepted_at=datetime(fy, _MONTHS[i], 28, tzinfo=UTC),
period_end=pe, fiscal_year=fy, fiscal_period=fp,
revenue=sum(rev[: i + 1]), operating_income=sum(rev[: i + 1]) * 0.2,
diluted_eps=sum(eps[: i + 1]), cfo=sum(rev[: i + 1]) * 0.25,
capex=sum(rev[: i + 1]) * 0.05, depreciation_amortization=sum(rev[: i + 1]) * 0.05,
cash_and_st_investments=40, total_debt=100, shares_outstanding=shares))
s.add(OHLCVRecord(ticker_id=t.id, date=date(2026, 10, 1), open=price, high=price, low=price, close=price, volume=1000))
return t.id
async def _seed_group(factory):
async with factory() as s:
aapl = await _seed_issuer(s, "AAPL", "0000000001", "3571", rev_base=1000, price=200, eps_base=2.0)
for i in range(5): # 5 peers in SIC 35xx so the group has >= 5 valid issuers
await _seed_issuer(s, f"PEER{i}", f"000000010{i}", "3572", rev_base=500 + i * 100, price=50 + i * 10)
# AAPL earnings: one upcoming, one past with a surprise
s.add(EarningsEvent(ticker_id=aapl, announce_date=date(2026, 11, 1), session="amc", source="dolt_earnings"))
s.add(EarningsEvent(ticker_id=aapl, announce_date=date(2026, 8, 1), session="amc",
period_end=date(2026, 6, 30), eps_estimate=2.0, eps_actual=2.2, source="dolt_earnings"))
await s.commit()
return aapl
async def test_full_assembly(factory):
await _seed_group(factory)
async with factory() as s:
v1 = await build_fundamentals_v1(s, "AAPL", today=TODAY)
# earnings
assert v1["earnings"]["next"] == {"date": "2026-11-01", "session": "amc", "days_until": 17}
recent = v1["earnings"]["recent"]
assert recent and recent[0]["surprise_pct"] == pytest.approx(10.0)
# metrics — fixed key set, all present
assert [m["key"] for m in v1["metrics"]] == list(METRIC_KEYS)
by_key = {m["key"]: m for m in v1["metrics"]}
assert by_key["revenue_growth_yoy"]["value"] is not None
assert by_key["revenue_growth_yoy"]["source"] == "sec"
assert len(by_key["operating_margin"]["history"]) >= 3
# peer industry present for eligible metric (6 issuers), absent for size-dependent net_debt
assert by_key["operating_margin"]["industry"] is not None
assert by_key["operating_margin"]["industry"]["peer_count"] == 6
assert by_key["operating_margin"]["industry"]["label"] == "SIC 35 peers"
assert by_key["net_debt"]["industry"] is None
# valuation computed at request time
val = v1["valuation"]
assert val["pe"] is not None and val["market_cap_est"] is not None
assert val["price_date"] == "2026-10-01"
assert val["pe_industry"] is not None
# reads: header string + fixed by_key map (every metric + pe + fcf_yield)
assert v1["reads"]["header"]
assert set(v1["reads"]["by_key"]) == set(METRIC_KEYS) | {"pe", "fcf_yield"}
data = FundamentalResponse(symbol="AAPL", pe_ratio=12.3, **v1) # legacy + v1 additive
dumped = data.model_dump()
assert dumped["pe_ratio"] == 12.3 # legacy preserved untouched
assert dumped["metrics"][0]["key"] == "revenue_growth_yoy"
async def test_same_day_earnings_is_next_with_zero_days(factory):
async with factory() as s:
t = Ticker(symbol="TDY", cik=None)
s.add(t)
await s.flush()
s.add(EarningsEvent(ticker_id=t.id, announce_date=TODAY, session="bmo", source="dolt_earnings"))
s.add(EarningsEvent(ticker_id=t.id, announce_date=date(2026, 9, 1), session="amc",
eps_estimate=1.0, eps_actual=1.1, source="dolt_earnings"))
await s.commit()
async with factory() as s:
v1 = await build_fundamentals_v1(s, "TDY", today=TODAY)
assert v1["earnings"]["next"] == {"date": TODAY.isoformat(), "session": "bmo", "days_until": 0}
# the same-day event is upcoming, not in recent
assert all(r["announce_date"] != TODAY.isoformat() for r in v1["earnings"]["recent"])
async def test_eps_growth_read_is_populated(factory):
await _seed_group(factory)
async with factory() as s:
v1 = await build_fundamentals_v1(s, "AAPL", today=TODAY)
assert v1["reads"]["by_key"]["eps_growth_yoy"] is not None # EPS read now computed
async def test_non_positive_price_guards_valuation(factory):
async with factory() as s:
t = Ticker(symbol="ZERO", cik="0000000055", sic="3571")
s.add(t)
await s.flush()
s.add(FundamentalSnapshot(cik="0000000055", accession="z", form="10-K", filed_date=date(2026, 1, 1),
accepted_at=datetime(2026, 1, 1, tzinfo=UTC), period_end=date(2025, 12, 31),
fiscal_year=2025, fiscal_period="FY", diluted_eps=5.0, shares_outstanding=1000))
s.add(OHLCVRecord(ticker_id=t.id, date=date(2026, 1, 2), open=0, high=0, low=0, close=0, volume=1))
await s.commit()
async with factory() as s:
v1 = await build_fundamentals_v1(s, "ZERO", today=TODAY)
assert v1["valuation"] is None # close of 0 is not a usable price
async def test_no_cik_ticker_yields_null_metrics(factory):
async with factory() as s:
s.add(Ticker(symbol="ADR", cik=None)) # no SEC identity
await s.commit()
async with factory() as s:
v1 = await build_fundamentals_v1(s, "ADR", today=TODAY)
assert v1["valuation"] is None
assert all(m["value"] is None and m["industry"] is None for m in v1["metrics"])
assert v1["reads"]["header"] is None
assert v1["reads"]["by_key"] == {k: None for k in list(METRIC_KEYS) + ["pe", "fcf_yield"]}
async def test_industry_omitted_below_five_peers(factory):
async with factory() as s:
await _seed_issuer(s, "SOLO", "0000000009", "9999", rev_base=1000, price=100, eps_base=2.0)
await s.commit()
async with factory() as s:
v1 = await build_fundamentals_v1(s, "SOLO", today=TODAY)
# only 1 issuer in the group -> below MIN_PEERS -> every industry omitted
assert all(m["industry"] is None for m in v1["metrics"])
assert v1["valuation"]["pe_industry"] is None
# but the subject's own valuation still computes
assert v1["valuation"]["pe"] is not None
async def test_valuation_guarded_without_price(factory):
async with factory() as s:
t = Ticker(symbol="NOPX", cik="0000000077", sic="3571")
s.add(t)
await s.flush()
# snapshots but NO ohlcv close
s.add(FundamentalSnapshot(cik="0000000077", accession="a", form="10-K", filed_date=date(2026, 1, 1),
accepted_at=datetime(2026, 1, 1, tzinfo=UTC), period_end=date(2025, 12, 31),
fiscal_year=2025, fiscal_period="FY", diluted_eps=5.0, shares_outstanding=1000))
await s.commit()
async with factory() as s:
v1 = await build_fundamentals_v1(s, "NOPX", today=TODAY)
# no usable price -> valuation is null under the approved contract
assert v1["valuation"] is None
async def test_multiclass_subject_priced_by_requested_ticker(factory):
cik = "0001652044"
async with factory() as s:
# GOOGL and GOOG share one CIK/snapshots but trade at different prices
await _seed_issuer(s, "GOOGL", cik, "7372", rev_base=1000, price=200, eps_base=2.0)
# add a second class sharing the CIK: same snapshots exist; just its own ticker+price
goog = Ticker(symbol="GOOG", cik=cik, sic="7372")
s.add(goog)
await s.flush()
s.add(OHLCVRecord(ticker_id=goog.id, date=date(2026, 10, 1), open=100, high=100, low=100, close=100, volume=1))
for i in range(4): # peers so the group has >= 5 valid issuers
await _seed_issuer(s, f"P{i}", f"000000020{i}", "7373", rev_base=600 + i * 50, price=40 + i * 5)
await s.commit()
async with factory() as s:
googl = await build_fundamentals_v1(s, "GOOGL", today=TODAY)
goog_v = await build_fundamentals_v1(s, "GOOG", today=TODAY)
# subject P/E uses the REQUESTED class's price (200 vs 100), not an arbitrary sibling
assert googl["valuation"]["pe"] == pytest.approx(goog_v["valuation"]["pe"] * 2, rel=1e-6)
async def test_endpoint_merges_legacy_and_v1(client, db_session):
from datetime import timezone as _tz
from app.dependencies import require_access
from app.main import app
from app.models.fundamental import FundamentalData
app.dependency_overrides[require_access] = lambda: None
try:
t = Ticker(symbol="AAPL", cik="0000000001", sic="3571")
db_session.add(t)
await db_session.flush()
db_session.add(FundamentalData(ticker_id=t.id, pe_ratio=12.3, revenue_growth=5.0,
fetched_at=datetime(2026, 1, 1, tzinfo=_tz.utc)))
db_session.add(FundamentalSnapshot(cik="0000000001", accession="a", form="10-K",
filed_date=date(2026, 1, 1), accepted_at=datetime(2026, 1, 1, tzinfo=_tz.utc),
period_end=date(2025, 12, 31), fiscal_year=2025, fiscal_period="FY",
diluted_eps=5.0, shares_outstanding=1000))
db_session.add(OHLCVRecord(ticker_id=t.id, date=date(2026, 1, 2), open=100, high=100, low=100, close=100, volume=1))
await db_session.flush()
resp = await client.get("/api/v1/fundamentals/AAPL")
assert resp.status_code == 200
data = resp.json()["data"]
assert data["pe_ratio"] == 12.3 # legacy preserved
assert data["revenue_growth"] == 5.0
assert len(data["metrics"]) == 7 # additive v1
assert data["earnings"] is not None
assert "by_key" in data["reads"]
assert data["valuation"]["price_date"] == "2026-01-02"
finally:
app.dependency_overrides.pop(require_access, None)
+333
View File
@@ -0,0 +1,333 @@
"""Tests for pure read-time derivation of fundamentals from YTD snapshots."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timezone
import pytest
from app.services import fundamentals_derivation as fd
UTC = timezone.utc
@dataclass
class Snap:
fiscal_year: int
fiscal_period: str
period_end: date
filed_date: date
accepted_at: datetime
revenue: float | None = None
net_income: float | None = None
operating_income: float | None = None
diluted_eps: float | None = None
cfo: float | None = None
capex: float | None = None
depreciation_amortization: float | None = None
cash_and_st_investments: float | None = None
total_debt: float | None = None
shares_outstanding: float | None = None
weighted_avg_diluted_shares: float | None = None
_FP = ["Q1", "Q2", "Q3", "FY"]
_ENDS = { # period_end per (fy, quarter index 0..3)
2025: [date(2024, 12, 31), date(2025, 3, 31), date(2025, 6, 30), date(2025, 9, 30)],
2026: [date(2025, 12, 31), date(2026, 3, 31), date(2026, 6, 30), date(2026, 9, 30)],
}
def _year(fy, discretes: dict[str, list[float]], instants: dict[str, list] | None = None):
"""Build 4 snapshot rows (Q1,Q2,Q3,FY) with YTD-cumulative flow fields from the
given per-quarter discrete values; instants set as-is per quarter."""
rows = []
for i, fp in enumerate(_FP):
r = Snap(fy, fp, _ENDS[fy][i], _ENDS[fy][i], datetime(fy, 1 + i, 1, tzinfo=UTC))
for fname, ds in discretes.items():
setattr(r, fname, round(sum(ds[: i + 1]), 4)) # cumulative YTD
for fname, vals in (instants or {}).items():
setattr(r, fname, vals[i])
rows.append(r)
return rows
def _two_years():
rev25 = [100, 110, 120, 130]
rev26 = [110, 121, 132, 143] # +10% each quarter YoY
rows = _year(2025, {
"revenue": rev25,
"operating_income": [x * 0.2 for x in rev25],
"diluted_eps": [1.0, 1.1, 1.2, 1.3],
"cfo": [x * 0.25 for x in rev25],
"capex": [x * 0.05 for x in rev25],
"depreciation_amortization": [x * 0.05 for x in rev25],
}, instants={"shares_outstanding": [1000, 1000, 1000, 1000], "cash_and_st_investments": [40] * 4, "total_debt": [140] * 4})
rows += _year(2026, {
"revenue": rev26,
"operating_income": [x * 0.2 for x in rev26],
"diluted_eps": [1.1, 1.21, 1.32, 1.43],
"cfo": [x * 0.25 for x in rev26],
"capex": [x * 0.05 for x in rev26],
"depreciation_amortization": [x * 0.05 for x in rev26],
}, instants={"shares_outstanding": [900, 900, 900, 900], "cash_and_st_investments": [50] * 4, "total_debt": [150] * 4})
return rows
def test_revenue_growth_yoy_and_q4_derivation():
d = fd.derive(_two_years())
# TTM revenue FY2026 = 110+121+132+143 = 506; FY2025 = 460 -> +10%
assert d.metrics["revenue_growth_yoy"].value == pytest.approx(10.0, abs=1e-6)
# latest period is FY2026
assert d.latest_period_end == date(2026, 9, 30)
# tape has 4 points, newest last, each carrying a period_end
hist = d.metrics["revenue_growth_yoy"].history
assert len(hist) == 4 and hist[-1].period_end == date(2026, 9, 30)
def test_operating_and_fcf_margin():
d = fd.derive(_two_years())
assert d.metrics["operating_margin"].value == pytest.approx(20.0, abs=1e-6)
# FCF margin = (TTM cfo - TTM capex)/TTM rev = (0.25 - 0.05) = 20%
assert d.metrics["fcf_margin"].value == pytest.approx(20.0, abs=1e-6)
def test_net_debt_leverage_and_share_dilution():
d = fd.derive(_two_years())
# net debt = total_debt - cash = 150 - 50 = 100 (latest instant)
assert d.metrics["net_debt"].value == pytest.approx(100.0)
# EBITDA TTM = TTM operating_income + TTM D&A; net_debt/ebitda
op_ttm = 506 * 0.2 # 101.2
da_ttm = 506 * 0.05 # 25.3
assert d.metrics["net_debt_to_ebitda"].value == pytest.approx(100.0 / (op_ttm + da_ttm), rel=1e-6)
# shares 900 vs 1000 a year earlier -> -10% (buyback)
assert d.metrics["share_count_change_yoy"].value == pytest.approx(-10.0, abs=1e-6)
def test_split_suspect_share_move_suppresses_share_and_eps_comparisons():
rows = _two_years()
for row in rows:
if row.fiscal_year == 2026:
row.shares_outstanding = 2000 # +100% resembles an unadjusted 2-for-1 split
d = fd.derive(rows)
for key in ("share_count_change_yoy", "eps_growth_yoy"):
series = d.metrics[key]
assert series.value is None
assert series.history[-1].value is None
assert "possible split" in series.caveat
assert d.metrics["revenue_growth_yoy"].value == pytest.approx(10.0)
def test_valuation_inputs():
d = fd.derive(_two_years())
# TTM diluted EPS FY2026 = 1.1+1.21+1.32+1.43 = 5.06
assert d.ttm_diluted_eps == pytest.approx(5.06, abs=1e-6)
# TTM FCF = TTM cfo - TTM capex = 506*0.25 - 506*0.05 = 101.2
assert d.ttm_fcf == pytest.approx(506 * 0.20, abs=1e-6)
assert d.shares_outstanding == 900
def test_missing_period_yields_null_never_partial():
rows = _two_years()
# drop FY2026 Q3 -> discrete Q3 and Q4 (needs YTD Q3) become underivable,
# so TTM at FY2026 is null -> revenue growth null (not a partial sum)
rows = [r for r in rows if not (r.fiscal_year == 2026 and r.fiscal_period == "Q3")]
d = fd.derive(rows)
assert d.metrics["revenue_growth_yoy"].value is None
assert d.ttm_diluted_eps is None
def test_net_debt_requires_both_components():
rows = _two_years()
for r in rows: # drop debt on the latest year -> can't form net debt
if r.fiscal_year == 2026:
r.total_debt = None
d = fd.derive(rows)
assert d.metrics["net_debt"].value is None
assert d.metrics["net_debt_to_ebitda"].value is None # net debt null -> leverage null
def test_leverage_null_when_ebitda_nonpositive():
rows = _two_years()
for r in rows: # negative operating income -> TTM EBITDA <= 0
r.operating_income = -abs(r.revenue)
r.depreciation_amortization = 1
d = fd.derive(rows)
assert d.metrics["net_debt"].value == pytest.approx(100.0) # net debt still valid
assert d.metrics["net_debt_to_ebitda"].value is None # but leverage nulled
def test_tape_stops_at_a_gap():
rows = [r for r in _two_years() if not (r.fiscal_year == 2026 and r.fiscal_period == "Q1")]
d = fd.derive(rows)
hist = d.metrics["operating_margin"].history
# consecutive suffix ending at FY2026: Q2, Q3, FY (not compressed across the Q1 gap)
assert [p.period_end for p in hist] == [date(2026, 3, 31), date(2026, 6, 30), date(2026, 9, 30)]
def test_yoy_growth_null_when_prior_nonpositive():
rows = _two_years()
for r in rows: # prior-year TTM EPS becomes negative
if r.fiscal_year == 2025:
r.diluted_eps = -abs(r.diluted_eps)
d = fd.derive(rows)
assert d.metrics["eps_growth_yoy"].value is None # loss->profit is not a %
def test_amendment_selection_newest_accepted_wins():
rows = _two_years()
# an amendment to FY2026 FY restates revenue YTD higher, accepted later
amended = Snap(2026, "FY", date(2026, 9, 30), date(2026, 11, 1),
datetime(2027, 1, 1, tzinfo=UTC), revenue=999999,
operating_income=100, diluted_eps=1.43, cfo=100, capex=10,
depreciation_amortization=25, shares_outstanding=900,
cash_and_st_investments=50, total_debt=150)
d = fd.derive(rows + [amended])
# Q4 revenue discrete now uses the amended YTD(FY)=999999 minus YTD(Q3)=363
# so TTM/growth reflects the amendment, proving newest accepted_at won.
assert d.metrics["revenue_growth_yoy"].value != pytest.approx(10.0, abs=1e-6)
# -- partial amendments (A5 parity findings) ---------------------------------
def test_partial_amendment_does_not_blank_the_period():
# DVN's FY2025 10-K/A carries no financial facts at the report date. Taking
# the newest accession wholesale nulled the period, and with it the quarter
# chain, TTM and YoY.
rows = _two_years()
part_iii_only = Snap(2026, "FY", date(2026, 9, 30), date(2026, 11, 1),
datetime(2027, 1, 1, tzinfo=UTC))
baseline = fd.derive(rows)
d = fd.derive(rows + [part_iii_only])
assert d.ttm_diluted_eps == pytest.approx(baseline.ttm_diluted_eps)
assert d.metrics["revenue_growth_yoy"].value == pytest.approx(
baseline.metrics["revenue_growth_yoy"].value
)
def test_amendment_restating_one_field_leaves_the_others_intact():
rows = _two_years()
revenue_only = Snap(2026, "FY", date(2026, 9, 30), date(2026, 11, 1),
datetime(2027, 1, 1, tzinfo=UTC), revenue=999999)
baseline = fd.derive(rows)
d = fd.derive(rows + [revenue_only])
assert d.metrics["revenue_growth_yoy"].value != pytest.approx(
baseline.metrics["revenue_growth_yoy"].value
)
assert d.ttm_diluted_eps == pytest.approx(baseline.ttm_diluted_eps) # fell back
def test_same_key_row_for_a_different_period_is_never_merged():
# SEC labels two different year-ends with one fiscal_year for some filers
# (FRT, CRM). That is a mislabelled filing, not an amendment -- merging the
# two would silently blend fiscal years.
rows = _two_years()
mislabelled = Snap(2026, "FY", date(2027, 9, 30), date(2027, 11, 1),
datetime(2027, 12, 1, tzinfo=UTC), revenue=999999)
selected = fd._select_latest_per_period(rows + [mislabelled])
assert selected[(2026, "FY")] is mislabelled
# -- split safety for the TTM EPS scalar (A5 parity findings) ----------------
def _split_rows():
"""Two years where the share count jumps ~25x at the latest quarter, as
BKNG's did (31.7M -> 774.9M) when its split landed mid-window."""
rows = _two_years()
for row in rows:
if (row.fiscal_year, row.fiscal_period) == (2026, "FY"):
row.shares_outstanding = 25000.0 # vs 1000 a year earlier
return rows
def test_split_suppresses_ttm_diluted_eps():
# TTM sums four quarters of per-share values; a split inside the window
# mixes units. Unguarded this produced BKNG's P/E of 1.10, which clamps to a
# *perfect* fundamental sub-score -- worse than having no value at all.
d = fd.derive(_split_rows())
assert d.ttm_diluted_eps is None
assert d.ttm_diluted_eps_caveat == fd.SPLIT_SENSITIVE_CAVEAT
def test_ttm_diluted_eps_survives_when_no_split_is_suspected():
d = fd.derive(_two_years())
assert d.ttm_diluted_eps is not None
assert d.ttm_diluted_eps_caveat is None
def test_split_guard_leaves_dollar_scalars_alone():
# Only per-share values are split-sensitive; FCF is in dollars.
baseline = fd.derive(_two_years())
d = fd.derive(_split_rows())
assert d.ttm_fcf == pytest.approx(baseline.ttm_fcf)
# -- multi-class share-count fallback (A5 parity findings) -------------------
def test_shares_fall_back_to_weighted_average_when_cover_page_count_is_absent():
# META/CMCSA/BRK-B/CHTR report the cover-page count per share class, which is
# dimensional and therefore absent from companyfacts -- silently removing
# market cap and FCF yield for some of the largest issuers.
rows = _two_years()
for row in rows:
row.shares_outstanding = None
row.weighted_avg_diluted_shares = 2_564_000_000.0
d = fd.derive(rows)
assert d.shares_outstanding == 2_564_000_000.0
assert d.shares_outstanding_estimated is True
def test_point_in_time_share_count_is_preferred_and_not_flagged():
baseline = fd.derive(_two_years()).shares_outstanding
assert baseline is not None, "fixture should carry a cover-page count"
rows = _two_years()
for row in rows:
row.weighted_avg_diluted_shares = 1.0 # must lose to the real count
d = fd.derive(rows)
assert d.shares_outstanding == baseline
assert d.shares_outstanding_estimated is False
def test_weighted_average_fallback_survives_a_partial_amendment():
# A Part-III-only 10-K/A on a multi-class issuer's latest period: the merged
# row must keep the weighted-average count, or market cap silently vanishes.
rows = _two_years()
for row in rows:
row.shares_outstanding = None
row.weighted_avg_diluted_shares = 2_564_000_000.0
part_iii_only = Snap(2026, "FY", date(2026, 9, 30), date(2026, 11, 1),
datetime(2027, 1, 1, tzinfo=UTC))
d = fd.derive(rows + [part_iii_only])
assert d.shares_outstanding == 2_564_000_000.0
assert d.shares_outstanding_estimated is True
def test_no_share_count_at_all_stays_none_and_unflagged():
rows = _two_years()
for row in rows:
row.shares_outstanding = None
d = fd.derive(rows)
assert d.shares_outstanding is None
assert d.shares_outstanding_estimated is False
def test_merge_lists_cover_every_parser_field():
"""_MERGED_FIELDS/_CARRIED_FIELDS are hand-maintained, and _merge_amendments
builds the merged row from them alone so a parser field missing from both
is not merely stale on a merged period, it is *absent*, and callers using
getattr(row, name, None) read None. That is how weighted_avg_diluted_shares
silently lost market cap for multi-class issuers with a partial amendment.
Adding a column to SnapshotRow must fail here rather than lose data quietly.
"""
import dataclasses
from app.services.sec_facts_parser import SnapshotRow
parser_fields = {f.name for f in dataclasses.fields(SnapshotRow)}
covered = set(fd._MERGED_FIELDS) | set(fd._CARRIED_FIELDS)
assert not parser_fields - covered, (
f"parser fields not merged or carried: {sorted(parser_fields - covered)}"
)
+209
View File
@@ -0,0 +1,209 @@
"""A5 fundamentals parity report: read-only comparison + artifact archive."""
from __future__ import annotations
from datetime import date, datetime, timezone
import pytest
from sqlalchemy import func, select
from app.models.data_import_run import DataImportRun
from app.models.earnings_event import EarningsEvent
from app.models.fundamental import FundamentalData
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.ohlcv import OHLCVRecord
from app.models.ticker import Ticker
from app.services.fundamentals_parity_service import (
build_report,
fundamental_score,
load_latest,
load_latest_csv,
load_latest_json,
store_report,
)
UTC = timezone.utc
GENERATED = datetime(2026, 7, 23, 10, 30, tzinfo=UTC)
def _snapshot_rows(cik: str) -> list[FundamentalSnapshot]:
rows = []
periods = ("Q1", "Q2", "Q3", "FY")
months = (3, 6, 9, 12)
for fy, multiplier in ((2025, 1.0), (2026, 1.1)):
revenues = [100 * multiplier, 110 * multiplier, 120 * multiplier, 130 * multiplier]
eps = [1.0 * multiplier, 1.1 * multiplier, 1.2 * multiplier, 1.3 * multiplier]
for index, period in enumerate(periods):
period_end = date(fy, months[index], 28)
rows.append(
FundamentalSnapshot(
cik=cik,
accession=f"{cik}-{fy}-{period}",
form="10-K" if period == "FY" else "10-Q",
filed_date=period_end,
accepted_at=datetime(fy, months[index], 28, tzinfo=UTC),
period_end=period_end,
fiscal_year=fy,
fiscal_period=period,
revenue=sum(revenues[: index + 1]),
operating_income=sum(revenues[: index + 1]) * 0.2,
diluted_eps=sum(eps[: index + 1]),
cfo=sum(revenues[: index + 1]) * 0.25,
capex=sum(revenues[: index + 1]) * 0.05,
depreciation_amortization=sum(revenues[: index + 1]) * 0.05,
cash_and_st_investments=40,
total_debt=100,
shares_outstanding=1000,
)
)
return rows
async def _seed(db_session):
first = Ticker(symbol="AAA", cik="0000000001", sic="3571")
second = Ticker(symbol="BBB", cik=None, sic=None)
db_session.add_all([first, second])
await db_session.flush()
db_session.add_all(_snapshot_rows(first.cik))
db_session.add_all(
[
FundamentalData(
ticker_id=first.id,
pe_ratio=25,
revenue_growth=5,
earnings_surprise=0,
fetched_at=GENERATED,
),
FundamentalData(
ticker_id=second.id,
pe_ratio=12,
revenue_growth=3,
earnings_surprise=None,
fetched_at=GENERATED,
),
OHLCVRecord(
ticker_id=first.id,
date=date(2026, 7, 22),
open=100,
high=100,
low=100,
close=100,
volume=100,
),
EarningsEvent(
ticker_id=first.id,
announce_date=date(2026, 7, 1),
session="amc",
eps_estimate=2,
eps_actual=2.2,
source="dolt_earnings",
),
DataImportRun(
source="sec_facts",
revision="sec-rev",
status="promoted",
source_max_date=date(2026, 7, 22),
started_at=GENERATED,
completed_at=GENERATED,
),
DataImportRun(
source="dolt_earnings",
revision="dolt-rev",
status="no_op",
source_max_date=date(2026, 7, 22),
started_at=GENERATED,
completed_at=GENERATED,
),
]
)
await db_session.flush()
def test_score_formula_matches_production_rules():
score = fundamental_score(pe_ratio=15, revenue_growth=0, earnings_surprise=0)
assert score == pytest.approx((100 + 50 + 50) / 3)
assert fundamental_score(pe_ratio=15, revenue_growth=None, earnings_surprise=None) is None
async def test_report_compares_sources_and_leaves_database_untouched(db_session):
await _seed(db_session)
before = await db_session.scalar(select(func.count()).select_from(FundamentalData))
report = await build_report(
db_session,
generated_at=GENERATED,
today=date(2026, 7, 23),
)
after = await db_session.scalar(select(func.count()).select_from(FundamentalData))
assert before == after == 2
assert not db_session.new and not db_session.dirty and not db_session.deleted
assert report["read_only"] is True
assert report["approval_status"] == "pending_explicit_approval"
assert report["source_runs"]["sec_facts"]["revision"] == "sec-rev"
assert report["source_runs"]["dolt_earnings"]["revision"] == "dolt-rev"
first = next(row for row in report["rows"] if row["symbol"] == "AAA")
assert first["fields"]["pe_ratio"]["candidate"] == pytest.approx(
100 / 5.06, abs=1e-4
)
assert first["fields"]["revenue_growth"]["candidate"] == pytest.approx(10)
assert first["fields"]["earnings_surprise"]["candidate"] == pytest.approx(10)
assert first["scores"]["candidate_fundamental"] is not None
assert report["summary"]["universe_count"] == 2
assert report["summary"]["field_stats"]["pe_ratio"]["both_available"] == 1
async def test_artifacts_archive_and_latest_manifest(db_session, tmp_path):
await _seed(db_session)
report = await build_report(
db_session,
generated_at=GENERATED,
today=date(2026, 7, 23),
)
paths = store_report(report, tmp_path)
assert tmp_path.joinpath("latest.json").exists()
assert paths["json"].endswith(".json") and paths["csv"].endswith(".csv")
assert load_latest(tmp_path)["generated_at"] == GENERATED.isoformat()
csv_artifact = load_latest_csv(tmp_path)
assert csv_artifact is not None
assert csv_artifact[0].endswith(".csv")
assert "legacy_fundamental,candidate_fundamental" in csv_artifact[1]
assert "AAA" in csv_artifact[1]
json_artifact = load_latest_json(tmp_path)
assert json_artifact is not None and '"rows"' in json_artifact[1]
async def test_admin_endpoints_return_compact_summary_and_downloads(
client, db_session, tmp_path, monkeypatch
):
from app.config import settings
from app.dependencies import require_admin
from app.main import app
await _seed(db_session)
report = await build_report(
db_session,
generated_at=GENERATED,
today=date(2026, 7, 23),
)
store_report(report, tmp_path)
monkeypatch.setattr(settings, "fundamentals_parity_report_dir", str(tmp_path))
app.dependency_overrides[require_admin] = lambda: None
try:
summary_response = await client.get("/api/v1/admin/fundamentals-parity")
assert summary_response.status_code == 200
summary = summary_response.json()["data"]
assert summary["summary"]["universe_count"] == 2
assert "rows" not in summary
csv_response = await client.get("/api/v1/admin/fundamentals-parity/csv")
assert csv_response.status_code == 200
assert "AAA" in csv_response.json()["data"]["content"]
json_response = await client.get("/api/v1/admin/fundamentals-parity/json")
assert json_response.status_code == 200
assert '"rows"' in json_response.json()["data"]["content"]
finally:
app.dependency_overrides.pop(require_admin, None)
+58
View File
@@ -0,0 +1,58 @@
"""Tests for pure peer statistics."""
from __future__ import annotations
import math
from app.services import fundamentals_peers as pr
def test_median_ranks_at_50_tie_aware():
s = pr.peer_stat(3, [1, 2, 3, 4, 5], higher_is_better=True)
assert s.median == 3
assert s.favorable_percentile == 50 # tie-aware rank of the median
assert s.peer_count == 5
def test_all_equal_peers_rank_at_50():
s = pr.peer_stat(3, [3, 3, 3, 3, 3], higher_is_better=True)
assert s.favorable_percentile == 50 # not 100 — ties don't get full credit
def test_peer_stat_lower_is_better_flips_direction():
assert pr.peer_stat(3, [1, 2, 3, 4, 5], higher_is_better=False).favorable_percentile == 50
def test_peer_stat_unique_top_and_bottom():
assert pr.peer_stat(5, [1, 2, 3, 4, 5], higher_is_better=True).favorable_percentile == 100
assert pr.peer_stat(1, [1, 2, 3, 4, 5], higher_is_better=True).favorable_percentile == 0
def test_peer_stat_requires_min_valid_peers():
assert pr.peer_stat(3, [1, 2, 3, None], higher_is_better=True) is None # 3 valid < 5
assert pr.peer_stat(None, [1, 2, 3, 4, 5], higher_is_better=True) is None # null subject
def test_peer_stat_excludes_null_and_non_finite():
s = pr.peer_stat(3, [1, 2, 3, 4, 5, None, math.nan, math.inf, -math.inf], higher_is_better=True)
assert s.peer_count == 5 # nulls + NaN/inf dropped
# a non-finite subject is invalid
assert pr.peer_stat(math.nan, [1, 2, 3, 4, 5], higher_is_better=True) is None
def test_net_debt_is_not_peer_eligible():
assert pr.peer_stat_for("net_debt", 100, [10, 20, 30, 40, 50]) is None # size-dependent
assert pr.peer_stat_for("net_debt_to_ebitda", 1.0, [1, 2, 3, 4, 5]) is not None
def test_peer_stat_for_uses_polarity():
# pe is lower-is-better: a low pe beats most peers
s = pr.peer_stat_for("pe", 10, [10, 20, 30, 40, 50])
assert s.favorable_percentile == 100
def test_two_digit_sic():
assert pr.two_digit_sic("7372") == "73"
assert pr.two_digit_sic("3571") == "35"
assert pr.two_digit_sic(None) is None
assert pr.two_digit_sic("x") is None
@@ -0,0 +1,183 @@
from __future__ import annotations
import json
from datetime import date, datetime, timezone
from app.models.data_import_run import DataImportRun
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.sec_filing_gap import SecFilingGap
from app.models.settings import SystemSetting
from app.models.ticker import Ticker
from app.services import fundamentals_quality_service
async def test_latest_sec_validation_blocks_deferred_and_no_history_ciks(
db_session,
):
missing = Ticker(symbol="MISSING", cik="0000000001")
no_history = Ticker(symbol="NEWREG", cik="0000000002")
healthy = Ticker(symbol="HEALTHY", cik="0000000003")
db_session.add_all([missing, no_history, healthy])
await db_session.flush()
db_session.add(
SystemSetting(
key="fundamental_data_sec_dolt_cutover_enabled",
value="true",
)
)
db_session.add(
DataImportRun(
source="sec_facts",
status="deferred",
validation_json=json.dumps({
"missing_xbrl": [{"cik": missing.cik, "accession": "MISSING-Q"}],
"no_xbrl_filings": [{"cik": no_history.cik}],
}),
started_at=datetime.now(timezone.utc),
)
)
await db_session.flush()
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
missing.id,
no_history.id,
}
async def test_sec_quality_gate_is_inactive_before_cutover(db_session):
ticker = Ticker(symbol="SHADOW", cik="0000000042")
db_session.add(ticker)
await db_session.flush()
now = datetime.now(timezone.utc)
db_session.add(
SecFilingGap(
cik=ticker.cik,
accession="SHADOW-Q",
form="10-Q",
index_date=date.today(),
reason="not_in_companyfacts",
first_seen_at=now,
last_attempted_at=now,
)
)
await db_session.flush()
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
async def test_active_gap_is_blocked_until_a_later_filing_supersedes_it(db_session):
ticker = Ticker(symbol="HIST", cik="0000000043")
now = datetime.now(timezone.utc)
db_session.add_all([
ticker,
SystemSetting(
key="fundamental_data_sec_dolt_cutover_enabled",
value="true",
),
SecFilingGap(
cik=ticker.cik,
accession="HIST-Q",
form="10-Q",
index_date=date.today().replace(day=1),
reason="coregistrant_facts_rejected",
first_seen_at=now,
last_attempted_at=now,
),
])
await db_session.flush()
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
ticker.id
}
db_session.add(
FundamentalSnapshot(
cik=ticker.cik,
accession="LATER-Q",
form="10-Q",
filed_date=date.today(),
accepted_at=datetime.now(timezone.utc),
period_end=date.today(),
fiscal_year=date.today().year,
fiscal_period="Q2",
)
)
await db_session.flush()
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
async def test_gap_without_index_date_uses_first_seen_date_for_supersession(
db_session,
):
ticker = Ticker(symbol="DATELESS", cik="0000000045")
first_seen = datetime(2026, 5, 1, 12, tzinfo=timezone.utc)
db_session.add_all([
ticker,
SystemSetting(
key="fundamental_data_sec_dolt_cutover_enabled",
value="true",
),
SecFilingGap(
cik=ticker.cik,
accession="DATELESS-Q",
form="10-Q",
index_date=None,
reason="not_in_companyfacts",
first_seen_at=first_seen,
last_attempted_at=first_seen,
),
])
await db_session.flush()
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
ticker.id
}
db_session.add(
FundamentalSnapshot(
cik=ticker.cik,
accession="LATER-DATELESS-Q",
form="10-Q",
filed_date=date(2026, 5, 2),
accepted_at=datetime(2026, 5, 2, 12, tzinfo=timezone.utc),
period_end=date(2026, 3, 31),
fiscal_year=2026,
fiscal_period="Q1",
)
)
await db_session.flush()
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
async def test_ticker_quality_explains_no_xbrl_block(db_session):
ticker = Ticker(symbol="NEWREG", cik="0000000044")
db_session.add_all([
ticker,
SystemSetting(
key="fundamental_data_sec_dolt_cutover_enabled",
value="true",
),
DataImportRun(
source="sec_facts",
status="promoted",
validation_json=json.dumps({
"setup_blocked_ciks": [ticker.cik],
"no_xbrl_ciks": [ticker.cik],
"no_xbrl_filings": [],
}),
started_at=datetime.now(timezone.utc),
),
])
await db_session.flush()
quality = await fundamentals_quality_service.ticker_quality(db_session, "NEWREG")
assert quality.eligible is False
assert quality.code == "no_xbrl_filings"
assert "CIK override" in (quality.message or "")
assert await fundamentals_quality_service.ticker_is_eligible(
db_session, ticker.id
) is False
+63
View File
@@ -0,0 +1,63 @@
"""Tests for deterministic text reads, incl. threshold boundaries."""
from __future__ import annotations
from types import SimpleNamespace
from app.services import fundamentals_reads as rd
def _hist(*values):
return [SimpleNamespace(value=v, period_end=None) for v in values]
def test_growth_read_boundaries():
assert rd.growth_read(_hist(5, 6, 8)) == "accelerating" # +2.0 exactly (>=)
assert rd.growth_read(_hist(5, 6, 7.9)) == "steady" # +1.9 < 2.0
assert rd.growth_read(_hist(10, 9, 7)) == "decelerating" # -2.0 exactly
assert rd.growth_read(_hist(5, 6)) is None # < 3 periods
def test_reads_use_latest_nonnull_suffix():
# latest displayed value is n/a -> no read (never reflect a null latest)
assert rd.growth_read(_hist(5, 6, 8, None)) is None
assert rd.margin_read(_hist(19, 20, 22, None)) is None
# an internal gap truncates the run -> fewer than 3 consecutive -> no read
assert rd.growth_read(_hist(5, 6, None, 8)) is None
# a clean 3-run after an older gap still reads
assert rd.growth_read(_hist(None, 5, 6, 8)) == "accelerating"
def test_margin_read_vs_mean_of_prior():
# prior mean = (19+20)/2 = 19.5; latest 21 -> +1.5 -> improving
assert rd.margin_read(_hist(19, 20, 21)) == "improving"
# latest exactly +1.0 over prior mean -> improving
assert rd.margin_read(_hist(20, 20, 21)) == "improving"
# within band
assert rd.margin_read(_hist(20, 20, 20.5)) == "stable"
assert rd.margin_read(_hist(21, 20)) is None # < 3 periods
def test_share_count_read():
assert rd.share_count_read(1.8) == "1.8% dilution"
assert rd.share_count_read(-2.0) == "buying back"
assert rd.share_count_read(1.0) == "flat" # boundary: not > 1.0
assert rd.share_count_read(None) is None
def test_peer_read_bands_and_polarity_phrasing():
assert rd.peer_read("operating_margin", 60) == "above peers" # boundary favorable
assert rd.peer_read("operating_margin", 40) == "below peers" # boundary adverse
assert rd.peer_read("operating_margin", 50) == "in line"
assert rd.peer_read("pe", 65) == "attractively valued"
assert rd.peer_read("pe", 30) == "priced above peers"
assert rd.peer_read("net_debt_to_ebitda", 20) == "elevated leverage"
assert rd.peer_read("pe", None) is None
def test_header_sentence_omits_missing_segments():
assert rd.header_sentence("accelerating", "stable", "priced above peers") == (
"growth accelerating · margins stable · valuation priced above peers"
)
assert rd.header_sentence(None, "improving", None) == "margins improving"
assert rd.header_sentence(None, None, None) == ""
+78 -4
View File
@@ -6,6 +6,8 @@ from datetime import date, timedelta
import pytest import pytest
from app.models.ohlcv import OHLCVRecord
from app.models.settings import IngestionProgress
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.providers.protocol import OHLCVData from app.providers.protocol import OHLCVData
from app.services import ingestion_service as svc from app.services import ingestion_service as svc
@@ -18,9 +20,12 @@ async def session():
yield s yield s
async def _add_ticker(session, symbol: str) -> None: async def _add_ticker(session, symbol: str) -> Ticker:
session.add(Ticker(symbol=symbol)) ticker = Ticker(symbol=symbol)
session.add(ticker)
await session.commit() await session.commit()
await session.refresh(ticker)
return ticker
def _bars(symbol: str, n: int) -> list[OHLCVData]: def _bars(symbol: str, n: int) -> list[OHLCVData]:
@@ -51,6 +56,50 @@ async def test_happy_path_ingests_bars(session):
assert result.records_ingested == 3 assert result.records_ingested == 3
async def test_incremental_fetch_overlaps_latest_session_and_updates_partial_bar(session):
"""Once today exists, a live refresh must fetch and overwrite it again."""
ticker = await _add_ticker(session, "LIVE")
today = date.today()
session.add_all([
OHLCVRecord(
ticker_id=ticker.id,
date=today - timedelta(days=i),
open=100.0,
high=101.0,
low=99.0,
close=100.0,
volume=1000,
)
for i in range(200)
])
session.add(IngestionProgress(ticker_id=ticker.id, last_ingested_date=today))
await session.commit()
provider = MockMarketDataProvider(ohlcv_data=[
OHLCVData(
ticker="LIVE",
date=today,
open=100.0,
high=124.0,
low=99.0,
close=123.0,
volume=2000,
)
])
result = await svc.fetch_and_ingest(session, provider, "LIVE")
assert provider.calls == [{
"ticker": "LIVE",
"start_date": today,
"end_date": today,
}]
assert result.status == "complete"
assert result.records_ingested == 1
records = await svc.price_service.query_ohlcv(session, "LIVE", today, today)
assert records[0].close == 123.0
assert records[0].volume == 2000
async def test_empty_fetch_with_existing_history_is_up_to_date(session): async def test_empty_fetch_with_existing_history_is_up_to_date(session):
# Covered ticker, just no new bars in the window → complete, not no_data. # Covered ticker, just no new bars in the window → complete, not no_data.
await _add_ticker(session, "BBB") await _add_ticker(session, "BBB")
@@ -81,9 +130,34 @@ async def test_empty_fetch_with_stale_history_reports_stale(session):
] ]
await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=old), "SATS") await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=old), "SATS")
result = await svc.fetch_and_ingest(session, MockMarketDataProvider(ohlcv_data=[]), "SATS") # Incremental overlap means Alpaca can keep returning the final historical
# bar. That is still stale: the latest session did not advance.
result = await svc.fetch_and_ingest(
session,
MockMarketDataProvider(ohlcv_data=[old[-1]]),
"SATS",
)
assert result.status == "stale" assert result.status == "stale"
assert result.records_ingested == 0 assert result.records_ingested == 1
assert result.last_date is not None assert result.last_date is not None
assert "renamed" in (result.message or "").lower() or "halted" in (result.message or "").lower() assert "renamed" in (result.message or "").lower() or "halted" in (result.message or "").lower()
async def test_ingest_can_skip_sr_refresh_when_scanner_follows(session, monkeypatch):
await _add_ticker(session, "SCAN")
calls: list[str] = []
async def fake_refresh(db, symbol):
calls.append(symbol)
monkeypatch.setattr(svc, "_refresh_structural_sr", fake_refresh)
result = await svc.fetch_and_ingest(
session,
MockMarketDataProvider(ohlcv_data=_bars("SCAN", 3)),
"SCAN",
refresh_sr=False,
)
assert result.status == "complete"
assert calls == []

Some files were not shown because too many files have changed in this diff Show More